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

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