/** * ESLint Rule: Mutation Must Map Errors * * Enforces the architecture documented in `lib/contracts/mutations/Mutation.ts`: * - Mutations call Services. * - Service returns Result<..., DomainError>. * - Mutation maps DomainError -> MutationError via mapToMutationError. * - Mutation returns Result<..., MutationError>. * * Strict enforcement (scoped to service results): * - Disallow returning a Service Result directly. * - `return result;` where `result` came from `await service.*(...)` * - `return service.someMethod(...)` * - `return await service.someMethod(...)` * - If a mutation does `if (result.isErr()) return Result.err(...)`, * it must be `Result.err(mapToMutationError(result.getError()))`. */ module.exports = { meta: { type: 'problem', docs: { description: 'Require mutations to map service errors via mapToMutationError', category: 'Architecture', recommended: true, }, schema: [], messages: { mustMapMutationError: 'Mutations must map service errors via mapToMutationError(result.getError()) before returning Result.err(...) - see apps/website/lib/contracts/mutations/Mutation.ts', mustNotReturnServiceResult: 'Mutations must not return a Service Result directly; map errors and return a Mutation-layer Result instead - see apps/website/lib/contracts/mutations/Mutation.ts', }, }, create(context) { const filename = context.getFilename(); if (!filename.includes('/lib/mutations/') || !filename.endsWith('.ts')) { return {}; } const resultVarsFromServiceCall = new Set(); const serviceVarNames = new Set(); const mapToMutationErrorNames = new Set(['mapToMutationError']); 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 isMapToMutationErrorCall(node) { return ( node && node.type === 'CallExpression' && node.callee && node.callee.type === 'Identifier' && mapToMutationErrorNames.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 isServiceObjectName(name) { return typeof name === 'string' && (name.endsWith('Service') || serviceVarNames.has(name)); } function isServiceMemberCallExpression(callExpression) { if (!callExpression || callExpression.type !== 'CallExpression') return false; const callee = callExpression.callee; if (!callee || callee.type !== 'MemberExpression' || callee.computed) return false; if (!callee.object || callee.object.type !== 'Identifier') return false; return isServiceObjectName(callee.object.name); } function isAwaitedServiceCall(init) { if (!init || init.type !== 'AwaitExpression') return false; return isServiceMemberCallExpression(init.argument); } 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 isMutationErrorImport = importPath.includes('/lib/contracts/mutations/MutationError') || importPath.endsWith('lib/contracts/mutations/MutationError') || importPath.endsWith('contracts/mutations/MutationError') || importPath.endsWith('contracts/mutations/MutationError.ts'); if (!isMutationErrorImport) return; for (const spec of node.specifiers || []) { // import { mapToMutationError as X } from '...' if (spec.type === 'ImportSpecifier' && spec.imported && spec.imported.type === 'Identifier') { if (spec.imported.name === 'mapToMutationError') { mapToMutationErrorNames.add(spec.local.name); } } } }, VariableDeclarator(node) { if (!node.id || node.id.type !== 'Identifier') return; // const service = new SomeService(); if (node.init && node.init.type === 'NewExpression' && node.init.callee && node.init.callee.type === 'Identifier') { if (node.init.callee.name.endsWith('Service')) { serviceVarNames.add(node.id.name); } } // const result = await service.method(...) if (!isAwaitedServiceCall(node.init)) return; resultVarsFromServiceCall.add(node.id.name); }, ReturnStatement(node) { if (!node.argument) return; // return result; if (node.argument.type === 'Identifier') { if (resultVarsFromServiceCall.has(node.argument.name)) { context.report({ node, messageId: 'mustNotReturnServiceResult' }); } return; } // return service.method(...) if (node.argument.type === 'CallExpression') { if (isServiceMemberCallExpression(node.argument)) { context.report({ node, messageId: 'mustNotReturnServiceResult' }); } return; } // return await service.method(...) if (node.argument.type === 'AwaitExpression') { if (isServiceMemberCallExpression(node.argument.argument)) { context.report({ node, messageId: 'mustNotReturnServiceResult' }); } } }, IfStatement(node) { // if (result.isErr()) { return Result.err(...) } 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 (!isMapToMutationErrorCall(errArg)) { context.report({ node: returnNode, messageId: 'mustMapMutationError' }); return; } const mappedFrom = errArg.arguments && errArg.arguments[0]; if (!isGetErrorCall(mappedFrom, resultVarName)) { context.report({ node: returnNode, messageId: 'mustMapMutationError' }); } }); }, }; }, };