/** * ESLint Rule: Server Actions Interface Compliance * * Ensures server actions follow a specific interface pattern: * - Must be async functions * - Must use mutations for business logic * - Must handle errors properly using Result type * - Should not contain business logic directly */ module.exports = { meta: { type: 'problem', docs: { description: 'Ensure server actions implement the correct interface pattern', category: 'Server Actions', recommended: true, }, messages: { mustBeAsync: 'Server actions must be async functions', mustUseMutations: 'Server actions must use mutations for business logic', mustHandleErrors: 'Server actions must handle errors using Result type', noBusinessLogic: 'Server actions should be thin wrappers, business logic belongs in mutations', invalidActionPattern: 'Server actions should follow pattern: async function actionName(input) { const result = await mutation.execute(input); return result; }', missingMutationUsage: 'Server action must instantiate and call a mutation', }, schema: [], }, create(context) { const filename = context.getFilename(); const isServerActionFile = filename.includes('/app/') && (filename.endsWith('.ts') || filename.endsWith('.tsx')) && !filename.endsWith('.test.ts') && !filename.endsWith('.test.tsx'); if (!isServerActionFile) { return {}; } let hasUseServerDirective = false; const serverActionFunctions = []; return { // Check for 'use server' directive ExpressionStatement(node) { if (node.expression.type === 'Literal' && node.expression.value === 'use server') { hasUseServerDirective = true; } }, // Track async function declarations FunctionDeclaration(node) { if (node.async && node.id && node.id.name) { serverActionFunctions.push({ name: node.id.name, node: node, body: node.body, params: node.params, }); } }, // Track async arrow function exports ExportNamedDeclaration(node) { if (node.declaration && node.declaration.type === 'FunctionDeclaration') { if (node.declaration.async && node.declaration.id) { serverActionFunctions.push({ name: node.declaration.id.name, node: node.declaration, body: node.declaration.body, params: node.declaration.params, }); } } else if (node.declaration && node.declaration.type === 'VariableDeclaration') { node.declaration.declarations.forEach(decl => { if (decl.init && decl.init.type === 'ArrowFunctionExpression' && decl.init.async) { serverActionFunctions.push({ name: decl.id.name, node: decl.init, body: decl.init.body, params: decl.init.params, }); } }); } }, 'Program:exit'() { // Only check files with 'use server' directive if (!hasUseServerDirective) return; // Check each server action function serverActionFunctions.forEach(func => { // Check if function is async if (!func.node.async) { context.report({ node: func.node, messageId: 'mustBeAsync', }); } // Analyze function body for mutation usage and error handling const bodyAnalysis = analyzeFunctionBody(func.body); if (!bodyAnalysis.hasMutationUsage) { context.report({ node: func.node, messageId: 'missingMutationUsage', }); } if (!bodyAnalysis.hasErrorHandling) { context.report({ node: func.node, messageId: 'mustHandleErrors', }); } if (bodyAnalysis.hasBusinessLogic) { context.report({ node: func.node, messageId: 'noBusinessLogic', }); } // Check if function follows the expected pattern if (!bodyAnalysis.followsPattern) { context.report({ node: func.node, messageId: 'invalidActionPattern', }); } }); }, }; }, }; /** * Analyze function body to check for proper patterns */ function analyzeFunctionBody(body) { const analysis = { hasMutationUsage: false, hasErrorHandling: false, hasBusinessLogic: false, followsPattern: false, }; if (!body || body.type !== 'BlockStatement') { return analysis; } const statements = body.body; let hasMutationInstantiation = false; let hasMutationExecute = false; let hasResultCheck = false; let hasReturnResult = false; // Check for mutation usage in a more flexible way statements.forEach(stmt => { // Look for: const mutation = new SomeMutation() if (stmt.type === 'VariableDeclaration') { stmt.declarations.forEach(decl => { if (decl.init && decl.init.type === 'NewExpression') { if (decl.init.callee.type === 'Identifier' && decl.init.callee.name.endsWith('Mutation')) { hasMutationInstantiation = true; } } // Look for: const result = await mutation.execute(...) if (decl.init && decl.init.type === 'AwaitExpression') { const awaitExpr = decl.init.argument; if (awaitExpr.type === 'CallExpression') { const callee = awaitExpr.callee; if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier' && callee.property.name === 'execute') { // Check if the object is a mutation or result of mutation if (callee.object.type === 'Identifier') { const objName = callee.object.name; // Could be 'mutation' or 'result' (from mutation.execute) if (objName === 'mutation' || objName === 'result' || objName.endsWith('Mutation')) { hasMutationExecute = true; } } } } } }); } // Look for error handling: if (result.isErr()) if (stmt.type === 'IfStatement') { if (stmt.test.type === 'CallExpression') { const callee = stmt.test.callee; if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier' && (callee.property.name === 'isErr' || callee.property.name === 'isOk')) { hasResultCheck = true; } } } // Look for return statements that return Result if (stmt.type === 'ReturnStatement' && stmt.argument) { if (stmt.argument.type === 'CallExpression') { const callee = stmt.argument.callee; if (callee.type === 'MemberExpression' && callee.object.type === 'Identifier' && callee.object.name === 'Result') { hasReturnResult = true; } } } }); analysis.hasMutationUsage = hasMutationInstantiation && hasMutationExecute; analysis.hasErrorHandling = hasResultCheck; analysis.hasReturnResult = hasReturnResult; // Check if follows pattern: mutation instantiation → execute → error handling → return Result analysis.followsPattern = analysis.hasMutationUsage && analysis.hasErrorHandling && analysis.hasReturnResult; // Check for business logic (direct service calls, complex calculations, etc.) const hasDirectServiceCall = statements.some(stmt => { if (stmt.type === 'ExpressionStatement' && stmt.expression.type === 'CallExpression') { const callee = stmt.expression.callee; if (callee.type === 'MemberExpression' && callee.object.type === 'Identifier') { return callee.object.name.endsWith('Service'); } } return false; }); analysis.hasBusinessLogic = hasDirectServiceCall; return analysis; }