Files
gridpilot.gg/apps/website/eslint-rules/page-query-must-map-errors.js
2026-01-14 02:02:24 +01:00

193 lines
6.6 KiB
JavaScript

/**
* ESLint Rule: PageQuery Must Map Errors
*
* Enforces the architecture documented in `lib/contracts/page-queries/PageQuery.ts`:
* - PageQueries call Services.
* - Service returns Result<..., DomainError>.
* - PageQuery maps DomainError -> PresentationError via mapToPresentationError.
* - PageQuery returns Result<..., PresentationError>.
*
* Strict enforcement (scoped to service-result error branches):
* - If a page query does `const result = await someService.*(...)` and later does
* `if (result.isErr()) return Result.err(...)`, then the `Result.err(...)`
* must be `Result.err(mapToPresentationError(result.getError()))`.
* - PageQueries must not `return result;` for a service result variable.
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Require PageQueries to map service errors via mapToPresentationError',
category: 'Architecture',
recommended: true,
},
schema: [],
messages: {
mustMapPresentationError:
'PageQueries must map service errors via mapToPresentationError(result.getError()) before returning Result.err(...) - see apps/website/lib/contracts/page-queries/PageQuery.ts',
mustNotReturnServiceResult:
'PageQueries must not return a Service Result directly; map errors and return a Presentation-layer Result instead - see apps/website/lib/contracts/page-queries/PageQuery.ts',
},
},
create(context) {
const filename = context.getFilename();
if (!filename.includes('/lib/page-queries/') || !filename.endsWith('.ts')) {
return {};
}
const resultVarsFromServiceCall = new Set();
const mapToPresentationErrorNames = new Set(['mapToPresentationError']);
function isIdentifier(node, name) {
return node && node.type === 'Identifier' && node.name === name;
}
function isResultErrCall(node) {
return (
node &&
node.type === 'CallExpression' &&
node.callee &&
node.callee.type === 'MemberExpression' &&
!node.callee.computed &&
node.callee.object &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'Result' &&
node.callee.property &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'err'
);
}
function isMapToPresentationErrorCall(node) {
return (
node &&
node.type === 'CallExpression' &&
node.callee &&
node.callee.type === 'Identifier' &&
mapToPresentationErrorNames.has(node.callee.name)
);
}
function isGetErrorCall(node, resultVarName) {
return (
node &&
node.type === 'CallExpression' &&
node.callee &&
node.callee.type === 'MemberExpression' &&
!node.callee.computed &&
node.callee.object &&
isIdentifier(node.callee.object, resultVarName) &&
node.callee.property &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'getError'
);
}
function isAwaitedServiceCall(init) {
if (!init || init.type !== 'AwaitExpression') return false;
const call = init.argument;
if (!call || call.type !== 'CallExpression') return false;
const callee = call.callee;
// `await service.someMethod(...)`
if (callee.type === 'MemberExpression' && callee.object && callee.object.type === 'Identifier') {
return callee.object.name.endsWith('Service');
}
return false;
}
function collectReturnStatements(statementNode, callback) {
if (!statementNode) return;
if (statementNode.type !== 'BlockStatement') {
if (statementNode.type === 'ReturnStatement') callback(statementNode);
return;
}
for (const s of statementNode.body || []) {
if (!s) continue;
if (s.type === 'ReturnStatement') callback(s);
}
}
return {
ImportDeclaration(node) {
const importPath = node.source && node.source.value;
if (typeof importPath !== 'string') return;
const isPresentationErrorImport =
importPath.includes('/lib/contracts/page-queries/PresentationError') ||
importPath.endsWith('lib/contracts/page-queries/PresentationError') ||
importPath.endsWith('contracts/page-queries/PresentationError') ||
importPath.endsWith('contracts/page-queries/PresentationError.ts');
if (!isPresentationErrorImport) return;
for (const spec of node.specifiers || []) {
// import { mapToPresentationError as X } from '...'
if (spec.type === 'ImportSpecifier' && spec.imported && spec.imported.type === 'Identifier') {
if (spec.imported.name === 'mapToPresentationError') {
mapToPresentationErrorNames.add(spec.local.name);
}
}
}
},
VariableDeclarator(node) {
if (!node.id || node.id.type !== 'Identifier') return;
if (!isAwaitedServiceCall(node.init)) return;
resultVarsFromServiceCall.add(node.id.name);
},
ReturnStatement(node) {
if (node.argument && node.argument.type === 'Identifier') {
if (resultVarsFromServiceCall.has(node.argument.name)) {
context.report({ node, messageId: 'mustNotReturnServiceResult' });
}
}
},
IfStatement(node) {
const test = node.test;
if (!test || test.type !== 'CallExpression') return;
const testCallee = test.callee;
if (
!testCallee ||
testCallee.type !== 'MemberExpression' ||
testCallee.computed ||
!testCallee.object ||
testCallee.object.type !== 'Identifier' ||
!testCallee.property ||
testCallee.property.type !== 'Identifier' ||
testCallee.property.name !== 'isErr'
) {
return;
}
const resultVarName = testCallee.object.name;
if (!resultVarsFromServiceCall.has(resultVarName)) return;
collectReturnStatements(node.consequent, (returnNode) => {
const arg = returnNode.argument;
if (!isResultErrCall(arg)) return;
const errArg = arg.arguments && arg.arguments[0];
if (!isMapToPresentationErrorCall(errArg)) {
context.report({ node: returnNode, messageId: 'mustMapPresentationError' });
return;
}
const mappedFrom = errArg.arguments && errArg.arguments[0];
if (!isGetErrorCall(mappedFrom, resultVarName)) {
context.report({ node: returnNode, messageId: 'mustMapPresentationError' });
}
});
},
};
},
};