Files
gridpilot.gg/apps/website/eslint-rules/view-data-implements.js
2026-01-23 15:30:23 +01:00

140 lines
4.8 KiB
JavaScript

/**
* 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',
});
}
}
},
};
},
};