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