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

228 lines
7.8 KiB
JavaScript

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