website refactor
This commit is contained in:
94
apps/website/eslint-rules/filename-matches-export.js
Normal file
94
apps/website/eslint-rules/filename-matches-export.js
Normal file
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
108
apps/website/eslint-rules/lib-no-next-imports.js
Normal file
108
apps/website/eslint-rules/lib-no-next-imports.js
Normal file
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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<TOutput>',
|
||||
wrongReturnType: 'Mutations must return Promise<Result<void, string>> - 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<Result<...>>
|
||||
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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
100
apps/website/eslint-rules/page-query-must-use-builders.js
Normal file
100
apps/website/eslint-rules/page-query-must-use-builders.js
Normal file
@@ -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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,123 +1,64 @@
|
||||
/**
|
||||
* ESLint rule to enforce Presenter contract
|
||||
*
|
||||
* Enforces that classes ending with "Presenter" must:
|
||||
* 1. Implement Presenter<TInput, TOutput> 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<ApiDto, string>
|
||||
*/
|
||||
|
||||
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<TInput, TOutput> 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<Result<ApiDto, string>> - 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<TInput, TOutput>
|
||||
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<Result<...>>
|
||||
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',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,123 +1,48 @@
|
||||
/**
|
||||
* ESLint rule to enforce Presenter contract
|
||||
*
|
||||
* Enforces that classes ending with "Presenter" must:
|
||||
* 1. Implement Presenter<TInput, TOutput> 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<TInput, TOutput> 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<TInput, TOutput>
|
||||
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',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
143
apps/website/eslint-rules/service-function-format.js
Normal file
143
apps/website/eslint-rules/service-function-format.js
Normal file
@@ -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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
90
apps/website/eslint-rules/services-no-instantiation.js
Normal file
90
apps/website/eslint-rules/services-no-instantiation.js
Normal file
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
83
apps/website/eslint-rules/single-export-per-file.js
Normal file
83
apps/website/eslint-rules/single-export-per-file.js
Normal file
@@ -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(', '),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
84
apps/website/eslint-rules/view-data-builder-contract.js
Normal file
84
apps/website/eslint-rules/view-data-builder-contract.js
Normal file
@@ -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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
84
apps/website/eslint-rules/view-model-builder-contract.js
Normal file
84
apps/website/eslint-rules/view-model-builder-contract.js
Normal file
@@ -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',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
76
apps/website/lib/contracts/Result.ts
Normal file
76
apps/website/lib/contracts/Result.ts
Normal file
@@ -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<T> {
|
||||
isOk(): true;
|
||||
isErr(): false;
|
||||
unwrap(): T;
|
||||
unwrapOr(defaultValue: T): T;
|
||||
getError(): never;
|
||||
map<U>(fn: (value: T) => U): ResultOk<U>;
|
||||
}
|
||||
|
||||
export interface ResultError<E> {
|
||||
isOk(): false;
|
||||
isErr(): true;
|
||||
unwrap(): never;
|
||||
unwrapOr(defaultValue: any): any;
|
||||
getError(): E;
|
||||
map<U>(fn: (value: any) => U): ResultError<E>;
|
||||
}
|
||||
|
||||
export class Ok<T> implements ResultOk<T> {
|
||||
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<U>(fn: (value: T) => U): ResultOk<U> {
|
||||
return new Ok(fn(this.value));
|
||||
}
|
||||
}
|
||||
|
||||
export class Err<E> implements ResultError<E> {
|
||||
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<U>(_fn: (value: any) => U): ResultError<E> {
|
||||
return this as any;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type alias
|
||||
*/
|
||||
export type Result<T, E> = ResultOk<T> | ResultError<E>;
|
||||
|
||||
/**
|
||||
* 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<T>(value: T): Result<T, never> {
|
||||
return new Ok(value);
|
||||
},
|
||||
|
||||
err<E>(error: E): Result<never, E> {
|
||||
return new Err(error);
|
||||
},
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Result } from "../Result";
|
||||
|
||||
/**
|
||||
* Mutation Contract
|
||||
*
|
||||
@@ -27,7 +29,7 @@ export interface Mutation<TInput = void, TOutput = void> {
|
||||
* Execute the mutation
|
||||
*
|
||||
* @param input - Mutation input
|
||||
* @returns Output (optional)
|
||||
* @returns Result indicating success or error
|
||||
*/
|
||||
execute(input: TInput): Promise<TOutput>;
|
||||
execute(input: TInput): Promise<Result<TOutput, string>>;
|
||||
}
|
||||
@@ -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<TPageDto, TParams = void> {
|
||||
export interface PageQuery<TApiDto, TParams = void> {
|
||||
/**
|
||||
* 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<PageQueryResult<TPageDto>>;
|
||||
}
|
||||
execute(params: TParams): Promise<Result<TApiDto, string>>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user