Files
gridpilot.gg/apps/website/eslint-rules/server-actions-interface.js
2026-01-14 02:02:24 +01:00

243 lines
8.1 KiB
JavaScript

/**
* 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;
}