/** * ESLint rules for Formatter/Display Guardrails * * Enforces boundaries and purity for Formatters and Display Objects */ module.exports = { // Rule 1: No IO in formatters/displays 'no-io-in-display-objects': { meta: { type: 'problem', docs: { description: 'Forbid IO imports in formatters and displays', category: 'Formatters', }, messages: { message: 'Formatters/Displays cannot import from api, services, page-queries, or view-models - see apps/website/lib/contracts/formatters/Formatter.ts', }, }, create(context) { const forbiddenPaths = [ '@/lib/api/', '@/lib/services/', '@/lib/page-queries/', '@/lib/view-models/', '@/lib/presenters/', ]; return { ImportDeclaration(node) { const importPath = node.source.value; if (forbiddenPaths.some(path => importPath.includes(path)) && !isInComment(node)) { context.report({ node, messageId: 'message', }); } }, }; }, }, // Rule 2: No non-class display exports 'no-non-class-display-exports': { meta: { type: 'problem', docs: { description: 'Forbid non-class exports in formatters and displays', category: 'Formatters', }, messages: { message: 'Formatters and Displays must be class-based and export only classes - see apps/website/lib/contracts/formatters/Formatter.ts', }, }, create(context) { return { ExportNamedDeclaration(node) { if (node.declaration && (node.declaration.type === 'FunctionDeclaration' || (node.declaration.type === 'VariableDeclaration' && !node.declaration.declarations.some(d => d.init && d.init.type === 'ClassExpression')))) { context.report({ node, messageId: 'message', }); } }, ExportDefaultDeclaration(node) { if (node.declaration && node.declaration.type !== 'ClassDeclaration' && node.declaration.type !== 'ClassExpression') { context.report({ node, messageId: 'message', }); } }, }; }, }, // Rule 3: Formatters must return primitives 'formatters-must-return-primitives': { meta: { type: 'problem', docs: { description: 'Enforce that Formatters return primitive values for ViewData compatibility', category: 'Formatters', }, messages: { message: 'Formatters used in ViewDataBuilders must return primitive values (string, number, boolean, null) - see apps/website/lib/contracts/formatters/Formatter.ts', }, }, create(context) { const filename = context.getFilename(); const isViewDataBuilder = filename.includes('/lib/builders/view-data/'); if (!isViewDataBuilder) return {}; return { CallExpression(node) { // Check if calling a Formatter/Display method if (node.callee.type === 'MemberExpression' && node.callee.object.name && (node.callee.object.name.endsWith('Formatter') || node.callee.object.name.endsWith('Display'))) { // If it's inside a ViewData object literal, it must be a primitive return let parent = node.parent; while (parent) { if (parent.type === 'Property' && parent.parent.type === 'ObjectExpression') { // This is a property in an object literal (likely ViewData) // We can't easily check the return type of the method at lint time without type info, // but we can enforce that it's not the whole object being assigned. if (node.callee.property.name === 'format' || node.callee.property.name.startsWith('format')) { // Good: calling a format method return; } // If they are assigning the result of a non-format method, warn context.report({ node, messageId: 'message', }); } parent = parent.parent; } } }, }; }, }, }; // Helper functions function isInComment(node) { return false; }