Files
gridpilot.gg/apps/website/eslint-rules/server-actions-must-use-mutations.js
2026-01-13 00:16:14 +01:00

131 lines
4.1 KiB
JavaScript

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