158 lines
5.4 KiB
JavaScript
158 lines
5.4 KiB
JavaScript
/**
|
|
* ESLint rule to enforce Services follow clean architecture patterns
|
|
*
|
|
* Services must:
|
|
* 1. Explicitly implement Service<TApiDto, TError> interface
|
|
* 2. Return Result types for type-safe error handling
|
|
* 3. Use DomainError types (not strings)
|
|
* 4. Be classes named *Service
|
|
* 5. Create their own dependencies (API Client, Logger, ErrorReporter)
|
|
* 6. 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.',
|
|
mustImplementContract: 'Services must explicitly implement Service<TApiDto, TError> interface',
|
|
servicesMustBeMarked: 'Services must be explicitly marked with @Service decorator or similar (placeholder for services-must-be-marked)',
|
|
},
|
|
},
|
|
|
|
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 if class implements Service interface
|
|
// The implements clause can be:
|
|
// - implements Service
|
|
// - implements Service<TApiDto, TError>
|
|
// The AST structure is:
|
|
// impl.expression: Identifier { name: 'Service' }
|
|
// impl.typeArguments: TSTypeParameterInstantiation (for generic types)
|
|
const implementsService = node.implements && node.implements.some(impl => {
|
|
if (impl.expression.type === 'Identifier') {
|
|
return impl.expression.name === 'Service';
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (!implementsService) {
|
|
context.report({
|
|
node: node.id,
|
|
messageId: 'mustImplementContract',
|
|
});
|
|
}
|
|
|
|
// 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',
|
|
});
|
|
}
|
|
},
|
|
};
|
|
},
|
|
};
|