140 lines
4.4 KiB
JavaScript
140 lines
4.4 KiB
JavaScript
/**
|
|
* ESLint Rule: Mutation Contract
|
|
*
|
|
* Enforces the basic Mutation contract:
|
|
* - Mutation classes should `implement Mutation<...>` (type-level contract)
|
|
* - `execute()` must return `Promise<Result<...>>`
|
|
*/
|
|
|
|
module.exports = {
|
|
meta: {
|
|
type: 'problem',
|
|
docs: {
|
|
description: 'Ensure mutations implement the Mutation contract and return Result',
|
|
category: 'Mutation Contract',
|
|
recommended: true,
|
|
},
|
|
messages: {
|
|
mustImplementMutationInterface:
|
|
'Mutation classes must implement Mutation<TInput, TOutput, TError> - see apps/website/lib/contracts/mutations/Mutation.ts',
|
|
wrongReturnType:
|
|
'Mutation execute() must return Promise<Result<TOutput, TError>> - see apps/website/lib/contracts/Result.ts',
|
|
},
|
|
schema: [],
|
|
},
|
|
|
|
create(context) {
|
|
const filename = context.getFilename();
|
|
|
|
if (!filename.includes('/lib/mutations/') || !filename.endsWith('.ts')) {
|
|
return {};
|
|
}
|
|
|
|
const mutationInterfaceNames = new Set(['Mutation']);
|
|
|
|
function isIdentifier(node, nameSet) {
|
|
return node && node.type === 'Identifier' && nameSet.has(node.name);
|
|
}
|
|
|
|
function isPromiseType(typeAnnotation) {
|
|
if (!typeAnnotation || typeAnnotation.type !== 'TSTypeReference') return false;
|
|
const typeName = typeAnnotation.typeName;
|
|
return typeName && typeName.type === 'Identifier' && typeName.name === 'Promise';
|
|
}
|
|
|
|
function isResultType(typeNode) {
|
|
if (!typeNode || typeNode.type !== 'TSTypeReference') return false;
|
|
const typeName = typeNode.typeName;
|
|
if (!typeName) return false;
|
|
|
|
// Common case: Result<...>
|
|
if (typeName.type === 'Identifier') {
|
|
return typeName.name === 'Result';
|
|
}
|
|
|
|
// Fallback: handle qualified names (rare)
|
|
if (typeName.type === 'TSQualifiedName') {
|
|
return typeName.right && typeName.right.type === 'Identifier' && typeName.right.name === 'Result';
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
return {
|
|
ImportDeclaration(node) {
|
|
const importPath = node.source && node.source.value;
|
|
if (typeof importPath !== 'string') return;
|
|
|
|
// Accept both alias and relative imports.
|
|
const isMutationContractImport =
|
|
importPath.includes('/lib/contracts/mutations/Mutation') ||
|
|
importPath.endsWith('lib/contracts/mutations/Mutation') ||
|
|
importPath.endsWith('contracts/mutations/Mutation') ||
|
|
importPath.endsWith('contracts/mutations/Mutation.ts');
|
|
|
|
if (!isMutationContractImport) return;
|
|
|
|
for (const spec of node.specifiers || []) {
|
|
// import { Mutation as X } from '...'
|
|
if (spec.type === 'ImportSpecifier' && spec.imported && spec.imported.type === 'Identifier') {
|
|
mutationInterfaceNames.add(spec.local.name);
|
|
}
|
|
}
|
|
},
|
|
|
|
ClassDeclaration(node) {
|
|
if (!node.id || node.id.type !== 'Identifier') return;
|
|
if (!node.id.name.endsWith('Mutation')) return;
|
|
|
|
const implementsNodes = node.implements || [];
|
|
const implementsMutation = implementsNodes.some((impl) => {
|
|
// `implements Mutation<...>`
|
|
return impl && isIdentifier(impl.expression, mutationInterfaceNames);
|
|
});
|
|
|
|
if (!implementsMutation) {
|
|
context.report({
|
|
node: node.id,
|
|
messageId: 'mustImplementMutationInterface',
|
|
});
|
|
}
|
|
},
|
|
|
|
MethodDefinition(node) {
|
|
if (node.key.type !== 'Identifier' || node.key.name !== 'execute') return;
|
|
if (!node.value) return;
|
|
|
|
const returnType = node.value.returnType;
|
|
const typeAnnotation = returnType && returnType.typeAnnotation;
|
|
|
|
// Must be Promise<...>
|
|
if (!isPromiseType(typeAnnotation)) {
|
|
context.report({
|
|
node,
|
|
messageId: 'wrongReturnType',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const promiseTypeArgs = typeAnnotation.typeParameters;
|
|
if (!promiseTypeArgs || !promiseTypeArgs.params || promiseTypeArgs.params.length === 0) {
|
|
context.report({
|
|
node,
|
|
messageId: 'wrongReturnType',
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Must be Promise<Result<...>> (we don't constrain the generics here)
|
|
const inner = promiseTypeArgs.params[0];
|
|
if (!isResultType(inner)) {
|
|
context.report({
|
|
node,
|
|
messageId: 'wrongReturnType',
|
|
});
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|