Files
gridpilot.gg/apps/website/eslint-rules/template-purity-rules.js
2026-01-12 19:24:59 +01:00

278 lines
7.9 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 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;
}