/** * ESLint Rule: Server Actions Must Use Mutations * * Ensures server actions use mutations instead of direct service calls */ module.exports = { meta: { type: 'problem', docs: { description: 'Ensure server actions use mutations instead of direct service calls', category: 'Server Actions', recommended: true, }, messages: { mustUseMutations: 'Server actions must use Mutations, not direct Service or API Client calls. See apps/website/docs/architecture/write/MUTATIONS.md', noDirectService: 'Direct service calls in server actions are not allowed', noMutationUsage: 'Server actions should instantiate and call mutations', }, schema: [], }, create(context) { const filename = context.getFilename(); const isServerAction = filename.includes('/app/') && (filename.endsWith('.ts') || filename.endsWith('.tsx')) && !filename.endsWith('.test.ts') && !filename.endsWith('.test.tsx'); if (!isServerAction) { return {}; } let hasUseServerDirective = false; let hasServiceImport = false; let hasApiClientImport = false; let hasMutationImport = false; const newExpressions = []; const callExpressions = []; return { // Check for 'use server' directive ExpressionStatement(node) { if (node.expression.type === 'Literal' && node.expression.value === 'use server') { hasUseServerDirective = true; } }, // Check imports ImportDeclaration(node) { const importPath = node.source.value; if (importPath.includes('/lib/services/')) { hasServiceImport = true; } if (importPath.includes('/lib/api/') || importPath.includes('/api/')) { hasApiClientImport = true; } if (importPath.includes('/lib/mutations/')) { hasMutationImport = true; } }, // Track all NewExpression (instantiations) NewExpression(node) { newExpressions.push(node); }, // Track all CallExpression (method calls) CallExpression(node) { callExpressions.push(node); }, 'Program:exit'() { // Only check files with 'use server' directive if (!hasUseServerDirective) return; // Check for direct service/API client instantiation const hasDirectServiceInstantiation = newExpressions.some(node => { if (node.callee.type === 'Identifier') { const calleeName = node.callee.name; return calleeName.endsWith('Service') || calleeName.endsWith('ApiClient') || calleeName.endsWith('Client'); } return false; }); // Check for direct service method calls const hasDirectServiceCall = callExpressions.some(node => { if (node.callee.type === 'MemberExpression' && node.callee.object.type === 'Identifier') { const objName = node.callee.object.name; return objName.endsWith('Service') || objName.endsWith('ApiClient'); } return false; }); // Check if mutations are being used const hasMutationUsage = newExpressions.some(node => { if (node.callee.type === 'Identifier') { return node.callee.name.endsWith('Mutation'); } return false; }); // Report violations if (hasDirectServiceInstantiation && !hasMutationUsage) { context.report({ node: context.getSourceCode().ast, messageId: 'mustUseMutations', }); } if (hasDirectServiceCall) { context.report({ node: context.getSourceCode().ast, messageId: 'noDirectService', }); } // If imports exist but no mutation usage if ((hasServiceImport || hasApiClientImport) && !hasMutationImport) { context.report({ node: context.getSourceCode().ast, messageId: 'mustUseMutations', }); } }, }; }, };