228 lines
7.8 KiB
JavaScript
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' });
|
|
}
|
|
});
|
|
},
|
|
};
|
|
},
|
|
};
|