/** * ESLint rule to enforce ViewData contract implementation * * ViewData files in lib/view-data/ must: * 1. Be interfaces or types named *ViewData * 2. Extend the ViewData interface from contracts * 3. NOT contain ViewModels (ViewModels are for ClientWrappers/Hooks) */ module.exports = { meta: { type: 'problem', docs: { description: 'Enforce ViewData contract implementation', category: 'Contracts', recommended: true, }, fixable: null, schema: [], messages: { notAnInterface: 'ViewData files must be interfaces or types named *ViewData', missingExtends: 'ViewData must extend the ViewData interface from lib/contracts/view-data/ViewData.ts', noViewModelsInViewData: 'ViewData must not contain ViewModels. ViewData is for plain JSON data (DTOs) passed through SSR. Use ViewModels in ClientWrappers or Hooks instead.', }, }, create(context) { const filename = context.getFilename(); const isInViewData = filename.includes('/lib/view-data/') && !filename.includes('/contracts/'); if (!isInViewData) return {}; let hasViewDataExtends = false; let hasCorrectName = false; return { // Check for ViewModel imports ImportDeclaration(node) { if (!isInViewData) return; const importPath = node.source.value; if (importPath.includes('/lib/view-models/')) { context.report({ node, messageId: 'noViewModelsInViewData', }); } }, // Check interface declarations TSInterfaceDeclaration(node) { const interfaceName = node.id?.name; if (interfaceName && interfaceName.endsWith('ViewData')) { hasCorrectName = true; // Check for ViewModel usage in properties node.body.body.forEach(member => { if (member.type === 'TSPropertySignature' && member.typeAnnotation) { const typeAnnotation = member.typeAnnotation.typeAnnotation; if (isViewModelType(typeAnnotation)) { context.report({ node: member, messageId: 'noViewModelsInViewData', }); } } }); // Check if it extends ViewData if (node.extends && node.extends.length > 0) { for (const ext of node.extends) { // Use context.getSourceCode().getText(ext) to be absolutely sure const extendsText = context.getSourceCode().getText(ext).trim(); // We check for 'ViewData' but must be careful not to match 'SomethingViewData' // unless it's exactly 'ViewData' or part of a qualified name if (extendsText === 'ViewData' || extendsText.endsWith('.ViewData') || extendsText.startsWith('ViewData<') || extendsText.startsWith('ViewData ') || /\bViewData\b/.test(extendsText)) { // Use regex for word boundary hasViewDataExtends = true; } } } } }, // Check type alias declarations TSTypeAliasDeclaration(node) { const typeName = node.id?.name; if (typeName && typeName.endsWith('ViewData')) { hasCorrectName = true; // For type aliases, check if it's an intersection with ViewData if (node.typeAnnotation && node.typeAnnotation.type === 'TSIntersectionType') { for (const type of node.typeAnnotation.types) { if (type.type === 'TSTypeReference' && type.typeName && type.typeName.type === 'Identifier' && type.typeName.name === 'ViewData') { hasViewDataExtends = true; } } } } }, 'Program:exit'() { // Only report if we are in a file that should be a ViewData // and we didn't find a valid declaration const baseName = filename.split('/').pop(); // All files in lib/view-data/ must end with ViewData.ts if (baseName && !baseName.endsWith('ViewData.ts') && !baseName.endsWith('ViewData.tsx')) { context.report({ node: context.getSourceCode().ast, messageId: 'notAnInterface', }); return; } if (baseName && (baseName.endsWith('ViewData.ts') || baseName.endsWith('ViewData.tsx'))) { if (!hasCorrectName) { context.report({ node: context.getSourceCode().ast, messageId: 'notAnInterface', }); } else if (!hasViewDataExtends) { context.report({ node: context.getSourceCode().ast, messageId: 'missingExtends', }); } } }, }; }, };