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