/** * ESLint Rule: Page Query Must Use Builders * * Ensures page queries use builders to transform DTOs into ViewData * This prevents DTOs from leaking to the client */ module.exports = { meta: { type: 'problem', docs: { description: 'Ensure page queries use builders to transform DTOs into ViewData', category: 'Page Query', recommended: true, }, messages: { mustUseBuilder: 'PageQueries must use ViewDataBuilder to transform DTOs - see apps/website/lib/contracts/builders/ViewDataBuilder.ts', noDirectDtoReturn: 'PageQueries must not return DTOs directly, use builders to create ViewData', }, schema: [], }, create(context) { const filename = context.getFilename(); const isPageQuery = filename.includes('/page-queries/') && filename.endsWith('.ts'); if (!isPageQuery) { return {}; } let hasBuilderImport = false; const builderUsages = []; const returnStatements = []; const builtVariables = new Set(); return { // Check for builder imports ImportDeclaration(node) { const importPath = node.source.value; if (importPath.includes('/builders/view-data/')) { hasBuilderImport = true; // Track which builder is imported node.specifiers.forEach(spec => { if (spec.type === 'ImportSpecifier') { builderUsages.push({ name: spec.imported.name, localName: spec.local.name, }); } }); } }, // Track variable assignments from builders VariableDeclarator(node) { if (node.init && node.init.type === 'CallExpression') { const callee = node.init.callee; if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier' && callee.property.name === 'build') { builtVariables.add(node.id.name); } } }, // Track variable assignments from builders VariableDeclarator(node) { if (node.init && node.init.type === 'CallExpression') { const callee = node.init.callee; if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier' && callee.property.name === 'build') { builtVariables.add(node.id.name); } } }, // Track variable assignments from builders VariableDeclarator(node) { if (node.init && node.init.type === 'CallExpression') { const callee = node.init.callee; if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier' && callee.property.name === 'build') { builtVariables.add(node.id.name); } } }, // Track return statements ReturnStatement(node) { if (node.argument) { returnStatements.push(node); } }, 'Program:exit'() { if (!hasBuilderImport) { context.report({ node: context.getSourceCode().ast, messageId: 'mustUseBuilder', }); return; } // Check if return statements use builders returnStatements.forEach(returnNode => { const returnExpr = returnNode.argument; // Check if it's a builder call if (returnExpr && returnExpr.type === 'CallExpression') { const callee = returnExpr.callee; // Check if it's a builder method call (e.g., ViewDataBuilder.build()) if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier' && callee.property.name === 'build') { // This is good - using a builder return; } // Check if it's a direct Result.ok() with DTO if (callee.type === 'MemberExpression' && callee.object.type === 'Identifier' && callee.object.name === 'Result' && callee.property.type === 'Identifier' && callee.property.name === 'ok') { // Check if the argument is a variable that might be a DTO if (returnExpr.arguments && returnExpr.arguments[0]) { const arg = returnExpr.arguments[0]; // If it's an identifier, check if it's likely a DTO if (arg.type === 'Identifier') { const varName = arg.name; // Skip if it's a variable built from a builder if (builtVariables.has(varName)) { return; } // Common DTO patterns: result, data, dto, apiResult, etc. if (varName.match(/(result|data|dto|apiResult|response)/i)) { context.report({ node: returnNode, messageId: 'noDirectDtoReturn', }); } } } } } }); }, }; }, };