From 04d445bf0076f0affe0c31b355bf10d2817e0063 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Thu, 22 Jan 2026 18:46:51 +0100 Subject: [PATCH] eslint rules --- apps/website/.eslintrc.json | 24 ++++- apps/website/eslint-rules/index.js | 12 +++ .../view-data-builder-implements.js | 96 +++++++++++++++++++ .../eslint-rules/view-data-implements.js | 91 ++++++++++++++++++ .../view-model-builder-implements.js | 96 +++++++++++++++++++ .../eslint-rules/view-model-implements.js | 65 +++++++++++++ 6 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 apps/website/eslint-rules/view-data-builder-implements.js create mode 100644 apps/website/eslint-rules/view-data-implements.js create mode 100644 apps/website/eslint-rules/view-model-builder-implements.js create mode 100644 apps/website/eslint-rules/view-model-implements.js diff --git a/apps/website/.eslintrc.json b/apps/website/.eslintrc.json index 1a594be34..02e49b09b 100644 --- a/apps/website/.eslintrc.json +++ b/apps/website/.eslintrc.json @@ -44,7 +44,8 @@ "lib/builders/view-models/*.tsx" ], "rules": { - "gridpilot-rules/view-model-builder-contract": "error" + "gridpilot-rules/view-model-builder-contract": "error", + "gridpilot-rules/view-model-builder-implements": "error" } }, { @@ -55,7 +56,8 @@ "rules": { "gridpilot-rules/filename-matches-export": "off", "gridpilot-rules/single-export-per-file": "off", - "gridpilot-rules/view-data-builder-contract": "off" + "gridpilot-rules/view-data-builder-contract": "off", + "gridpilot-rules/view-data-builder-implements": "error" } }, { @@ -192,6 +194,24 @@ "gridpilot-rules/view-data-location": "error" } }, + { + "files": [ + "lib/view-data/**/*.ts", + "lib/view-data/**/*.tsx" + ], + "rules": { + "gridpilot-rules/view-data-implements": "error" + } + }, + { + "files": [ + "lib/view-models/**/*.ts", + "lib/view-models/**/*.tsx" + ], + "rules": { + "gridpilot-rules/view-model-implements": "error" + } + }, { "files": [ "lib/services/**/*.ts" diff --git a/apps/website/eslint-rules/index.js b/apps/website/eslint-rules/index.js index 8968926e1..6948a6dec 100644 --- a/apps/website/eslint-rules/index.js +++ b/apps/website/eslint-rules/index.js @@ -46,6 +46,10 @@ const servicesImplementContract = require('./services-implement-contract'); const serverActionsReturnResult = require('./server-actions-return-result'); const serverActionsInterface = require('./server-actions-interface'); const noDisplayObjectsInUi = require('./no-display-objects-in-ui'); +const viewDataBuilderImplements = require('./view-data-builder-implements'); +const viewModelBuilderImplements = require('./view-model-builder-implements'); +const viewDataImplements = require('./view-data-implements'); +const viewModelImplements = require('./view-model-implements'); module.exports = { rules: { @@ -128,9 +132,13 @@ module.exports = { // View Data Rules 'view-data-location': viewDataLocation, 'view-data-builder-contract': viewDataBuilderContract, + 'view-data-builder-implements': viewDataBuilderImplements, + 'view-data-implements': viewDataImplements, // View Model Rules 'view-model-builder-contract': viewModelBuilderContract, + 'view-model-builder-implements': viewModelBuilderImplements, + 'view-model-implements': viewModelImplements, // Single Export Rules 'single-export-per-file': singleExportPerFile, @@ -253,9 +261,13 @@ module.exports = { // View Data 'gridpilot-rules/view-data-location': 'error', 'gridpilot-rules/view-data-builder-contract': 'error', + 'gridpilot-rules/view-data-builder-implements': 'error', + 'gridpilot-rules/view-data-implements': 'error', // View Model 'gridpilot-rules/view-model-builder-contract': 'error', + 'gridpilot-rules/view-model-builder-implements': 'error', + 'gridpilot-rules/view-model-implements': 'error', // Single Export Rules 'gridpilot-rules/single-export-per-file': 'error', diff --git a/apps/website/eslint-rules/view-data-builder-implements.js b/apps/website/eslint-rules/view-data-builder-implements.js new file mode 100644 index 000000000..86d38f66d --- /dev/null +++ b/apps/website/eslint-rules/view-data-builder-implements.js @@ -0,0 +1,96 @@ +/** + * ESLint rule to enforce View Data Builder contract implementation + * + * View Data Builders in lib/builders/view-data/ must: + * 1. Be classes named *ViewDataBuilder + * 2. Implement the ViewDataBuilder interface + * 3. Have a static build() method + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Enforce View Data Builder contract implementation', + category: 'Builders', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + notAClass: 'View Data Builders must be classes named *ViewDataBuilder', + missingImplements: 'View Data Builders must implement ViewDataBuilder interface', + missingBuildMethod: 'View Data Builders must have a static build() method', + }, + }, + + create(context) { + const filename = context.getFilename(); + const isInViewDataBuilders = filename.includes('/lib/builders/view-data/'); + + if (!isInViewDataBuilders) return {}; + + let hasImplements = false; + let hasBuildMethod = false; + + return { + // Check class declaration + ClassDeclaration(node) { + const className = node.id?.name; + + if (!className || !className.endsWith('ViewDataBuilder')) { + context.report({ + node, + messageId: 'notAClass', + }); + } + + // Check if class implements ViewDataBuilder interface + if (node.implements && node.implements.length > 0) { + for (const impl of node.implements) { + // Handle GenericTypeAnnotation for ViewDataBuilder + if (impl.expression.type === 'TSInstantiationExpression') { + const expr = impl.expression.expression; + if (expr.type === 'Identifier' && expr.name === 'ViewDataBuilder') { + hasImplements = true; + } + } else if (impl.expression.type === 'Identifier') { + // Handle simple ViewDataBuilder (without generics) + if (impl.expression.name === 'ViewDataBuilder') { + hasImplements = true; + } + } + } + } + + // Check for static build method + const buildMethod = node.body.body.find(member => + member.type === 'MethodDefinition' && + member.key.type === 'Identifier' && + member.key.name === 'build' && + member.static === true + ); + + if (buildMethod) { + hasBuildMethod = true; + } + }, + + 'Program:exit'() { + if (!hasImplements) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'missingImplements', + }); + } + + if (!hasBuildMethod) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'missingBuildMethod', + }); + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/view-data-implements.js b/apps/website/eslint-rules/view-data-implements.js new file mode 100644 index 000000000..30ff237f8 --- /dev/null +++ b/apps/website/eslint-rules/view-data-implements.js @@ -0,0 +1,91 @@ +/** + * 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 + */ + +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', + }, + }, + + create(context) { + const filename = context.getFilename(); + const isInViewData = filename.includes('/lib/view-data/'); + + if (!isInViewData) return {}; + + let hasViewDataExtends = false; + let hasCorrectName = false; + + return { + // Check interface declarations + TSInterfaceDeclaration(node) { + const interfaceName = node.id?.name; + + if (interfaceName && interfaceName.endsWith('ViewData')) { + hasCorrectName = true; + + // Check if it extends ViewData + if (node.extends && node.extends.length > 0) { + for (const ext of node.extends) { + if (ext.type === 'TSExpressionWithTypeArguments' && + ext.expression.type === 'Identifier' && + ext.expression.name === 'ViewData') { + 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'() { + if (!hasCorrectName) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'notAnInterface', + }); + } else if (!hasViewDataExtends) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'missingExtends', + }); + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/view-model-builder-implements.js b/apps/website/eslint-rules/view-model-builder-implements.js new file mode 100644 index 000000000..addaa7dc1 --- /dev/null +++ b/apps/website/eslint-rules/view-model-builder-implements.js @@ -0,0 +1,96 @@ +/** + * ESLint rule to enforce View Model Builder contract implementation + * + * View Model Builders in lib/builders/view-models/ must: + * 1. Be classes named *ViewModelBuilder + * 2. Implement the ViewModelBuilder interface + * 3. Have a static build() method + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Enforce View Model Builder contract implementation', + category: 'Builders', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + notAClass: 'View Model Builders must be classes named *ViewModelBuilder', + missingImplements: 'View Model Builders must implement ViewModelBuilder interface', + missingBuildMethod: 'View Model Builders must have a static build() method', + }, + }, + + create(context) { + const filename = context.getFilename(); + const isInViewModelBuilders = filename.includes('/lib/builders/view-models/'); + + if (!isInViewModelBuilders) return {}; + + let hasImplements = false; + let hasBuildMethod = false; + + return { + // Check class declaration + ClassDeclaration(node) { + const className = node.id?.name; + + if (!className || !className.endsWith('ViewModelBuilder')) { + context.report({ + node, + messageId: 'notAClass', + }); + } + + // Check if class implements ViewModelBuilder interface + if (node.implements && node.implements.length > 0) { + for (const impl of node.implements) { + // Handle GenericTypeAnnotation for ViewModelBuilder + if (impl.expression.type === 'TSInstantiationExpression') { + const expr = impl.expression.expression; + if (expr.type === 'Identifier' && expr.name === 'ViewModelBuilder') { + hasImplements = true; + } + } else if (impl.expression.type === 'Identifier') { + // Handle simple ViewModelBuilder (without generics) + if (impl.expression.name === 'ViewModelBuilder') { + hasImplements = true; + } + } + } + } + + // Check for static build method + const buildMethod = node.body.body.find(member => + member.type === 'MethodDefinition' && + member.key.type === 'Identifier' && + member.key.name === 'build' && + member.static === true + ); + + if (buildMethod) { + hasBuildMethod = true; + } + }, + + 'Program:exit'() { + if (!hasImplements) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'missingImplements', + }); + } + + if (!hasBuildMethod) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'missingBuildMethod', + }); + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/view-model-implements.js b/apps/website/eslint-rules/view-model-implements.js new file mode 100644 index 000000000..31e9db2c5 --- /dev/null +++ b/apps/website/eslint-rules/view-model-implements.js @@ -0,0 +1,65 @@ +/** + * ESLint rule to enforce ViewModel contract implementation + * + * ViewModel files in lib/view-models/ must: + * 1. Be classes named *ViewModel + * 2. Extend the ViewModel class from contracts + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Enforce ViewModel contract implementation', + category: 'Contracts', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + notAClass: 'ViewModel files must be classes named *ViewModel', + missingExtends: 'ViewModel must extend the ViewModel class from lib/contracts/view-models/ViewModel.ts', + }, + }, + + create(context) { + const filename = context.getFilename(); + const isInViewModels = filename.includes('/lib/view-models/'); + + if (!isInViewModels) return {}; + + let hasViewModelExtends = false; + let hasCorrectName = false; + + return { + // Check class declarations + ClassDeclaration(node) { + const className = node.id?.name; + + if (className && className.endsWith('ViewModel')) { + hasCorrectName = true; + + // Check if it extends ViewModel + if (node.superClass && node.superClass.type === 'Identifier' && + node.superClass.name === 'ViewModel') { + hasViewModelExtends = true; + } + } + }, + + 'Program:exit'() { + if (!hasCorrectName) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'notAClass', + }); + } else if (!hasViewModelExtends) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'missingExtends', + }); + } + }, + }; + }, +};