140 lines
4.8 KiB
JavaScript
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',
|
|
});
|
|
}
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|