369 lines
12 KiB
JavaScript
369 lines
12 KiB
JavaScript
/**
|
|
* 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 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;
|
|
} |