/** * 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. Templates should only receive logic-rich ViewModels via props from ClientWrappers, never import them directly.', }, }, create(context) { return { ImportDeclaration(node) { const importPath = node.source.value; // Templates are allowed to import ViewModels for TYPE-ONLY usage (interface/type) // but not for instantiation or logic. However, to be safe, we forbid direct imports // and suggest passing them through ClientWrappers. if ((importPath.includes('@/lib/view-models/') || importPath.includes('@/lib/presenters/') || importPath.includes('@/lib/display-objects/')) && !isInComment(node) && node.importKind !== 'type') { 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 8: No 'use client' directive in templates 'no-use-client-in-templates': { meta: { type: 'problem', docs: { description: 'Forbid use client directive in templates', category: 'Template Purity', }, messages: { message: 'Templates must not use "use client" directive - they should be stateless composition', }, }, create(context) { const filename = context.getFilename(); const isInTemplates = filename.includes('/templates/'); if (!isInTemplates) return {}; return { ExpressionStatement(node) { if (node.expression.type === 'Literal' && node.expression.value === 'use client') { 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) { const sourceCode = context.getSourceCode(); function isTemplateExportFunction(node) { const functionName = node.id && node.id.type === 'Identifier' ? node.id.name : null; if (!functionName || !functionName.endsWith('Template')) return false; // Only enforce for exported template component functions. return node.parent && node.parent.type === 'ExportNamedDeclaration'; } function getTypeReferenceName(typeNode) { if (!typeNode || typeNode.type !== 'TSTypeReference') return null; const typeName = typeNode.typeName; if (!typeName || typeName.type !== 'Identifier') return null; return typeName.name; } function findLocalTypeDeclaration(typeName) { const programBody = sourceCode.ast && sourceCode.ast.body ? sourceCode.ast.body : []; for (const stmt of programBody) { if (!stmt) continue; // interface Foo { ... } if (stmt.type === 'TSInterfaceDeclaration' && stmt.id && stmt.id.type === 'Identifier') { if (stmt.id.name === typeName) return stmt; } // type Foo = ... if (stmt.type === 'TSTypeAliasDeclaration' && stmt.id && stmt.id.type === 'Identifier') { if (stmt.id.name === typeName) return stmt; } // export interface/type Foo ... if (stmt.type === 'ExportNamedDeclaration' && stmt.declaration) { const decl = stmt.declaration; if (decl.type === 'TSInterfaceDeclaration' && decl.id && decl.id.type === 'Identifier' && decl.id.name === typeName) { return decl; } if (decl.type === 'TSTypeAliasDeclaration' && decl.id && decl.id.type === 'Identifier' && decl.id.name === typeName) { return decl; } } } return null; } // Helper to recursively check if type contains ViewData (including by resolving local interface/type aliases). function typeContainsViewData(typeNode, seenTypeNames = new Set()) { if (!typeNode) return false; // Direct type name includes ViewData (e.g. ProfileViewData) if (typeNode.type === 'TSTypeReference' && typeNode.typeName && typeNode.typeName.type === 'Identifier') { const name = typeNode.typeName.name; if (name.includes('ViewData')) return true; // If the param is typed as a local Props interface/type, resolve it and inspect its members. if (!seenTypeNames.has(name)) { seenTypeNames.add(name); const decl = findLocalTypeDeclaration(name); if (decl && decl.type === 'TSInterfaceDeclaration') { for (const member of decl.body.body || []) { if (member.type === 'TSPropertySignature' && member.typeAnnotation) { if (typeContainsViewData(member.typeAnnotation.typeAnnotation, seenTypeNames)) return true; } } } if (decl && decl.type === 'TSTypeAliasDeclaration') { if (typeContainsViewData(decl.typeAnnotation, seenTypeNames)) return true; } } } // Nested in object type if (typeNode.type === 'TSTypeLiteral' && typeNode.members) { for (const member of typeNode.members) { if (member.type === 'TSPropertySignature' && member.typeAnnotation) { if (typeContainsViewData(member.typeAnnotation.typeAnnotation, seenTypeNames)) return true; } } } // Union/intersection types if (typeNode.type === 'TSUnionType' || typeNode.type === 'TSIntersectionType') { return typeNode.types.some((t) => typeContainsViewData(t, seenTypeNames)); } return false; } return { FunctionDeclaration(node) { if (!isTemplateExportFunction(node)) return; if (node.params.length === 0) { context.report({ node, messageId: 'message' }); return; } const firstParam = node.params[0]; // `function FooTemplate({ ... }: Props)` -> the type annotation is on the parameter, not on the destructured properties. if (!firstParam.typeAnnotation || !firstParam.typeAnnotation.typeAnnotation) { context.report({ node, messageId: 'message' }); return; } const firstParamType = firstParam.typeAnnotation.typeAnnotation; // If it's a reference to a local type/interface, we handle it in typeContainsViewData(). const refName = getTypeReferenceName(firstParamType); if (refName && refName.includes('ViewData')) return; if (!typeContainsViewData(firstParamType)) { 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; }