website refactor

This commit is contained in:
2026-01-13 00:16:14 +01:00
parent 5ea95eaf51
commit d18e2979ba
17 changed files with 1056 additions and 303 deletions

View 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,
},
});
}
},
};
},
};

View File

@@ -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',

View File

@@ -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',
},
},

View 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 },
});
}
}
}
},
};
},
};

View File

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

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

View File

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

View File

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

View File

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

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

View 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 },
});
}
}
},
};
},
};

View 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(', '),
},
});
}
},
};
},
};

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

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

View 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);
},
};

View File

@@ -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>>;
}

View File

@@ -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>>;
}