diff --git a/apps/website/eslint-rules/filename-matches-export.js b/apps/website/eslint-rules/filename-matches-export.js new file mode 100644 index 000000000..5700e57f4 --- /dev/null +++ b/apps/website/eslint-rules/filename-matches-export.js @@ -0,0 +1,94 @@ +/** + * ESLint rule to enforce filename matches exported name + * + * The filename (without extension) should match the exported name exactly. + * For example: AdminDashboardPageQuery.ts should export AdminDashboardPageQuery + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Enforce filename matches exported name', + category: 'Best Practices', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + filenameMismatch: 'Filename "{{filename}}" should match exported name "{{exportName}}"', + noMatchFound: 'No export found that matches filename "{{filename}}"', + }, + }, + + create(context) { + const filename = context.getFilename(); + + // Extract base filename without extension + const baseName = filename.split('/').pop(); // Get last part of path + const nameWithoutExt = baseName?.replace(/\.(ts|tsx|js|jsx)$/, ''); + + // Skip test files, index files, and type definition files + if (!nameWithoutExt || + nameWithoutExt.endsWith('.test') || + nameWithoutExt.endsWith('.spec') || + nameWithoutExt === 'index' || + nameWithoutExt.endsWith('.d')) { + return {}; + } + + let exportedName = null; + + return { + // Track named exports + ExportNamedDeclaration(node) { + if (node.declaration) { + if (node.declaration.id) { + exportedName = node.declaration.id.name; + } else if (node.declaration.declarations) { + // Multiple const exports - use the first one + const first = node.declaration.declarations[0]; + if (first.id && first.id.name) { + exportedName = first.id.name; + } + } + } else if (node.specifiers && node.specifiers.length > 0) { + // Re-exports - use the first one + exportedName = node.specifiers[0].exported.name; + } + }, + + // Track default exports + ExportDefaultDeclaration(node) { + if (node.declaration && node.declaration.id) { + exportedName = node.declaration.id.name; + } else { + exportedName = 'default'; + } + }, + + 'Program:exit'() { + if (!exportedName) { + // No export found - this might be okay for some files + return; + } + + if (exportedName === 'default') { + // Default exports don't need to match filename + return; + } + + if (exportedName !== nameWithoutExt) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'filenameMismatch', + data: { + filename: nameWithoutExt, + exportName: exportedName, + }, + }); + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/filename-rules.js b/apps/website/eslint-rules/filename-rules.js index e0abf3b48..d2426f388 100644 --- a/apps/website/eslint-rules/filename-rules.js +++ b/apps/website/eslint-rules/filename-rules.js @@ -54,10 +54,53 @@ module.exports = { FunctionDeclaration(node) { const filename = context.getFilename(); if (filename.includes('/lib/services/') && filename.endsWith('.ts')) { - const expectedFunctionName = filename.split('/').pop().replace('.ts', ''); - const actualFunctionName = node.id?.name; + const expectedClassName = filename.split('/').pop().replace('.ts', ''); + const actualClassName = node.id?.name; - if (actualFunctionName && actualFunctionName !== expectedFunctionName) { + if (actualClassName && actualClassName !== expectedClassName) { + context.report({ + node, + messageId: 'message', + }); + } + } + }, + }; + }, + }, + + // Rule 3: Display filename must end with Display.tsx and match class name + 'display-filename-must-end-with-display-tsx': { + meta: { + type: 'problem', + docs: { + description: 'Enforce display filename ends with Display.tsx and matches class name', + category: 'Filename', + }, + messages: { + message: 'Display filenames must end with Display.tsx and the class name must match (e.g., RatingDisplay.tsx contains class RatingDisplay). Displays must be reusable, not screen-specific.', + }, + }, + create(context) { + return { + ClassDeclaration(node) { + const filename = context.getFilename(); + if (filename.includes('/lib/display-objects/') && filename.endsWith('.tsx')) { + // Check if filename ends with Display.tsx + if (!filename.endsWith('Display.tsx')) { + context.report({ + node, + messageId: 'message', + }); + return; + } + + // Extract expected class name from filename (remove path and Display.tsx suffix) + const filenameOnly = filename.split('/').pop(); + const expectedClassName = filenameOnly.replace('.tsx', ''); + const actualClassName = node.id?.name; + + if (actualClassName && actualClassName !== expectedClassName) { context.report({ node, messageId: 'message', diff --git a/apps/website/eslint-rules/index.js b/apps/website/eslint-rules/index.js index 91c583189..2e7d516ea 100644 --- a/apps/website/eslint-rules/index.js +++ b/apps/website/eslint-rules/index.js @@ -29,6 +29,14 @@ const presenterPurity = require('./presenter-purity'); const mutationContract = require('./mutation-contract'); const serverActionsMustUseMutations = require('./server-actions-must-use-mutations'); const viewDataLocation = require('./view-data-location'); +const viewDataBuilderContract = require('./view-data-builder-contract'); +const viewModelBuilderContract = require('./view-model-builder-contract'); +const singleExportPerFile = require('./single-export-per-file'); +const filenameMatchesExport = require('./filename-matches-export'); +const pageQueryMustUseBuilders = require('./page-query-must-use-builders'); +const serviceFunctionFormat = require('./service-function-format'); +const libNoNextImports = require('./lib-no-next-imports'); +const servicesNoInstantiation = require('./services-no-instantiation'); module.exports = { rules: { @@ -90,6 +98,7 @@ module.exports = { // Filename Rules 'filename-presenter-match': filenameRules['presenter-filename-must-match-class'], 'filename-service-match': filenameRules['service-filename-must-match-function'], + 'filename-display-match': filenameRules['display-filename-must-end-with-display-tsx'], // Component Data Manipulation Rules 'component-no-data-manipulation': componentNoDataManipulation, @@ -102,6 +111,22 @@ module.exports = { // View Data Rules 'view-data-location': viewDataLocation, + 'view-data-builder-contract': viewDataBuilderContract, + + // View Model Rules + 'view-model-builder-contract': viewModelBuilderContract, + + // Single Export Rules + 'single-export-per-file': singleExportPerFile, + 'filename-matches-export': filenameMatchesExport, + + // Page Query Builder Rules + 'page-query-must-use-builders': pageQueryMustUseBuilders, + + // Service Rules + 'service-function-format': serviceFunctionFormat, + 'lib-no-next-imports': libNoNextImports, + 'services-no-instantiation': servicesNoInstantiation, }, // Configurations for different use cases @@ -167,6 +192,7 @@ module.exports = { // Filename 'gridpilot-rules/filename-presenter-match': 'error', 'gridpilot-rules/filename-service-match': 'error', + 'gridpilot-rules/filename-display-match': 'error', // Mutations 'gridpilot-rules/mutation-contract': 'error', @@ -176,6 +202,21 @@ module.exports = { // View Data 'gridpilot-rules/view-data-location': 'error', + 'gridpilot-rules/view-data-builder-contract': 'error', + + // View Model + 'gridpilot-rules/view-model-builder-contract': 'error', + + // Single Export Rules + 'gridpilot-rules/single-export-per-file': 'error', + 'gridpilot-rules/filename-matches-export': 'error', + + // Page Query Builder Rules + 'gridpilot-rules/page-query-must-use-builders': 'error', + + // Service Rules + 'gridpilot-rules/service-function-format': 'error', + 'gridpilot-rules/lib-no-next-imports': 'error', }, }, diff --git a/apps/website/eslint-rules/lib-no-next-imports.js b/apps/website/eslint-rules/lib-no-next-imports.js new file mode 100644 index 000000000..58075bea2 --- /dev/null +++ b/apps/website/eslint-rules/lib-no-next-imports.js @@ -0,0 +1,108 @@ +/** + * ESLint rule to forbid Next.js imports in lib/ directory + * + * The lib/ directory should be framework-agnostic. + * Next.js imports (redirect, cookies, headers, etc.) should only be in app/ directory. + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Forbid Next.js imports in lib/ directory', + category: 'Architecture', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + noNextImports: 'Next.js imports are forbidden in lib/ directory. Found: {{import}} from "{{source}}"', + noNextRedirect: 'redirect() must be used in app/ directory, not lib/ directory', + noNextCookies: 'cookies() must be used in app/ directory, not lib/ directory', + noNextHeaders: 'headers() must be used in app/ directory, not lib/ directory', + }, + }, + + create(context) { + const filename = context.getFilename(); + const isInLib = filename.includes('/lib/'); + + // Skip if not in lib directory + if (!isInLib) return {}; + + return { + // Track import statements + ImportDeclaration(node) { + const source = node.source.value; + + // Check for Next.js imports + if (source === 'next/navigation' || + source === 'next/headers' || + source === 'next/cookies' || + source === 'next/router' || + source === 'next/link' || + source === 'next/image' || + source === 'next/script' || + source === 'next/dynamic') { + + // Check for specific named imports + node.specifiers.forEach(spec => { + if (spec.type === 'ImportSpecifier') { + const imported = spec.imported.name; + + if (imported === 'redirect') { + context.report({ + node: spec, + messageId: 'noNextRedirect', + }); + } else if (imported === 'cookies') { + context.report({ + node: spec, + messageId: 'noNextCookies', + }); + } else if (imported === 'headers') { + context.report({ + node: spec, + messageId: 'noNextHeaders', + }); + } else { + context.report({ + node: spec, + messageId: 'noNextImports', + data: { import: imported, source }, + }); + } + } else if (spec.type === 'ImportDefaultSpecifier') { + context.report({ + node: spec, + messageId: 'noNextImports', + data: { import: 'default', source }, + }); + } + }); + } + }, + + // Also check for require() calls + CallExpression(node) { + if (node.callee.type === 'Identifier' && node.callee.name === 'require') { + if (node.arguments.length > 0 && node.arguments[0].type === 'Literal') { + const source = node.arguments[0].value; + + if (source === 'next/navigation' || + source === 'next/headers' || + source === 'next/cookies' || + source === 'next/router') { + + context.report({ + node, + messageId: 'noNextImports', + data: { import: 'require()', source }, + }); + } + } + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/mutation-contract.js b/apps/website/eslint-rules/mutation-contract.js index 01b200048..927452e05 100644 --- a/apps/website/eslint-rules/mutation-contract.js +++ b/apps/website/eslint-rules/mutation-contract.js @@ -1,21 +1,19 @@ /** * ESLint Rule: Mutation Contract * - * Ensures mutations implement the Mutation contract + * Ensures mutations return Result type */ module.exports = { meta: { type: 'problem', docs: { - description: 'Ensure mutations implement the Mutation contract', + description: 'Ensure mutations return Result type', category: 'Mutation Contract', recommended: true, }, messages: { - noMutationContract: 'Mutations must implement the Mutation interface from lib/contracts/mutations/Mutation.ts', - missingExecute: 'Mutations must have an execute method that takes input and returns Promise', - wrongExecuteSignature: 'Execute method must have signature: execute(input: TInput): Promise', + wrongReturnType: 'Mutations must return Promise> - see apps/website/lib/contracts/Result.ts', }, schema: [], }, @@ -28,103 +26,45 @@ module.exports = { return {}; } - let hasMutationImport = false; - let hasExecuteMethod = false; - let implementsMutation = false; - let executeMethodNode = null; - return { - // Check for Mutation import - ImportDeclaration(node) { - if (node.source.value === '@/lib/contracts/mutations/Mutation') { - hasMutationImport = true; - } - }, - - // Check for implements clause - ClassDeclaration(node) { - if (node.implements) { - node.implements.forEach(impl => { - if (impl.type === 'Identifier' && impl.name === 'Mutation') { - implementsMutation = true; - } - if (impl.type === 'TSExpressionWithTypeArguments' && - impl.expression.type === 'Identifier' && - impl.expression.name === 'Mutation') { - implementsMutation = true; - } - }); - } - }, - - // Check for execute method MethodDefinition(node) { - if (node.key.type === 'Identifier' && node.key.name === 'execute') { - hasExecuteMethod = true; - executeMethodNode = node; - } - }, - - 'Program:exit'() { - // Skip if file doesn't look like a mutation - const isMutationFile = filename.includes('/lib/mutations/') && - filename.endsWith('.ts') && - !filename.endsWith('.test.ts'); - - if (!isMutationFile) return; - - // Check if it's a class-based mutation - const hasClass = filename.includes('/lib/mutations/') && - !filename.endsWith('.test.ts'); - - if (hasClass && !hasExecuteMethod) { - if (executeMethodNode) { + if (node.key.type === 'Identifier' && + node.key.name === 'execute' && + node.value.type === 'FunctionExpression') { + + const returnType = node.value.returnType; + + // Check if it returns Promise> + if (!returnType || + !returnType.typeAnnotation || + !returnType.typeAnnotation.typeName || + returnType.typeAnnotation.typeName.name !== 'Promise') { context.report({ - node: executeMethodNode, - messageId: 'missingExecute', + node, + messageId: 'wrongReturnType', }); - } else { - // Find the class node to report on - const sourceCode = context.getSourceCode(); - const classNode = sourceCode.ast.body.find(node => - node.type === 'ClassDeclaration' && - node.id && - node.id.name.endsWith('Mutation') - ); - if (classNode) { - context.report({ - node: classNode, - messageId: 'missingExecute', - }); - } + return; } - return; - } - // Check for contract implementation (if it's a class) - if (hasClass && !implementsMutation && hasMutationImport) { - // Find the class node to report on - const sourceCode = context.getSourceCode(); - const classNode = sourceCode.ast.body.find(node => - node.type === 'ClassDeclaration' && - node.id && - node.id.name.endsWith('Mutation') - ); - if (classNode) { + // Check for Result type + const typeArgs = returnType.typeAnnotation.typeParameters; + if (!typeArgs || !typeArgs.params || typeArgs.params.length === 0) { context.report({ - node: classNode, - messageId: 'noMutationContract', + node, + messageId: 'wrongReturnType', + }); + return; + } + + const resultType = typeArgs.params[0]; + if (resultType.type !== 'TSTypeReference' || + !resultType.typeName || + (resultType.typeName.type === 'Identifier' && resultType.typeName.name !== 'Result')) { + context.report({ + node, + messageId: 'wrongReturnType', }); } - return; - } - - // Check execute method signature - if (executeMethodNode && executeMethodNode.value.params.length === 0) { - context.report({ - node: executeMethodNode, - messageId: 'wrongExecuteSignature', - }); } }, }; diff --git a/apps/website/eslint-rules/page-query-must-use-builders.js b/apps/website/eslint-rules/page-query-must-use-builders.js new file mode 100644 index 000000000..503f1baaf --- /dev/null +++ b/apps/website/eslint-rules/page-query-must-use-builders.js @@ -0,0 +1,100 @@ +/** + * ESLint rule to enforce PageQueries use Builders + * + * PageQueries should not manually transform API DTOs. + * They must use Builder classes to transform API DTOs to View Data. + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Enforce PageQueries use Builders for data transformation', + category: 'Page Query', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + mustUseBuilder: 'PageQueries must use Builder classes to transform API DTOs. Found manual object literal transformation in execute() method.', + multipleExports: 'PageQuery files should only export the PageQuery class, not DTOs.', + }, + }, + + create(context) { + let inPageQueryExecute = false; + let hasManualTransformation = false; + let hasMultipleExports = false; + + return { + // Track PageQuery class execute method + MethodDefinition(node) { + if (node.key.type === 'Identifier' && + node.key.name === 'execute' && + node.parent.type === 'ClassBody') { + + const classNode = node.parent.parent; + if (classNode && classNode.id && classNode.id.name && + classNode.id.name.endsWith('PageQuery')) { + inPageQueryExecute = true; + } + } + }, + + // Detect object literal assignments (manual transformation) + VariableDeclarator(node) { + if (inPageQueryExecute && node.init && node.init.type === 'ObjectExpression') { + // This is: const dto = { ... } + hasManualTransformation = true; + } + }, + + // Detect object literal in return statements + ReturnStatement(node) { + if (inPageQueryExecute && node.argument && node.argument.type === 'ObjectExpression') { + // This is: return { ... } + hasManualTransformation = true; + } + }, + + // Track exports + ExportNamedDeclaration(node) { + if (node.declaration) { + if (node.declaration.type === 'ClassDeclaration') { + const className = node.declaration.id?.name; + if (className && !className.endsWith('PageQuery')) { + hasMultipleExports = true; + } + } else if (node.declaration.type === 'InterfaceDeclaration' || + node.declaration.type === 'TypeAlias') { + hasMultipleExports = true; + } + } else if (node.specifiers && node.specifiers.length > 0) { + hasMultipleExports = true; + } + }, + + 'MethodDefinition:exit'(node) { + if (node.key.type === 'Identifier' && node.key.name === 'execute') { + inPageQueryExecute = false; + } + }, + + 'Program:exit'() { + if (hasManualTransformation) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'mustUseBuilder', + }); + } + + if (hasMultipleExports) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'multipleExports', + }); + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/page-query-return-type.js b/apps/website/eslint-rules/page-query-return-type.js index 1aec96a22..2a6392f42 100644 --- a/apps/website/eslint-rules/page-query-return-type.js +++ b/apps/website/eslint-rules/page-query-return-type.js @@ -1,123 +1,64 @@ /** - * ESLint rule to enforce Presenter contract - * - * Enforces that classes ending with "Presenter" must: - * 1. Implement Presenter interface - * 2. Have a present(input) method - * 3. Have 'use client' directive + * ESLint rule to enforce PageQuery execute return type + * + * Enforces that PageQuery.execute() returns Result */ module.exports = { meta: { type: 'problem', docs: { - description: 'Enforce Presenter contract implementation', - category: 'Best Practices', + description: 'Enforce PageQuery execute return type', + category: 'Page Query', recommended: true, }, fixable: null, schema: [], messages: { - missingImplements: 'Presenter class must implement Presenter interface', - missingPresentMethod: 'Presenter class must have present(input) method', - missingUseClient: 'Presenter must have \'use client\' directive at top-level', + wrongReturnType: 'PageQuery execute() must return Promise> - see apps/website/lib/contracts/Result.ts', }, }, create(context) { - const sourceCode = context.getSourceCode(); - let hasUseClient = false; - let presenterClassNode = null; - let hasPresentMethod = false; - let hasImplements = false; - return { - // Check for 'use client' directive - Program(node) { - // Check comments at the top - const comments = sourceCode.getAllComments(); - if (comments.length > 0) { - const firstComment = comments[0]; - if (firstComment.type === 'Line' && firstComment.value.trim() === 'use client') { - hasUseClient = true; - } else if (firstComment.type === 'Block' && firstComment.value.includes('use client')) { - hasUseClient = true; - } - } - - // Also check for 'use client' string literal as first statement - if (node.body.length > 0) { - const firstStmt = node.body[0]; - if (firstStmt && - firstStmt.type === 'ExpressionStatement' && - firstStmt.expression.type === 'Literal' && - firstStmt.expression.value === 'use client') { - hasUseClient = true; - } - } - }, - - // Find Presenter classes - ClassDeclaration(node) { - const className = node.id?.name; - - // Check if this is a Presenter class - if (className && className.endsWith('Presenter')) { - presenterClassNode = node; - - // Check if it implements any interface - if (node.implements && node.implements.length > 0) { - for (const impl of node.implements) { - // Handle GenericTypeAnnotation for Presenter - if (impl.expression.type === 'TSInstantiationExpression') { - const expr = impl.expression.expression; - if (expr.type === 'Identifier' && expr.name === 'Presenter') { - hasImplements = true; - } - } else if (impl.expression.type === 'Identifier') { - // Handle simple Presenter (without generics) - if (impl.expression.name === 'Presenter') { - hasImplements = true; - } - } - } - } - } - }, - - // Check for present method in classes MethodDefinition(node) { - if (presenterClassNode && - node.key.type === 'Identifier' && - node.key.name === 'present' && - node.parent === presenterClassNode) { - hasPresentMethod = true; - } - }, + if (node.key.type === 'Identifier' && + node.key.name === 'execute' && + node.value.type === 'FunctionExpression') { + + const returnType = node.value.returnType; + + // Check if it returns Promise> + if (!returnType || + !returnType.typeAnnotation || + !returnType.typeAnnotation.typeName || + returnType.typeAnnotation.typeName.name !== 'Promise') { + context.report({ + node, + messageId: 'wrongReturnType', + }); + return; + } - // Report violations at the end - 'Program:exit'() { - if (!presenterClassNode) return; + // Check for Result type + const typeArgs = returnType.typeAnnotation.typeParameters; + if (!typeArgs || !typeArgs.params || typeArgs.params.length === 0) { + context.report({ + node, + messageId: 'wrongReturnType', + }); + return; + } - if (!hasImplements) { - context.report({ - node: presenterClassNode, - messageId: 'missingImplements', - }); - } - - if (!hasPresentMethod) { - context.report({ - node: presenterClassNode, - messageId: 'missingPresentMethod', - }); - } - - if (!hasUseClient) { - context.report({ - node: presenterClassNode, - messageId: 'missingUseClient', - }); + const resultType = typeArgs.params[0]; + if (resultType.type !== 'TSTypeReference' || + !resultType.typeName || + (resultType.typeName.type === 'Identifier' && resultType.typeName.name !== 'Result')) { + context.report({ + node, + messageId: 'wrongReturnType', + }); + } } }, }; diff --git a/apps/website/eslint-rules/presenter-contract.js b/apps/website/eslint-rules/presenter-contract.js index 88181b938..7cf133ca2 100644 --- a/apps/website/eslint-rules/presenter-contract.js +++ b/apps/website/eslint-rules/presenter-contract.js @@ -1,123 +1,48 @@ /** - * ESLint rule to enforce Presenter contract - * - * Enforces that classes ending with "Presenter" must: - * 1. Implement Presenter interface - * 2. Have a present(input) method - * 3. Have 'use client' directive + * ESLint rule to enforce that Presenters are NOT used + * + * Presenters are deprecated and replaced with Builders. + * This rule flags any file ending with "Presenter" as an error. */ module.exports = { meta: { type: 'problem', docs: { - description: 'Enforce Presenter contract implementation', + description: 'Prevent use of deprecated Presenter pattern', category: 'Best Practices', recommended: true, }, fixable: null, schema: [], messages: { - missingImplements: 'Presenter class must implement Presenter interface - see apps/website/lib/contracts/presenters/Presenter.ts', - missingPresentMethod: 'Presenter class must have present(input) method - see apps/website/lib/contracts/presenters/Presenter.ts', - missingUseClient: 'Presenter must have \'use client\' directive at top-level - see apps/website/lib/contracts/presenters/Presenter.ts', + presenterDeprecated: 'Presenters are deprecated. Use Builder pattern instead. See apps/website/lib/contracts/builders/Builder.ts', }, }, create(context) { - const sourceCode = context.getSourceCode(); - let hasUseClient = false; - let presenterClassNode = null; - let hasPresentMethod = false; - let hasImplements = false; - return { - // Check for 'use client' directive + // Check for any file ending with "Presenter" Program(node) { - // Check comments at the top - const comments = sourceCode.getAllComments(); - if (comments.length > 0) { - const firstComment = comments[0]; - if (firstComment.type === 'Line' && firstComment.value.trim() === 'use client') { - hasUseClient = true; - } else if (firstComment.type === 'Block' && firstComment.value.includes('use client')) { - hasUseClient = true; - } - } - - // Also check for 'use client' string literal as first statement - if (node.body.length > 0) { - const firstStmt = node.body[0]; - if (firstStmt && - firstStmt.type === 'ExpressionStatement' && - firstStmt.expression.type === 'Literal' && - firstStmt.expression.value === 'use client') { - hasUseClient = true; - } + const filename = context.getFilename(); + + // Check if filename ends with Presenter.ts or Presenter.tsx + if (filename.endsWith('Presenter.ts') || filename.endsWith('Presenter.tsx')) { + context.report({ + node, + messageId: 'presenterDeprecated', + }); } }, - - // Find Presenter classes + + // Also check class declarations ending with Presenter ClassDeclaration(node) { const className = node.id?.name; - // Check if this is a Presenter class if (className && className.endsWith('Presenter')) { - presenterClassNode = node; - - // Check if it implements any interface - if (node.implements && node.implements.length > 0) { - for (const impl of node.implements) { - // Handle GenericTypeAnnotation for Presenter - if (impl.expression.type === 'TSInstantiationExpression') { - const expr = impl.expression.expression; - if (expr.type === 'Identifier' && expr.name === 'Presenter') { - hasImplements = true; - } - } else if (impl.expression.type === 'Identifier') { - // Handle simple Presenter (without generics) - if (impl.expression.name === 'Presenter') { - hasImplements = true; - } - } - } - } - } - }, - - // Check for present method in classes - MethodDefinition(node) { - if (presenterClassNode && - node.key.type === 'Identifier' && - node.key.name === 'present' && - node.parent.type === 'ClassBody' && - node.parent.parent === presenterClassNode) { - hasPresentMethod = true; - } - }, - - // Report violations at the end - 'Program:exit'() { - if (!presenterClassNode) return; - - if (!hasImplements) { context.report({ - node: presenterClassNode, - messageId: 'missingImplements', - }); - } - - if (!hasPresentMethod) { - context.report({ - node: presenterClassNode, - messageId: 'missingPresentMethod', - }); - } - - if (!hasUseClient) { - context.report({ - node: presenterClassNode, - messageId: 'missingUseClient', + node, + messageId: 'presenterDeprecated', }); } }, diff --git a/apps/website/eslint-rules/server-actions-must-use-mutations.js b/apps/website/eslint-rules/server-actions-must-use-mutations.js index 271608e39..13a8e2ab9 100644 --- a/apps/website/eslint-rules/server-actions-must-use-mutations.js +++ b/apps/website/eslint-rules/server-actions-must-use-mutations.js @@ -105,14 +105,14 @@ module.exports = { // Report violations if (hasDirectServiceInstantiation && !hasMutationUsage) { context.report({ - node: null, + node: context.getSourceCode().ast, messageId: 'mustUseMutations', }); } if (hasDirectServiceCall) { context.report({ - node: null, + node: context.getSourceCode().ast, messageId: 'noDirectService', }); } @@ -120,7 +120,7 @@ module.exports = { // If imports exist but no mutation usage if ((hasServiceImport || hasApiClientImport) && !hasMutationImport) { context.report({ - node: null, + node: context.getSourceCode().ast, messageId: 'mustUseMutations', }); } diff --git a/apps/website/eslint-rules/service-function-format.js b/apps/website/eslint-rules/service-function-format.js new file mode 100644 index 000000000..6bd63f3f1 --- /dev/null +++ b/apps/website/eslint-rules/service-function-format.js @@ -0,0 +1,143 @@ +/** + * ESLint rule to enforce Service function format + * + * Services in lib/services/ must: + * 1. Be classes named *Service (not functions) + * 2. Not have side effects (no redirect, console.log, etc.) + * 3. Use builders for data transformation + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Enforce proper Service class format', + category: 'Services', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + notAClass: 'Services must be classes named *Service, not functions. Found function "{{name}}" in lib/services/', + hasSideEffects: 'Services must be pure. Found side effect: {{effect}}', + noRedirect: 'Services cannot use redirect(). Use PageQueries or Client Components for navigation.', + multipleExports: 'Service files should only export the Service class.', + }, + }, + + create(context) { + const filename = context.getFilename(); + const isInServices = filename.includes('/lib/services/'); + let hasSideEffect = false; + let sideEffectType = ''; + let hasMultipleExports = false; + let hasFunctionExport = false; + let functionName = ''; + + return { + // Track function declarations + FunctionDeclaration(node) { + if (isInServices && node.id && node.id.name) { + hasFunctionExport = true; + functionName = node.id.name; + } + }, + + // Track function expressions + FunctionExpression(node) { + if (isInServices && node.parent && node.parent.type === 'VariableDeclarator') { + hasFunctionExport = true; + functionName = node.parent.id.name; + } + }, + + // Track arrow functions + ArrowFunctionExpression(node) { + if (isInServices && node.parent && node.parent.type === 'VariableDeclarator') { + hasFunctionExport = true; + functionName = node.parent.id.name; + } + }, + + // Track redirect calls + CallExpression(node) { + if (isInServices) { + // Check for redirect() + if (node.callee.type === 'Identifier' && node.callee.name === 'redirect') { + hasSideEffect = true; + sideEffectType = 'redirect()'; + } + + // Check for console.log, console.error, etc. + if (node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'console') { + hasSideEffect = true; + sideEffectType = 'console.' + (node.callee.property.name || 'call'); + } + + // Check for process.exit() + if (node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'process' && + node.callee.property.name === 'exit') { + hasSideEffect = true; + sideEffectType = 'process.exit()'; + } + } + }, + + // Track exports + ExportNamedDeclaration(node) { + if (isInServices) { + if (node.declaration) { + if (node.declaration.type === 'ClassDeclaration') { + const className = node.declaration.id?.name; + if (className && !className.endsWith('Service')) { + hasMultipleExports = true; + } + } else if (node.declaration.type === 'FunctionDeclaration') { + hasFunctionExport = true; + functionName = node.declaration.id?.name || ''; + } else { + // Interface, type alias, const, etc. + hasMultipleExports = true; + } + } else if (node.specifiers && node.specifiers.length > 0) { + hasMultipleExports = true; + } + } + }, + + 'Program:exit'() { + if (!isInServices) return; + + // Check for function exports + if (hasFunctionExport) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'notAClass', + data: { name: functionName }, + }); + } + + // Check for side effects + if (hasSideEffect) { + context.report({ + node: context.getSourceCode().ast, + messageId: sideEffectType === 'redirect()' ? 'noRedirect' : 'hasSideEffects', + data: { effect: sideEffectType }, + }); + } + + // Check for multiple exports + if (hasMultipleExports && !hasFunctionExport) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'multipleExports', + }); + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/services-no-instantiation.js b/apps/website/eslint-rules/services-no-instantiation.js new file mode 100644 index 000000000..dadf18873 --- /dev/null +++ b/apps/website/eslint-rules/services-no-instantiation.js @@ -0,0 +1,90 @@ +/** + * ESLint rule to forbid instantiation in services + * + * Services should not instantiate other services, API clients, or adapters. + * They should receive dependencies via constructor injection. + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Forbid instantiation in services', + category: 'Services', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + noInstantiation: 'Services should not instantiate {{type}} "{{name}}". Use constructor injection instead.', + noNewInServices: 'Services should not use new keyword. Use constructor injection.', + }, + }, + + create(context) { + const filename = context.getFilename(); + const isInServices = filename.includes('/lib/services/'); + + if (!isInServices) return {}; + + return { + // Track new keyword usage + NewExpression(node) { + const callee = node.callee; + + // Get the name being instantiated + let instantiatedName = ''; + if (callee.type === 'Identifier') { + instantiatedName = callee.name; + } else if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') { + instantiatedName = callee.property.name; + } + + // Check if it's a service, API client, or adapter + const isService = instantiatedName.endsWith('Service'); + const isApiClient = instantiatedName.endsWith('ApiClient') || + instantiatedName.endsWith('Client'); + const isAdapter = instantiatedName.includes('Adapter'); + const isRepository = instantiatedName.includes('Repository'); + const isGateway = instantiatedName.includes('Gateway'); + + if (isService || isApiClient || isAdapter || isRepository || isGateway) { + const type = isService ? 'Service' : + isApiClient ? 'API Client' : + isAdapter ? 'Adapter' : + isRepository ? 'Repository' : 'Gateway'; + + context.report({ + node, + messageId: 'noInstantiation', + data: { type, name: instantiatedName }, + }); + } else { + // Any new keyword in services is suspicious + context.report({ + node, + messageId: 'noNewInServices', + }); + } + }, + + // Also check for object literal instantiation + ObjectExpression(node) { + // Check if this is being assigned to a variable that looks like a service + const parent = node.parent; + if (parent && parent.type === 'VariableDeclarator') { + const varName = parent.id.name; + if (varName.endsWith('Service') || + varName.endsWith('Client') || + varName.includes('Adapter')) { + context.report({ + node, + messageId: 'noInstantiation', + data: { type: 'object literal', name: varName }, + }); + } + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/single-export-per-file.js b/apps/website/eslint-rules/single-export-per-file.js new file mode 100644 index 000000000..6f77754e6 --- /dev/null +++ b/apps/website/eslint-rules/single-export-per-file.js @@ -0,0 +1,83 @@ +/** + * ESLint rule to enforce single export per file + * + * Each file should export exactly one named export (or default export). + * This ensures clear, focused modules. + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Enforce single export per file', + category: 'Best Practices', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + multipleExports: 'File should have exactly one export. Found {{count}} exports: {{exports}}', + noExports: 'File should have at least one export', + }, + }, + + create(context) { + let exportCount = 0; + const exportNames = []; + + return { + // Track named exports + ExportNamedDeclaration(node) { + if (node.declaration) { + // Exporting a class, function, const, etc. + if (node.declaration.id) { + exportCount++; + exportNames.push(node.declaration.id.name); + } else if (node.declaration.declarations) { + // Multiple const exports + node.declaration.declarations.forEach(decl => { + if (decl.id && decl.id.name) { + exportCount++; + exportNames.push(decl.id.name); + } + }); + } + } else if (node.specifiers) { + // Re-exports + node.specifiers.forEach(spec => { + exportCount++; + exportNames.push(spec.exported.name); + }); + } + }, + + // Track default exports + ExportDefaultDeclaration(node) { + exportCount++; + if (node.declaration && node.declaration.id) { + exportNames.push(`default (${node.declaration.id.name})`); + } else { + exportNames.push('default'); + } + }, + + 'Program:exit'() { + if (exportCount === 0) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'noExports', + }); + } else if (exportCount > 1) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'multipleExports', + data: { + count: exportCount, + exports: exportNames.join(', '), + }, + }); + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/view-data-builder-contract.js b/apps/website/eslint-rules/view-data-builder-contract.js new file mode 100644 index 000000000..11e3fa0e0 --- /dev/null +++ b/apps/website/eslint-rules/view-data-builder-contract.js @@ -0,0 +1,84 @@ +/** + * ESLint rule to enforce View Data Builder contract + * + * View Data Builders must: + * 1. Be classes named *ViewDataBuilder + * 2. Have a static build() method + * 3. Accept an API DTO as parameter + * 4. Return View Data + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Enforce View Data Builder contract', + category: 'Builders', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + notAClass: 'View Data Builders must be classes named *ViewDataBuilder', + missingBuildMethod: 'View Data Builders must have a static build() method', + invalidBuildSignature: 'build() method must accept API DTO and return View Data', + }, + }, + + create(context) { + const filename = context.getFilename(); + const isInViewDataBuilders = filename.includes('/lib/builders/view-data/'); + + if (!isInViewDataBuilders) return {}; + + let hasBuildMethod = false; + let hasCorrectSignature = false; + + return { + // Check class declaration + ClassDeclaration(node) { + const className = node.id?.name; + + if (!className || !className.endsWith('ViewDataBuilder')) { + context.report({ + node, + messageId: 'notAClass', + }); + } + + // 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; + + // Check signature - should have at least one parameter + if (buildMethod.value && + buildMethod.value.params && + buildMethod.value.params.length > 0) { + hasCorrectSignature = true; + } + } + }, + + 'Program:exit'() { + if (!hasBuildMethod) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'missingBuildMethod', + }); + } else if (!hasCorrectSignature) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'invalidBuildSignature', + }); + } + }, + }; + }, +}; diff --git a/apps/website/eslint-rules/view-model-builder-contract.js b/apps/website/eslint-rules/view-model-builder-contract.js new file mode 100644 index 000000000..cd93e4767 --- /dev/null +++ b/apps/website/eslint-rules/view-model-builder-contract.js @@ -0,0 +1,84 @@ +/** + * ESLint rule to enforce View Model Builder contract + * + * View Model Builders must: + * 1. Be classes named *ViewModelBuilder + * 2. Have a static build() method + * 3. Accept View Data as parameter + * 4. Return View Model + */ + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Enforce View Model Builder contract', + category: 'Builders', + recommended: true, + }, + fixable: null, + schema: [], + messages: { + notAClass: 'View Model Builders must be classes named *ViewModelBuilder', + missingBuildMethod: 'View Model Builders must have a static build() method', + invalidBuildSignature: 'build() method must accept View Data and return View Model', + }, + }, + + create(context) { + const filename = context.getFilename(); + const isInViewModelBuilders = filename.includes('/lib/builders/view-models/'); + + if (!isInViewModelBuilders) return {}; + + let hasBuildMethod = false; + let hasCorrectSignature = false; + + return { + // Check class declaration + ClassDeclaration(node) { + const className = node.id?.name; + + if (!className || !className.endsWith('ViewModelBuilder')) { + context.report({ + node, + messageId: 'notAClass', + }); + } + + // 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; + + // Check signature - should have at least one parameter + if (buildMethod.value && + buildMethod.value.params && + buildMethod.value.params.length > 0) { + hasCorrectSignature = true; + } + } + }, + + 'Program:exit'() { + if (!hasBuildMethod) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'missingBuildMethod', + }); + } else if (!hasCorrectSignature) { + context.report({ + node: context.getSourceCode().ast, + messageId: 'invalidBuildSignature', + }); + } + }, + }; + }, +}; diff --git a/apps/website/lib/contracts/Result.ts b/apps/website/lib/contracts/Result.ts new file mode 100644 index 000000000..a9230fee6 --- /dev/null +++ b/apps/website/lib/contracts/Result.ts @@ -0,0 +1,76 @@ +/** + * Result type for operations that can fail + * + * Inspired by Rust's Result type, this provides a type-safe way to handle + * success and error cases without exceptions. + */ + +export interface ResultOk { + isOk(): true; + isErr(): false; + unwrap(): T; + unwrapOr(defaultValue: T): T; + getError(): never; + map(fn: (value: T) => U): ResultOk; +} + +export interface ResultError { + isOk(): false; + isErr(): true; + unwrap(): never; + unwrapOr(defaultValue: any): any; + getError(): E; + map(fn: (value: any) => U): ResultError; +} + +export class Ok implements ResultOk { + constructor(private readonly value: T) {} + + isOk(): true { return true; } + isErr(): false { return false; } + unwrap(): T { return this.value; } + unwrapOr(_defaultValue: T): T { return this.value; } + getError(): never { + throw new Error('Cannot get error from Ok result'); + } + map(fn: (value: T) => U): ResultOk { + return new Ok(fn(this.value)); + } +} + +export class Err implements ResultError { + constructor(private readonly error: E) {} + + isOk(): false { return false; } + isErr(): true { return true; } + unwrap(): never { + throw new Error(`Called unwrap on error: ${this.error}`); + } + unwrapOr(defaultValue: any): any { return defaultValue; } + getError(): E { return this.error; } + map(_fn: (value: any) => U): ResultError { + return this as any; + } +} + +/** + * Result type alias + */ +export type Result = ResultOk | ResultError; + +/** + * Result class with static factory methods + * + * Usage: + * - Result.ok(value) creates a successful Result + * - Result.err(error) creates an error Result + */ +export const Result = { + ok(value: T): Result { + return new Ok(value); + }, + + err(error: E): Result { + return new Err(error); + }, +}; diff --git a/apps/website/lib/contracts/mutations/Mutation.ts b/apps/website/lib/contracts/mutations/Mutation.ts index 4424d6c2a..bd0ecfa5c 100644 --- a/apps/website/lib/contracts/mutations/Mutation.ts +++ b/apps/website/lib/contracts/mutations/Mutation.ts @@ -1,3 +1,5 @@ +import { Result } from "../Result"; + /** * Mutation Contract * @@ -27,7 +29,7 @@ export interface Mutation { * Execute the mutation * * @param input - Mutation input - * @returns Output (optional) + * @returns Result indicating success or error */ - execute(input: TInput): Promise; + execute(input: TInput): Promise>; } \ No newline at end of file diff --git a/apps/website/lib/contracts/page-queries/PageQuery.ts b/apps/website/lib/contracts/page-queries/PageQuery.ts index 5fd499890..f0ad9087c 100644 --- a/apps/website/lib/contracts/page-queries/PageQuery.ts +++ b/apps/website/lib/contracts/page-queries/PageQuery.ts @@ -1,5 +1,4 @@ -import { PageQueryResult } from "./PageQueryResult"; - +import { Result } from "../Result"; /** * PageQuery contract interface @@ -10,18 +9,18 @@ import { PageQueryResult } from "./PageQueryResult"; * - Server-side composition classes * - Call services that call apps/api * - Assemble a Page DTO - * - Return explicit result describing route outcome + * - Explicit result describing route outcome * - Do not implement business rules * - * @template TPageDto - The Page DTO type this query produces + * @template TApiDto - The API DTO type this query produces * @template TParams - The parameters required to execute this query */ -export interface PageQuery { +export interface PageQuery { /** * Execute the page query * * @param params - Parameters required for query execution - * @returns Promise resolving to a PageQueryResult discriminated union + * @returns Promise resolving to a Result */ - execute(params: TParams): Promise>; -} \ No newline at end of file + execute(params: TParams): Promise>; +}