Files
gridpilot.gg/apps/website/eslint-rules/services-implement-contract.js
2026-01-21 18:03:56 +01:00

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