website refactor
This commit is contained in:
118
apps/website/eslint-rules/clean-error-handling.js
Normal file
118
apps/website/eslint-rules/clean-error-handling.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* ESLint rule to enforce clean error handling architecture
|
||||
*
|
||||
* PageQueries and Mutations must:
|
||||
* 1. Use Services for data access
|
||||
* 2. Services must return Result types
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce clean error handling architecture in PageQueries and Mutations',
|
||||
category: 'Architecture',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
mustUseServices: 'PageQueries and Mutations must use Services for data access, not API Clients directly.',
|
||||
servicesMustReturnResult: 'Services must return Result<T, DomainError> for type-safe error handling.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isPageQuery = filename.includes('/lib/page-queries/');
|
||||
const isMutation = filename.includes('/lib/mutations/');
|
||||
const isService = filename.includes('/lib/services/');
|
||||
const isRelevant = isPageQuery || isMutation || isService;
|
||||
|
||||
if (!isRelevant) return {};
|
||||
|
||||
// Track imports
|
||||
const apiClientImports = new Set();
|
||||
const serviceImports = new Set();
|
||||
|
||||
return {
|
||||
// Track imports
|
||||
ImportDeclaration(node) {
|
||||
node.specifiers.forEach(spec => {
|
||||
const importPath = node.source.value;
|
||||
if (importPath.includes('/lib/api/')) {
|
||||
apiClientImports.add(spec.local.name);
|
||||
}
|
||||
if (importPath.includes('/lib/services/')) {
|
||||
serviceImports.add(spec.local.name);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// Check PageQueries/Mutations for direct API Client usage
|
||||
NewExpression(node) {
|
||||
if (node.callee.type === 'Identifier') {
|
||||
const className = node.callee.name;
|
||||
|
||||
// Only check in PageQueries and Mutations
|
||||
if ((isPageQuery || isMutation) &&
|
||||
(className.endsWith('ApiClient') || className.endsWith('Api'))) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'mustUseServices',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Check Services for Result return type
|
||||
MethodDefinition(node) {
|
||||
if (isService && node.key.type === 'Identifier' && node.key.name === 'execute') {
|
||||
const returnType = node.value.returnType;
|
||||
|
||||
if (!returnType ||
|
||||
!returnType.typeAnnotation ||
|
||||
!returnType.typeAnnotation.typeName ||
|
||||
returnType.typeAnnotation.typeName.name !== 'Promise') {
|
||||
// Missing Promise return type
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'servicesMustReturnResult',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for Result type
|
||||
const typeArgs = returnType.typeAnnotation.typeParameters;
|
||||
if (!typeArgs || !typeArgs.params || typeArgs.params.length === 0) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'servicesMustReturnResult',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const resultType = typeArgs.params[0];
|
||||
if (resultType.type !== 'TSTypeReference' ||
|
||||
!resultType.typeName ||
|
||||
(resultType.typeName.type === 'Identifier' && resultType.typeName.name !== 'Result')) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'servicesMustReturnResult',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Check that PageQueries/Mutations have Service imports
|
||||
'Program:exit'() {
|
||||
if ((isPageQuery || isMutation) && serviceImports.size === 0) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'mustUseServices',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -37,6 +37,9 @@ 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');
|
||||
const noPageDtosDirectory = require('./no-page-dtos-directory');
|
||||
const cleanErrorHandling = require('./clean-error-handling');
|
||||
const servicesImplementContract = require('./services-implement-contract');
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
@@ -82,6 +85,7 @@ module.exports = {
|
||||
'services-must-be-marked': servicesRules['services-must-be-marked'],
|
||||
'services-no-external-api': servicesRules['no-external-api-in-services'],
|
||||
'services-must-be-pure': servicesRules['services-must-be-pure'],
|
||||
'services-must-return-result': cleanErrorHandling,
|
||||
|
||||
// Client-Only Rules
|
||||
'client-only-no-server-code': clientOnlyRules['no-server-code-in-client-only'],
|
||||
@@ -127,6 +131,13 @@ module.exports = {
|
||||
'service-function-format': serviceFunctionFormat,
|
||||
'lib-no-next-imports': libNoNextImports,
|
||||
'services-no-instantiation': servicesNoInstantiation,
|
||||
|
||||
// Page DTO Rules
|
||||
'no-page-dtos-directory': noPageDtosDirectory,
|
||||
|
||||
// Clean Error Handling Rules
|
||||
'clean-error-handling': cleanErrorHandling,
|
||||
'services-implement-contract': servicesImplementContract,
|
||||
},
|
||||
|
||||
// Configurations for different use cases
|
||||
|
||||
37
apps/website/eslint-rules/no-page-dtos-directory.js
Normal file
37
apps/website/eslint-rules/no-page-dtos-directory.js
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* ESLint rule to forbid lib/builders/page-dtos directory
|
||||
*
|
||||
* This directory is completely forbidden.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Forbid lib/builders/page-dtos directory',
|
||||
category: 'Best Practices',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
forbiddenDirectory: 'The lib/builders/page-dtos directory is forbidden. Use lib/builders/view-data/ or lib/builders/view-models/ instead.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
|
||||
return {
|
||||
Program(node) {
|
||||
// Check if file is in the forbidden directory
|
||||
if (filename.includes('lib/builders/page-dtos')) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'forbiddenDirectory',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* ESLint rule to enforce PageQueries use Builders
|
||||
*
|
||||
* PageQueries should not manually transform API DTOs.
|
||||
* PageQueries should not manually transform API DTOs or return them directly.
|
||||
* They must use Builder classes to transform API DTOs to View Data.
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,7 @@ module.exports = {
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
mustUseBuilder: 'PageQueries must use Builder classes to transform API DTOs. Found manual object literal transformation in execute() method.',
|
||||
mustUseBuilder: 'PageQueries must use Builder classes to transform API DTOs to View Data. Found manual transformation or direct API DTO return.',
|
||||
multipleExports: 'PageQuery files should only export the PageQuery class, not DTOs.',
|
||||
},
|
||||
},
|
||||
@@ -25,8 +25,17 @@ module.exports = {
|
||||
let inPageQueryExecute = false;
|
||||
let hasManualTransformation = false;
|
||||
let hasMultipleExports = false;
|
||||
let hasBuilderCall = false;
|
||||
let pageQueryClassName = null;
|
||||
|
||||
return {
|
||||
// Track PageQuery class name
|
||||
ClassDeclaration(node) {
|
||||
if (node.id && node.id.name && node.id.name.endsWith('PageQuery')) {
|
||||
pageQueryClassName = node.id.name;
|
||||
}
|
||||
},
|
||||
|
||||
// Track PageQuery class execute method
|
||||
MethodDefinition(node) {
|
||||
if (node.key.type === 'Identifier' &&
|
||||
@@ -41,19 +50,75 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
// Detect Builder calls
|
||||
CallExpression(node) {
|
||||
if (inPageQueryExecute) {
|
||||
// Check for Builder.build() or Builder.createViewData()
|
||||
if (node.callee.type === 'MemberExpression' &&
|
||||
node.callee.property.type === 'Identifier' &&
|
||||
(node.callee.property.name === 'build' || node.callee.property.name === 'createViewData')) {
|
||||
|
||||
// Check if the object is a Builder
|
||||
if (node.callee.object.type === 'Identifier' &&
|
||||
(node.callee.object.name.includes('Builder') ||
|
||||
node.callee.object.name.includes('builder'))) {
|
||||
hasBuilderCall = 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;
|
||||
if (inPageQueryExecute && node.argument) {
|
||||
// Direct object literal return
|
||||
if (node.argument.type === 'ObjectExpression') {
|
||||
hasManualTransformation = true;
|
||||
}
|
||||
// Direct identifier return (likely API DTO)
|
||||
else if (node.argument.type === 'Identifier' &&
|
||||
!node.argument.name.includes('ViewData') &&
|
||||
!node.argument.name.includes('viewData')) {
|
||||
// This might be returning an API DTO directly
|
||||
// We'll flag it as manual transformation since no builder was used
|
||||
if (!hasBuilderCall) {
|
||||
hasManualTransformation = true;
|
||||
}
|
||||
}
|
||||
// CallExpression like Result.ok(apiDto) or Result.err()
|
||||
else if (node.argument.type === 'CallExpression') {
|
||||
// Check if it's a Result call with an identifier argument
|
||||
const callExpr = node.argument;
|
||||
if (callExpr.callee.type === 'MemberExpression' &&
|
||||
callExpr.callee.object.type === 'Identifier' &&
|
||||
callExpr.callee.object.name === 'Result' &&
|
||||
callExpr.callee.property.type === 'Identifier' &&
|
||||
(callExpr.callee.property.name === 'ok' || callExpr.callee.property.name === 'err')) {
|
||||
|
||||
// If it's Result.ok(someIdentifier), check if the identifier is likely an API DTO
|
||||
if (callExpr.callee.property.name === 'ok' &&
|
||||
callExpr.arguments.length > 0 &&
|
||||
callExpr.arguments[0].type === 'Identifier') {
|
||||
const argName = callExpr.arguments[0].name;
|
||||
// Common API DTO naming patterns
|
||||
const isApiDto = argName.includes('Dto') ||
|
||||
argName.includes('api') ||
|
||||
argName === 'result' ||
|
||||
argName === 'data';
|
||||
|
||||
if (isApiDto && !hasBuilderCall) {
|
||||
hasManualTransformation = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -81,7 +146,8 @@ module.exports = {
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (hasManualTransformation) {
|
||||
// Only report if no builder was used
|
||||
if (hasManualTransformation && !hasBuilderCall) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'mustUseBuilder',
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* 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
|
||||
* 2. Create their own dependencies (API Client, Logger, ErrorReporter)
|
||||
* 3. Return Result types
|
||||
* 4. NOT use redirect() or process.exit()
|
||||
* 5. CAN use console.error() for logging (allowed)
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
@@ -19,20 +21,22 @@ module.exports = {
|
||||
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.',
|
||||
noProcessExit: 'Services cannot use process.exit().',
|
||||
multipleExports: 'Service files should only export the Service class.',
|
||||
mustReturnResult: 'Service methods must return Result<T, DomainError>.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isInServices = filename.includes('/lib/services/');
|
||||
let hasSideEffect = false;
|
||||
let sideEffectType = '';
|
||||
let hasRedirect = false;
|
||||
let hasProcessExit = false;
|
||||
let hasMultipleExports = false;
|
||||
let hasFunctionExport = false;
|
||||
let functionName = '';
|
||||
let hasResultReturningMethod = false;
|
||||
|
||||
return {
|
||||
// Track function declarations
|
||||
@@ -59,30 +63,20 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
// Track redirect calls
|
||||
// Track redirect and process.exit calls
|
||||
CallExpression(node) {
|
||||
if (isInServices) {
|
||||
// Check for redirect()
|
||||
if (node.callee.type === 'Identifier' && node.callee.name === 'redirect') {
|
||||
hasSideEffect = true;
|
||||
sideEffectType = 'redirect()';
|
||||
hasRedirect = true;
|
||||
}
|
||||
|
||||
// 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()';
|
||||
hasProcessExit = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -109,6 +103,30 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
|
||||
// Check for Result-returning methods
|
||||
MethodDefinition(node) {
|
||||
if (isInServices && node.key.type === 'Identifier') {
|
||||
const returnType = node.value?.returnType;
|
||||
|
||||
if (returnType &&
|
||||
returnType.typeAnnotation &&
|
||||
returnType.typeAnnotation.typeName &&
|
||||
returnType.typeAnnotation.typeName.name === 'Promise' &&
|
||||
returnType.typeAnnotation.typeParameters &&
|
||||
returnType.typeAnnotation.typeParameters.params.length > 0) {
|
||||
|
||||
const resultType = returnType.typeAnnotation.typeParameters.params[0];
|
||||
|
||||
if (resultType.type === 'TSTypeReference' &&
|
||||
resultType.typeName &&
|
||||
resultType.typeName.type === 'Identifier' &&
|
||||
resultType.typeName.name === 'Result') {
|
||||
hasResultReturningMethod = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (!isInServices) return;
|
||||
|
||||
@@ -121,12 +139,19 @@ module.exports = {
|
||||
});
|
||||
}
|
||||
|
||||
// Check for side effects
|
||||
if (hasSideEffect) {
|
||||
// Check for redirect
|
||||
if (hasRedirect) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: sideEffectType === 'redirect()' ? 'noRedirect' : 'hasSideEffects',
|
||||
data: { effect: sideEffectType },
|
||||
messageId: 'noRedirect',
|
||||
});
|
||||
}
|
||||
|
||||
// Check for process.exit
|
||||
if (hasProcessExit) {
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'noProcessExit',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,6 +162,10 @@ module.exports = {
|
||||
messageId: 'multipleExports',
|
||||
});
|
||||
}
|
||||
|
||||
// Check for Result-returning methods (warn if none found)
|
||||
// Note: This is a soft check - services might have private methods that don't return Result
|
||||
// The important thing is that the public API methods do
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
133
apps/website/eslint-rules/services-implement-contract.js
Normal file
133
apps/website/eslint-rules/services-implement-contract.js
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* ESLint rule to enforce Services follow clean architecture patterns
|
||||
*
|
||||
* Services must:
|
||||
* 1. Return Result types for type-safe error handling
|
||||
* 2. Use DomainError types (not strings)
|
||||
* 3. Be classes named *Service
|
||||
* 4. Create their own dependencies (API Client, Logger, ErrorReporter)
|
||||
* 5. Have NO constructor parameters (self-contained)
|
||||
*
|
||||
* Note: Method names can vary (execute(), getSomething(), etc.)
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce Services follow clean architecture patterns',
|
||||
category: 'Services',
|
||||
recommended: true,
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
mustReturnResult: 'Service methods must return Promise<Result<T, DomainError>>',
|
||||
mustUseDomainError: 'Error types must be DomainError objects, not strings',
|
||||
noConstructorParams: 'Services must be self-contained. Constructor cannot have parameters. Dependencies should be created inside the constructor.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.getFilename();
|
||||
const isInServices = filename.includes('/lib/services/');
|
||||
|
||||
if (!isInServices) return {};
|
||||
|
||||
let hasResultReturningMethod = false;
|
||||
let usesStringErrors = false;
|
||||
let hasConstructorParams = false;
|
||||
let classNode = null;
|
||||
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
classNode = node;
|
||||
const className = node.id?.name;
|
||||
|
||||
if (!className || !className.endsWith('Service')) {
|
||||
return; // Not a service class
|
||||
}
|
||||
|
||||
// Check all methods for Result return types
|
||||
node.body.body.forEach(member => {
|
||||
if (member.type === 'MethodDefinition' &&
|
||||
member.key.type === 'Identifier') {
|
||||
|
||||
// Check constructor parameters
|
||||
if (member.kind === 'constructor') {
|
||||
const params = member.value.params;
|
||||
if (params && params.length > 0) {
|
||||
hasConstructorParams = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check return types
|
||||
const returnType = member.value?.returnType;
|
||||
|
||||
if (returnType &&
|
||||
returnType.typeAnnotation &&
|
||||
returnType.typeAnnotation.typeName &&
|
||||
returnType.typeAnnotation.typeName.name === 'Promise' &&
|
||||
returnType.typeAnnotation.typeParameters &&
|
||||
returnType.typeAnnotation.typeParameters.params.length > 0) {
|
||||
|
||||
const resultType = returnType.typeAnnotation.typeParameters.params[0];
|
||||
|
||||
// Check for Result<...>
|
||||
if (resultType.type === 'TSTypeReference' &&
|
||||
resultType.typeName &&
|
||||
resultType.typeName.type === 'Identifier' &&
|
||||
resultType.typeName.name === 'Result') {
|
||||
hasResultReturningMethod = true;
|
||||
}
|
||||
|
||||
// Check for string error type
|
||||
if (resultType.type === 'TSTypeReference' &&
|
||||
resultType.typeParameters &&
|
||||
resultType.typeParameters.params.length > 1) {
|
||||
|
||||
const errorType = resultType.typeParameters.params[1];
|
||||
// Check if error is string literal or string type
|
||||
if (errorType.type === 'TSStringKeyword' ||
|
||||
(errorType.type === 'TSLiteralType' && errorType.literal.type === 'StringLiteral')) {
|
||||
usesStringErrors = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (!isInServices || !classNode) return;
|
||||
|
||||
const className = classNode.id?.name;
|
||||
if (!className || !className.endsWith('Service')) return;
|
||||
|
||||
// Error if constructor has parameters
|
||||
if (hasConstructorParams) {
|
||||
context.report({
|
||||
node: classNode,
|
||||
messageId: 'noConstructorParams',
|
||||
});
|
||||
}
|
||||
|
||||
// Error if no methods return Result
|
||||
if (!hasResultReturningMethod) {
|
||||
context.report({
|
||||
node: classNode,
|
||||
messageId: 'mustReturnResult',
|
||||
});
|
||||
}
|
||||
|
||||
// Error if using string errors
|
||||
if (usesStringErrors) {
|
||||
context.report({
|
||||
node: classNode,
|
||||
messageId: 'mustUseDomainError',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
62
apps/website/eslint-rules/test-rule-debug.js
Normal file
62
apps/website/eslint-rules/test-rule-debug.js
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Debug test for page-query-must-use-builders rule
|
||||
*/
|
||||
|
||||
const rule = require('./page-query-must-use-builders.js');
|
||||
|
||||
// Simulate the AST traversal with logging
|
||||
const mockContext = {
|
||||
getSourceCode: () => ({ ast: {} }),
|
||||
report: (data) => {
|
||||
console.log('✓ REPORT TRIGGERED:', data.messageId);
|
||||
}
|
||||
};
|
||||
|
||||
const visitor = rule.create(mockContext);
|
||||
|
||||
console.log('=== Starting Debug Test ===\n');
|
||||
|
||||
// 1. ClassDeclaration
|
||||
console.log('1. ClassDeclaration: AdminDashboardPageQuery');
|
||||
visitor.ClassDeclaration({
|
||||
id: { name: 'AdminDashboardPageQuery' }
|
||||
});
|
||||
|
||||
// 2. MethodDefinition (execute)
|
||||
console.log('2. MethodDefinition: execute()');
|
||||
visitor.MethodDefinition({
|
||||
key: { type: 'Identifier', name: 'execute' },
|
||||
parent: { type: 'ClassBody' }
|
||||
});
|
||||
|
||||
// 3. VariableDeclarator
|
||||
console.log('3. VariableDeclarator: const apiDto = ...');
|
||||
visitor.VariableDeclarator({
|
||||
init: { type: 'CallExpression' }
|
||||
});
|
||||
|
||||
// 4. ReturnStatement
|
||||
console.log('4. ReturnStatement: return Result.ok(apiDto)');
|
||||
visitor.ReturnStatement({
|
||||
argument: {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
object: { type: 'Identifier', name: 'Result' },
|
||||
property: { type: 'Identifier', name: 'ok' }
|
||||
},
|
||||
arguments: [{ type: 'Identifier', name: 'apiDto' }]
|
||||
}
|
||||
});
|
||||
|
||||
// 5. MethodDefinition:exit
|
||||
console.log('5. MethodDefinition:exit');
|
||||
visitor['MethodDefinition:exit']({
|
||||
key: { type: 'Identifier', name: 'execute' }
|
||||
});
|
||||
|
||||
// 6. Program:exit
|
||||
console.log('6. Program:exit');
|
||||
visitor['Program:exit']();
|
||||
|
||||
console.log('\n=== Test Complete ===');
|
||||
58
apps/website/eslint-rules/test-rule-fixed.js
Normal file
58
apps/website/eslint-rules/test-rule-fixed.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Fixed test with proper parent structure
|
||||
*/
|
||||
|
||||
const rule = require('./page-query-must-use-builders.js');
|
||||
|
||||
const mockContext = {
|
||||
getSourceCode: () => ({ ast: {} }),
|
||||
report: (data) => {
|
||||
console.log('✓ REPORT:', data.messageId);
|
||||
}
|
||||
};
|
||||
|
||||
const visitor = rule.create(mockContext);
|
||||
|
||||
console.log('=== Fixed Test ===\n');
|
||||
|
||||
// 1. ClassDeclaration
|
||||
const classNode = {
|
||||
id: { name: 'AdminDashboardPageQuery' }
|
||||
};
|
||||
visitor.ClassDeclaration(classNode);
|
||||
|
||||
// 2. MethodDefinition (with proper parent structure)
|
||||
const methodNode = {
|
||||
key: { type: 'Identifier', name: 'execute' },
|
||||
parent: {
|
||||
type: 'ClassBody',
|
||||
parent: classNode // This is what was missing!
|
||||
}
|
||||
};
|
||||
visitor.MethodDefinition(methodNode);
|
||||
|
||||
// 3. VariableDeclarator
|
||||
visitor.VariableDeclarator({
|
||||
init: { type: 'CallExpression' }
|
||||
});
|
||||
|
||||
// 4. ReturnStatement
|
||||
visitor.ReturnStatement({
|
||||
argument: {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
object: { type: 'Identifier', name: 'Result' },
|
||||
property: { type: 'Identifier', name: 'ok' }
|
||||
},
|
||||
arguments: [{ type: 'Identifier', name: 'apiDto' }]
|
||||
}
|
||||
});
|
||||
|
||||
// 5. MethodDefinition:exit
|
||||
visitor['MethodDefinition:exit'](methodNode);
|
||||
|
||||
// 6. Program:exit
|
||||
visitor['Program:exit']();
|
||||
|
||||
console.log('\n=== Done ===');
|
||||
58
apps/website/eslint-rules/test-rule-simple.js
Normal file
58
apps/website/eslint-rules/test-rule-simple.js
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Simple test for page-query-must-use-builders rule
|
||||
*/
|
||||
|
||||
const rule = require('./page-query-must-use-builders.js');
|
||||
|
||||
// Simulate the AST traversal
|
||||
const mockContext = {
|
||||
getSourceCode: () => ({ ast: {} }),
|
||||
report: (data) => {
|
||||
console.log('REPORT:', data.messageId);
|
||||
}
|
||||
};
|
||||
|
||||
const visitor = rule.create(mockContext);
|
||||
|
||||
// Simulate visiting AdminDashboardPageQuery
|
||||
console.log('Starting test...');
|
||||
|
||||
// 1. ClassDeclaration
|
||||
visitor.ClassDeclaration({
|
||||
id: { name: 'AdminDashboardPageQuery' }
|
||||
});
|
||||
|
||||
// 2. MethodDefinition (execute)
|
||||
visitor.MethodDefinition({
|
||||
key: { type: 'Identifier', name: 'execute' },
|
||||
parent: { type: 'ClassBody' }
|
||||
});
|
||||
|
||||
// 3. VariableDeclarator (const apiDto = ...) - NOT an ObjectExpression
|
||||
visitor.VariableDeclarator({
|
||||
init: { type: 'CallExpression' } // apiDto = await adminService.getDashboardStats()
|
||||
});
|
||||
|
||||
// 4. ReturnStatement (return Result.ok(apiDto))
|
||||
// This is a CallExpression with callee = Result.ok and argument = apiDto
|
||||
visitor.ReturnStatement({
|
||||
argument: {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
object: { type: 'Identifier', name: 'Result' },
|
||||
property: { type: 'Identifier', name: 'ok' }
|
||||
},
|
||||
arguments: [{ type: 'Identifier', name: 'apiDto' }]
|
||||
}
|
||||
});
|
||||
|
||||
// 5. MethodDefinition:exit
|
||||
visitor['MethodDefinition:exit']({
|
||||
key: { type: 'Identifier', name: 'execute' }
|
||||
});
|
||||
|
||||
// 6. Program:exit
|
||||
visitor['Program:exit']();
|
||||
|
||||
console.log('Test complete');
|
||||
190
apps/website/eslint-rules/test-rule-with-logging.js
Normal file
190
apps/website/eslint-rules/test-rule-with-logging.js
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Test with logging inside the rule
|
||||
*/
|
||||
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Test',
|
||||
category: 'Page Query',
|
||||
},
|
||||
fixable: null,
|
||||
schema: [],
|
||||
messages: {
|
||||
mustUseBuilder: 'Must use builders',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
let inPageQueryExecute = false;
|
||||
let hasManualTransformation = false;
|
||||
let hasBuilderCall = false;
|
||||
let pageQueryClassName = null;
|
||||
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (node.id && node.id.name && node.id.name.endsWith('PageQuery')) {
|
||||
pageQueryClassName = node.id.name;
|
||||
console.log(' [ClassDeclaration] Found:', pageQueryClassName);
|
||||
}
|
||||
},
|
||||
|
||||
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;
|
||||
console.log(' [MethodDefinition] execute() in PageQuery');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
CallExpression(node) {
|
||||
if (inPageQueryExecute) {
|
||||
console.log(' [CallExpression] inside execute:', node.callee.type);
|
||||
if (node.callee.type === 'MemberExpression' &&
|
||||
node.callee.property.type === 'Identifier' &&
|
||||
(node.callee.property.name === 'build' || node.callee.property.name === 'createViewData')) {
|
||||
console.log(' [CallExpression] Found Builder call!');
|
||||
hasBuilderCall = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
VariableDeclarator(node) {
|
||||
if (inPageQueryExecute && node.init && node.init.type === 'ObjectExpression') {
|
||||
console.log(' [VariableDeclarator] ObjectExpression found');
|
||||
hasManualTransformation = true;
|
||||
}
|
||||
},
|
||||
|
||||
ReturnStatement(node) {
|
||||
if (inPageQueryExecute && node.argument) {
|
||||
console.log(' [ReturnStatement] type:', node.argument.type);
|
||||
|
||||
// Direct object literal return
|
||||
if (node.argument.type === 'ObjectExpression') {
|
||||
console.log(' [ReturnStatement] ObjectExpression - setting manual transformation');
|
||||
hasManualTransformation = true;
|
||||
}
|
||||
// Direct identifier return
|
||||
else if (node.argument.type === 'Identifier') {
|
||||
console.log(' [ReturnStatement] Identifier:', node.argument.name);
|
||||
if (!node.argument.name.includes('ViewData') && !node.argument.name.includes('viewData')) {
|
||||
console.log(' [ReturnStatement] Not ViewData, checking builder call...');
|
||||
if (!hasBuilderCall) {
|
||||
console.log(' [ReturnStatement] No builder call - setting manual transformation');
|
||||
hasManualTransformation = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// CallExpression
|
||||
else if (node.argument.type === 'CallExpression') {
|
||||
console.log(' [ReturnStatement] CallExpression');
|
||||
const callExpr = node.argument;
|
||||
if (callExpr.callee.type === 'MemberExpression' &&
|
||||
callExpr.callee.object.type === 'Identifier' &&
|
||||
callExpr.callee.object.name === 'Result' &&
|
||||
callExpr.callee.property.type === 'Identifier' &&
|
||||
(callExpr.callee.property.name === 'ok' || callExpr.callee.property.name === 'err')) {
|
||||
|
||||
console.log(' [ReturnStatement] Result.' + callExpr.callee.property.name + '()');
|
||||
|
||||
if (callExpr.callee.property.name === 'ok' &&
|
||||
callExpr.arguments.length > 0 &&
|
||||
callExpr.arguments[0].type === 'Identifier') {
|
||||
const argName = callExpr.arguments[0].name;
|
||||
console.log(' [ReturnStatement] Argument:', argName);
|
||||
|
||||
const isApiDto = argName.includes('Dto') ||
|
||||
argName.includes('api') ||
|
||||
argName === 'result' ||
|
||||
argName === 'data';
|
||||
|
||||
console.log(' [ReturnStatement] Is API DTO?', isApiDto);
|
||||
console.log(' [ReturnStatement] Has builder call?', hasBuilderCall);
|
||||
|
||||
if (isApiDto && !hasBuilderCall) {
|
||||
console.log(' [ReturnStatement] ✓ Setting manual transformation!');
|
||||
hasManualTransformation = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'MethodDefinition:exit'(node) {
|
||||
if (node.key.type === 'Identifier' && node.key.name === 'execute') {
|
||||
inPageQueryExecute = false;
|
||||
console.log(' [MethodDefinition:exit] execute()');
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
console.log('\n[Program:exit]');
|
||||
console.log(' hasManualTransformation:', hasManualTransformation);
|
||||
console.log(' hasBuilderCall:', hasBuilderCall);
|
||||
console.log(' Should report?', hasManualTransformation && !hasBuilderCall);
|
||||
|
||||
if (hasManualTransformation && !hasBuilderCall) {
|
||||
console.log(' ✓✓✓ REPORTING ERROR ✓✓✓');
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'mustUseBuilder',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Test it
|
||||
const mockContext = {
|
||||
getSourceCode: () => ({ ast: {} }),
|
||||
report: (data) => {
|
||||
console.log('\n*** REPORT CALLED ***');
|
||||
}
|
||||
};
|
||||
|
||||
const visitor = rule.create(mockContext);
|
||||
|
||||
console.log('=== Test with Logging ===\n');
|
||||
|
||||
visitor.ClassDeclaration({
|
||||
id: { name: 'AdminDashboardPageQuery' }
|
||||
});
|
||||
|
||||
visitor.MethodDefinition({
|
||||
key: { type: 'Identifier', name: 'execute' },
|
||||
parent: { type: 'ClassBody' }
|
||||
});
|
||||
|
||||
visitor.VariableDeclarator({
|
||||
init: { type: 'CallExpression' }
|
||||
});
|
||||
|
||||
visitor.ReturnStatement({
|
||||
argument: {
|
||||
type: 'CallExpression',
|
||||
callee: {
|
||||
type: 'MemberExpression',
|
||||
object: { type: 'Identifier', name: 'Result' },
|
||||
property: { type: 'Identifier', name: 'ok' }
|
||||
},
|
||||
arguments: [{ type: 'Identifier', name: 'apiDto' }]
|
||||
}
|
||||
});
|
||||
|
||||
visitor['MethodDefinition:exit']({
|
||||
key: { type: 'Identifier', name: 'execute' }
|
||||
});
|
||||
|
||||
visitor['Program:exit']();
|
||||
|
||||
console.log('\n=== Done ===');
|
||||
63
apps/website/eslint-rules/test-rule.js
Normal file
63
apps/website/eslint-rules/test-rule.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Test script for page-query-must-use-builders rule
|
||||
*/
|
||||
|
||||
const rule = require('./page-query-must-use-builders.js');
|
||||
const { Linter } = require('eslint');
|
||||
|
||||
const linter = new Linter();
|
||||
|
||||
// Register the plugin
|
||||
linter.defineRule('gridpilot-rules/page-query-must-use-builders', rule);
|
||||
|
||||
const code = `
|
||||
import { AdminApiClient } from '@/lib/api/admin/AdminApiClient';
|
||||
import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||
import { AdminService } from '@/lib/services/admin/AdminService';
|
||||
import type { DashboardStats } from '@/lib/api/admin/AdminApiClient';
|
||||
|
||||
export class AdminDashboardPageQuery implements PageQuery<DashboardStats, void> {
|
||||
async execute(): Promise<Result<DashboardStats, string>> {
|
||||
try {
|
||||
const logger = new ConsoleLogger();
|
||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||
showUserNotifications: false,
|
||||
logToConsole: true,
|
||||
reportToExternal: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||
const apiClient = new AdminApiClient(baseUrl, errorReporter, logger);
|
||||
const adminService = new AdminService(apiClient);
|
||||
|
||||
const apiDto = await adminService.getDashboardStats();
|
||||
|
||||
return Result.ok(apiDto);
|
||||
} catch (err) {
|
||||
console.error('AdminDashboardPageQuery failed:', err);
|
||||
|
||||
if (err instanceof Error && (err.message.includes('403') || err.message.includes('401'))) {
|
||||
return Result.err('notFound');
|
||||
}
|
||||
|
||||
return Result.err('admin_dashboard_fetch_failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const messages = linter.verify(code, {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'gridpilot-rules/page-query-must-use-builders': 'error',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('ESLint messages:', messages);
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* 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
|
||||
* 3. Accept API DTO as parameter (named 'apiDto', NOT 'pageDto')
|
||||
* 4. Return View Data
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,7 @@ module.exports = {
|
||||
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',
|
||||
wrongParameterName: 'Parameter must be named "apiDto", not "pageDto" or other names',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -33,6 +34,7 @@ module.exports = {
|
||||
|
||||
let hasBuildMethod = false;
|
||||
let hasCorrectSignature = false;
|
||||
let hasCorrectParameterName = false;
|
||||
|
||||
return {
|
||||
// Check class declaration
|
||||
@@ -47,7 +49,7 @@ module.exports = {
|
||||
}
|
||||
|
||||
// Check for static build method
|
||||
const buildMethod = node.body.body.find(member =>
|
||||
const buildMethod = node.body.body.find(member =>
|
||||
member.type === 'MethodDefinition' &&
|
||||
member.key.type === 'Identifier' &&
|
||||
member.key.name === 'build' &&
|
||||
@@ -58,10 +60,22 @@ module.exports = {
|
||||
hasBuildMethod = true;
|
||||
|
||||
// Check signature - should have at least one parameter
|
||||
if (buildMethod.value &&
|
||||
buildMethod.value.params &&
|
||||
if (buildMethod.value &&
|
||||
buildMethod.value.params &&
|
||||
buildMethod.value.params.length > 0) {
|
||||
hasCorrectSignature = true;
|
||||
|
||||
// Check parameter name
|
||||
const firstParam = buildMethod.value.params[0];
|
||||
if (firstParam.type === 'Identifier' && firstParam.name === 'apiDto') {
|
||||
hasCorrectParameterName = true;
|
||||
} else if (firstParam.type === 'Identifier' && firstParam.name === 'pageDto') {
|
||||
// Report specific error for pageDto
|
||||
context.report({
|
||||
node: firstParam,
|
||||
messageId: 'wrongParameterName',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -77,6 +91,12 @@ module.exports = {
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'invalidBuildSignature',
|
||||
});
|
||||
} else if (!hasCorrectParameterName) {
|
||||
// Only report if not already reported for pageDto
|
||||
context.report({
|
||||
node: context.getSourceCode().ast,
|
||||
messageId: 'wrongParameterName',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user