Files
gridpilot.gg/apps/website/eslint-rules/mutation-must-use-builders.js
2026-01-14 02:02:24 +01:00

211 lines
7.9 KiB
JavaScript

/**
* ESLint Rule: Mutation Must Use Builders
*
* Ensures mutations use builders to transform DTOs into ViewData
* or return appropriate simple types (void, string, etc.)
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure mutations use builders or return appropriate types, not DTOs',
category: 'Mutation',
recommended: true,
},
messages: {
mustUseBuilder: 'Mutations must use ViewDataBuilder to transform DTOs or return simple types - see apps/website/lib/contracts/builders/ViewDataBuilder.ts',
noDirectDtoReturn: 'Mutations must not return DTOs directly, use builders to create ViewData or return simple types like void, string, or primitive values',
},
schema: [],
},
create(context) {
const filename = context.getFilename();
const isMutation = filename.includes('/lib/mutations/') && filename.endsWith('.ts');
if (!isMutation) {
return {};
}
let hasBuilderImport = false;
const returnStatements = [];
const methodDefinitions = [];
return {
// Check for builder imports
ImportDeclaration(node) {
const importPath = node.source.value;
if (importPath.includes('/builders/view-data/')) {
hasBuilderImport = true;
}
},
// Track method definitions
MethodDefinition(node) {
if (node.key.type === 'Identifier' && node.key.name === 'execute') {
methodDefinitions.push(node);
}
},
// Track return statements in execute method
ReturnStatement(node) {
// Only track returns inside execute method
let parent = node;
while (parent) {
if (parent.type === 'MethodDefinition' &&
parent.key.type === 'Identifier' &&
parent.key.name === 'execute') {
if (node.argument) {
returnStatements.push(node);
}
break;
}
parent = parent.parent;
}
},
'Program:exit'() {
if (methodDefinitions.length === 0) {
return; // No execute method found
}
// Check each return statement in execute method
returnStatements.forEach(returnNode => {
const returnExpr = returnNode.argument;
if (!returnExpr) return;
// Check if it's a Result type
if (returnExpr.type === 'CallExpression') {
const callee = returnExpr.callee;
// Check for Result.ok() or Result.err()
if (callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'Result' &&
callee.property.type === 'Identifier' &&
(callee.property.name === 'ok' || callee.property.name === 'err')) {
const resultArg = returnExpr.arguments[0];
if (callee.property.name === 'ok') {
// Check what's being returned in Result.ok()
// If it's a builder call, that's good
if (resultArg && resultArg.type === 'CallExpression') {
const builderCallee = resultArg.callee;
if (builderCallee.type === 'MemberExpression' &&
builderCallee.property.type === 'Identifier' &&
builderCallee.property.name === 'build') {
return; // Good: using builder
}
}
// If it's a method call that might be unwrap() or similar
if (resultArg && resultArg.type === 'CallExpression') {
const methodCallee = resultArg.callee;
if (methodCallee.type === 'MemberExpression' &&
methodCallee.property.type === 'Identifier') {
const methodName = methodCallee.property.name;
// Common DTO extraction methods
if (['unwrap', 'getValue', 'getData', 'getResult'].includes(methodName)) {
context.report({
node: returnNode,
messageId: 'noDirectDtoReturn',
});
return;
}
}
}
// If it's a simple type (string, number, boolean, void), that's OK
if (resultArg && (
resultArg.type === 'Literal' ||
resultArg.type === 'Identifier' &&
['void', 'undefined', 'null'].includes(resultArg.name) ||
resultArg.type === 'UnaryExpression' // e.g., undefined
)) {
return; // Good: simple type
}
// If it's an identifier that might be a DTO
if (resultArg && resultArg.type === 'Identifier') {
const varName = resultArg.name;
// Check if it's likely a DTO (ends with DTO, or common patterns)
if (varName.match(/(DTO|Result|Response|Data)$/i) &&
!varName.includes('ViewData') &&
!varName.includes('ViewModel')) {
context.report({
node: returnNode,
messageId: 'noDirectDtoReturn',
});
}
}
// If it's an object literal with DTO-like properties
if (resultArg && resultArg.type === 'ObjectExpression') {
const hasDtoLikeProps = resultArg.properties.some(prop => {
if (prop.type === 'Property' && prop.key.type === 'Identifier') {
const keyName = prop.key.name;
return keyName.match(/(id|Id|URL|Url|DTO)$/i);
}
return false;
});
if (hasDtoLikeProps) {
context.report({
node: returnNode,
messageId: 'noDirectDtoReturn',
});
}
}
}
}
}
});
// If no builder import and we have DTO returns, report
if (!hasBuilderImport && returnStatements.length > 0) {
const hasDtoReturn = returnStatements.some(returnNode => {
const returnExpr = returnNode.argument;
if (returnExpr && returnExpr.type === 'CallExpression') {
const callee = returnExpr.callee;
if (callee.type === 'MemberExpression' &&
callee.object.type === 'Identifier' &&
callee.object.name === 'Result' &&
callee.property.name === 'ok') {
const arg = returnExpr.arguments[0];
// Check for method calls like result.unwrap()
if (arg && arg.type === 'CallExpression') {
const methodCallee = arg.callee;
if (methodCallee.type === 'MemberExpression' &&
methodCallee.property.type === 'Identifier') {
const methodName = methodCallee.property.name;
if (['unwrap', 'getValue', 'getData', 'getResult'].includes(methodName)) {
return true;
}
}
}
// Check for identifier
if (arg && arg.type === 'Identifier') {
return arg.name.match(/(DTO|Result|Response|Data)$/i);
}
}
}
return false;
});
if (hasDtoReturn) {
context.report({
node: methodDefinitions[0],
messageId: 'mustUseBuilder',
});
}
}
},
};
},
};