/** * 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' }); } }); }, }; }, };