/** * ESLint Rule: Mutation Contract * * Enforces the basic Mutation contract: * - Mutation classes should `implement Mutation<...>` (type-level contract) * - `execute()` must return `Promise>` */ 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 - see apps/website/lib/contracts/mutations/Mutation.ts', wrongReturnType: 'Mutation execute() must return Promise> - 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> (we don't constrain the generics here) const inner = promiseTypeArgs.params[0]; if (!isResultType(inner)) { context.report({ node, messageId: 'wrongReturnType', }); } }, }; }, };