/** * ESLint rules for Template Purity Guardrails * * Enforces pure template components without business logic */ module.exports = { // Rule 1: No ViewModels/DisplayObjects in templates 'no-view-models-in-templates': { meta: { type: 'problem', docs: { description: 'Forbid ViewModels/DisplayObjects imports in templates', category: 'Template Purity', }, messages: { message: 'ViewModels or DisplayObjects import forbidden in templates - see apps/website/lib/contracts/view-data/ViewData.ts', }, }, create(context) { return { ImportDeclaration(node) { const importPath = node.source.value; if ((importPath.includes('@/lib/view-models/') || importPath.includes('@/lib/presenters/') || importPath.includes('@/lib/display-objects/')) && !isInComment(node)) { context.report({ node, messageId: 'message', }); } }, }; }, }, // Rule 2: No state hooks in templates 'no-state-hooks-in-templates': { meta: { type: 'problem', docs: { description: 'Forbid state hooks in templates', category: 'Template Purity', }, messages: { message: 'State hooks forbidden in templates (use *PageClient.tsx) - see apps/website/lib/contracts/view-data/ViewData.ts', }, }, create(context) { return { CallExpression(node) { if (node.callee.type === 'Identifier' && ['useMemo', 'useEffect', 'useState', 'useReducer'].includes(node.callee.name) && !isInComment(node)) { context.report({ node, messageId: 'message', }); } }, }; }, }, // Rule 3: No computations in templates 'no-computations-in-templates': { meta: { type: 'problem', docs: { description: 'Forbid derived computations in templates', category: 'Template Purity', }, messages: { message: 'Derived computations forbidden in templates - see apps/website/lib/contracts/view-data/ViewData.ts', }, }, create(context) { return { CallExpression(node) { if (node.callee.type === 'MemberExpression' && ['filter', 'sort', 'reduce'].includes(node.callee.property.name) && !isInComment(node)) { context.report({ node, messageId: 'message', }); } }, }; }, }, // Rule 4: No restricted imports in templates 'no-restricted-imports-in-templates': { meta: { type: 'problem', docs: { description: 'Forbid restricted imports in templates', category: 'Template Purity', }, messages: { message: 'Templates cannot import from page-queries, services, api, di, or contracts - see apps/website/lib/contracts/view-data/ViewData.ts', }, }, create(context) { const restrictedPaths = [ '@/lib/page-queries/', '@/lib/services/', '@/lib/api/', '@/lib/di/', '@/lib/contracts/', ]; return { ImportDeclaration(node) { const importPath = node.source.value; if (restrictedPaths.some(path => importPath.includes(path)) && !isInComment(node)) { context.report({ node, messageId: 'message', }); } }, }; }, }, // Rule 5: Invalid template signature 'no-invalid-template-signature': { meta: { type: 'problem', docs: { description: 'Enforce correct template component signature', category: 'Template Purity', }, messages: { message: 'Template component must accept *ViewData type as first parameter - see apps/website/lib/contracts/view-data/ViewData.ts', }, }, create(context) { // Helper to recursively check if type contains ViewData function typeContainsViewData(typeNode) { if (!typeNode) return false; // Check direct type name if (typeNode.type === 'TSTypeReference' && typeNode.typeName && typeNode.typeName.name && typeNode.typeName.name.includes('ViewData')) { return true; } // Check nested in object type if (typeNode.type === 'TSTypeLiteral' && typeNode.members) { for (const member of typeNode.members) { if (member.type === 'TSPropertySignature' && member.typeAnnotation && typeContainsViewData(member.typeAnnotation.typeAnnotation)) { return true; } } } // Check union/intersection types if (typeNode.type === 'TSUnionType' || typeNode.type === 'TSIntersectionType') { return typeNode.types.some(t => typeContainsViewData(t)); } return false; } return { FunctionDeclaration(node) { if (node.params.length === 0) { context.report({ node, messageId: 'message', }); return; } const firstParam = node.params[0]; if (!firstParam.typeAnnotation || !firstParam.typeAnnotation.typeAnnotation) { context.report({ node, messageId: 'message', }); return; } const typeAnnotation = firstParam.typeAnnotation.typeAnnotation; if (!typeContainsViewData(typeAnnotation)) { context.report({ node, messageId: 'message', }); } }, }; }, }, // Rule 6: No template helper exports 'no-template-helper-exports': { meta: { type: 'problem', docs: { description: 'Forbid helper function exports in templates', category: 'Template Purity', }, messages: { message: 'Templates must not export helper functions - see apps/website/lib/contracts/view-data/ViewData.ts', }, }, create(context) { return { ExportNamedDeclaration(node) { if (node.declaration && (node.declaration.type === 'FunctionDeclaration' || node.declaration.type === 'VariableDeclaration')) { // Get the function/variable name const name = node.declaration.id?.name; // Allow the main template component (ends with Template) if (name && name.endsWith('Template')) { return; } // Allow default exports if (node.declaration.type === 'VariableDeclaration' && node.declaration.declarations.some(d => d.id.type === 'Identifier' && d.id.name.endsWith('Template'))) { return; } context.report({ node, messageId: 'message', }); } }, }; }, }, // Rule 7: Invalid template filename 'invalid-template-filename': { meta: { type: 'problem', docs: { description: 'Enforce correct template filename', category: 'Template Purity', }, messages: { message: 'Template files must end with Template.tsx - see apps/website/lib/contracts/view-data/ViewData.ts', }, }, create(context) { const filename = context.getFilename(); if (filename.includes('/templates/') && !filename.endsWith('Template.tsx')) { // Report at the top of the file context.report({ loc: { line: 1, column: 0 }, messageId: 'message', }); } return {}; }, }, }; // Helper functions function isInComment(node) { return false; }