mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
refactor: migrate naming convention detector to AST-based analysis
This commit is contained in:
@@ -240,6 +240,7 @@ export class ExecuteDetection {
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const namingViolations = this.namingConventionDetector.detectViolations(
|
||||
file.content,
|
||||
file.path.filename,
|
||||
file.layer,
|
||||
file.path.relative,
|
||||
|
||||
@@ -7,12 +7,14 @@ export interface INamingConventionDetector {
|
||||
/**
|
||||
* Detects naming convention violations for a given file
|
||||
*
|
||||
* @param content - Source code content to analyze
|
||||
* @param fileName - Name of the file to check (e.g., "UserService.ts")
|
||||
* @param layer - Architectural layer of the file (domain, application, infrastructure, shared)
|
||||
* @param filePath - Relative file path for context
|
||||
* @returns Array of naming convention violations
|
||||
*/
|
||||
detectViolations(
|
||||
content: string,
|
||||
fileName: string,
|
||||
layer: string | undefined,
|
||||
filePath: string,
|
||||
|
||||
@@ -1,37 +1,72 @@
|
||||
import Parser from "tree-sitter"
|
||||
import { INamingConventionDetector } from "../../domain/services/INamingConventionDetector"
|
||||
import { NamingViolation } from "../../domain/value-objects/NamingViolation"
|
||||
import {
|
||||
LAYERS,
|
||||
NAMING_PATTERNS,
|
||||
NAMING_VIOLATION_TYPES,
|
||||
USE_CASE_VERBS,
|
||||
} from "../../shared/constants/rules"
|
||||
import {
|
||||
EXCLUDED_FILES,
|
||||
FILE_SUFFIXES,
|
||||
NAMING_ERROR_MESSAGES,
|
||||
PATH_PATTERNS,
|
||||
PATTERN_WORDS,
|
||||
} from "../constants/detectorPatterns"
|
||||
import { NAMING_SUGGESTION_DEFAULT } from "../constants/naming-patterns"
|
||||
import { FILE_EXTENSIONS } from "../../shared/constants"
|
||||
import { EXCLUDED_FILES } from "../constants/detectorPatterns"
|
||||
import { CodeParser } from "../parsers/CodeParser"
|
||||
import { AstClassNameAnalyzer } from "../strategies/naming/AstClassNameAnalyzer"
|
||||
import { AstFunctionNameAnalyzer } from "../strategies/naming/AstFunctionNameAnalyzer"
|
||||
import { AstInterfaceNameAnalyzer } from "../strategies/naming/AstInterfaceNameAnalyzer"
|
||||
import { AstNamingTraverser } from "../strategies/naming/AstNamingTraverser"
|
||||
import { AstVariableNameAnalyzer } from "../strategies/naming/AstVariableNameAnalyzer"
|
||||
|
||||
/**
|
||||
* Detects naming convention violations based on Clean Architecture layers
|
||||
* Detects naming convention violations using AST-based analysis
|
||||
*
|
||||
* This detector ensures that files follow naming conventions appropriate to their layer:
|
||||
* - Domain: Entities (nouns), Services (*Service), Value Objects, Repository interfaces (I*Repository)
|
||||
* - Application: Use cases (verbs), DTOs (*Dto/*Request/*Response), Mappers (*Mapper)
|
||||
* - Infrastructure: Controllers (*Controller), Repository implementations (*Repository), Services (*Service/*Adapter)
|
||||
* This detector uses Abstract Syntax Tree (AST) analysis via tree-sitter to identify
|
||||
* naming convention violations in classes, interfaces, functions, and variables
|
||||
* according to Clean Architecture layer rules.
|
||||
*
|
||||
* The detector uses a modular architecture with specialized components:
|
||||
* - AstClassNameAnalyzer: Analyzes class names
|
||||
* - AstInterfaceNameAnalyzer: Analyzes interface names
|
||||
* - AstFunctionNameAnalyzer: Analyzes function and method names
|
||||
* - AstVariableNameAnalyzer: Analyzes variable and constant names
|
||||
* - AstNamingTraverser: Traverses the AST and coordinates analyzers
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const detector = new NamingConventionDetector()
|
||||
* const violations = detector.detectViolations('UserDto.ts', 'domain', 'src/domain/UserDto.ts')
|
||||
* // Returns violation: DTOs should not be in domain layer
|
||||
* const code = `
|
||||
* class userService { // Wrong: should be UserService
|
||||
* GetUser() {} // Wrong: should be getUser
|
||||
* }
|
||||
* `
|
||||
* const violations = detector.detectViolations(code, 'UserService.ts', 'domain', 'src/domain/UserService.ts')
|
||||
* // Returns array of NamingViolation objects
|
||||
* ```
|
||||
*/
|
||||
export class NamingConventionDetector implements INamingConventionDetector {
|
||||
private readonly parser: CodeParser
|
||||
private readonly traverser: AstNamingTraverser
|
||||
|
||||
constructor() {
|
||||
this.parser = new CodeParser()
|
||||
|
||||
const classAnalyzer = new AstClassNameAnalyzer()
|
||||
const interfaceAnalyzer = new AstInterfaceNameAnalyzer()
|
||||
const functionAnalyzer = new AstFunctionNameAnalyzer()
|
||||
const variableAnalyzer = new AstVariableNameAnalyzer()
|
||||
|
||||
this.traverser = new AstNamingTraverser(
|
||||
classAnalyzer,
|
||||
interfaceAnalyzer,
|
||||
functionAnalyzer,
|
||||
variableAnalyzer,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects naming convention violations in the given code
|
||||
*
|
||||
* @param content - Source code to analyze
|
||||
* @param fileName - Name of the file being analyzed
|
||||
* @param layer - Architectural layer (domain, application, infrastructure, shared)
|
||||
* @param filePath - File path for context (used in violation reports)
|
||||
* @returns Array of detected naming violations
|
||||
*/
|
||||
public detectViolations(
|
||||
content: string,
|
||||
fileName: string,
|
||||
layer: string | undefined,
|
||||
filePath: string,
|
||||
@@ -44,235 +79,23 @@ export class NamingConventionDetector implements INamingConventionDetector {
|
||||
return []
|
||||
}
|
||||
|
||||
switch (layer) {
|
||||
case LAYERS.DOMAIN:
|
||||
return this.checkDomainLayer(fileName, filePath)
|
||||
case LAYERS.APPLICATION:
|
||||
return this.checkApplicationLayer(fileName, filePath)
|
||||
case LAYERS.INFRASTRUCTURE:
|
||||
return this.checkInfrastructureLayer(fileName, filePath)
|
||||
case LAYERS.SHARED:
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
if (!content || content.trim().length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const tree = this.parseCode(content, filePath)
|
||||
return this.traverser.traverse(tree, content, layer, filePath)
|
||||
}
|
||||
|
||||
private checkDomainLayer(fileName: string, filePath: string): NamingViolation[] {
|
||||
const violations: NamingViolation[] = []
|
||||
|
||||
const forbiddenPatterns = NAMING_PATTERNS.DOMAIN.ENTITY.forbidden ?? []
|
||||
|
||||
for (const forbidden of forbiddenPatterns) {
|
||||
if (fileName.includes(forbidden)) {
|
||||
violations.push(
|
||||
NamingViolation.create(
|
||||
fileName,
|
||||
NAMING_VIOLATION_TYPES.FORBIDDEN_PATTERN,
|
||||
LAYERS.DOMAIN,
|
||||
filePath,
|
||||
NAMING_ERROR_MESSAGES.DOMAIN_FORBIDDEN,
|
||||
fileName,
|
||||
NAMING_SUGGESTION_DEFAULT,
|
||||
),
|
||||
)
|
||||
return violations
|
||||
}
|
||||
/**
|
||||
* Parses code based on file extension
|
||||
*/
|
||||
private parseCode(code: string, filePath: string): Parser.Tree {
|
||||
if (filePath.endsWith(FILE_EXTENSIONS.TYPESCRIPT_JSX)) {
|
||||
return this.parser.parseTsx(code)
|
||||
} else if (filePath.endsWith(FILE_EXTENSIONS.TYPESCRIPT)) {
|
||||
return this.parser.parseTypeScript(code)
|
||||
}
|
||||
|
||||
if (fileName.endsWith(FILE_SUFFIXES.SERVICE)) {
|
||||
if (!NAMING_PATTERNS.DOMAIN.SERVICE.pattern.test(fileName)) {
|
||||
violations.push(
|
||||
NamingViolation.create(
|
||||
fileName,
|
||||
NAMING_VIOLATION_TYPES.WRONG_CASE,
|
||||
LAYERS.DOMAIN,
|
||||
filePath,
|
||||
NAMING_PATTERNS.DOMAIN.SERVICE.description,
|
||||
fileName,
|
||||
),
|
||||
)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
if (
|
||||
fileName.startsWith(PATTERN_WORDS.I_PREFIX) &&
|
||||
fileName.includes(PATTERN_WORDS.REPOSITORY)
|
||||
) {
|
||||
if (!NAMING_PATTERNS.DOMAIN.REPOSITORY_INTERFACE.pattern.test(fileName)) {
|
||||
violations.push(
|
||||
NamingViolation.create(
|
||||
fileName,
|
||||
NAMING_VIOLATION_TYPES.WRONG_PREFIX,
|
||||
LAYERS.DOMAIN,
|
||||
filePath,
|
||||
NAMING_PATTERNS.DOMAIN.REPOSITORY_INTERFACE.description,
|
||||
fileName,
|
||||
),
|
||||
)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
if (!NAMING_PATTERNS.DOMAIN.ENTITY.pattern.test(fileName)) {
|
||||
violations.push(
|
||||
NamingViolation.create(
|
||||
fileName,
|
||||
NAMING_VIOLATION_TYPES.WRONG_CASE,
|
||||
LAYERS.DOMAIN,
|
||||
filePath,
|
||||
NAMING_PATTERNS.DOMAIN.ENTITY.description,
|
||||
fileName,
|
||||
NAMING_ERROR_MESSAGES.USE_PASCAL_CASE,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
private checkApplicationLayer(fileName: string, filePath: string): NamingViolation[] {
|
||||
const violations: NamingViolation[] = []
|
||||
|
||||
if (
|
||||
fileName.endsWith(FILE_SUFFIXES.DTO) ||
|
||||
fileName.endsWith(FILE_SUFFIXES.REQUEST) ||
|
||||
fileName.endsWith(FILE_SUFFIXES.RESPONSE)
|
||||
) {
|
||||
if (!NAMING_PATTERNS.APPLICATION.DTO.pattern.test(fileName)) {
|
||||
violations.push(
|
||||
NamingViolation.create(
|
||||
fileName,
|
||||
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
|
||||
LAYERS.APPLICATION,
|
||||
filePath,
|
||||
NAMING_PATTERNS.APPLICATION.DTO.description,
|
||||
fileName,
|
||||
NAMING_ERROR_MESSAGES.USE_DTO_SUFFIX,
|
||||
),
|
||||
)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
if (fileName.endsWith(FILE_SUFFIXES.MAPPER)) {
|
||||
if (!NAMING_PATTERNS.APPLICATION.MAPPER.pattern.test(fileName)) {
|
||||
violations.push(
|
||||
NamingViolation.create(
|
||||
fileName,
|
||||
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
|
||||
LAYERS.APPLICATION,
|
||||
filePath,
|
||||
NAMING_PATTERNS.APPLICATION.MAPPER.description,
|
||||
fileName,
|
||||
),
|
||||
)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
const startsWithVerb = this.startsWithCommonVerb(fileName)
|
||||
if (startsWithVerb) {
|
||||
if (!NAMING_PATTERNS.APPLICATION.USE_CASE.pattern.test(fileName)) {
|
||||
violations.push(
|
||||
NamingViolation.create(
|
||||
fileName,
|
||||
NAMING_VIOLATION_TYPES.WRONG_VERB_NOUN,
|
||||
LAYERS.APPLICATION,
|
||||
filePath,
|
||||
NAMING_PATTERNS.APPLICATION.USE_CASE.description,
|
||||
fileName,
|
||||
NAMING_ERROR_MESSAGES.USE_VERB_NOUN,
|
||||
),
|
||||
)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
if (
|
||||
filePath.includes(PATH_PATTERNS.USE_CASES) ||
|
||||
filePath.includes(PATH_PATTERNS.USE_CASES_ALT)
|
||||
) {
|
||||
const hasVerb = this.startsWithCommonVerb(fileName)
|
||||
if (!hasVerb) {
|
||||
violations.push(
|
||||
NamingViolation.create(
|
||||
fileName,
|
||||
NAMING_VIOLATION_TYPES.WRONG_VERB_NOUN,
|
||||
LAYERS.APPLICATION,
|
||||
filePath,
|
||||
NAMING_ERROR_MESSAGES.USE_CASE_START_VERB,
|
||||
fileName,
|
||||
`Start with a verb like: ${USE_CASE_VERBS.slice(0, 5).join(", ")}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
private checkInfrastructureLayer(fileName: string, filePath: string): NamingViolation[] {
|
||||
const violations: NamingViolation[] = []
|
||||
|
||||
if (fileName.endsWith(FILE_SUFFIXES.CONTROLLER)) {
|
||||
if (!NAMING_PATTERNS.INFRASTRUCTURE.CONTROLLER.pattern.test(fileName)) {
|
||||
violations.push(
|
||||
NamingViolation.create(
|
||||
fileName,
|
||||
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
|
||||
LAYERS.INFRASTRUCTURE,
|
||||
filePath,
|
||||
NAMING_PATTERNS.INFRASTRUCTURE.CONTROLLER.description,
|
||||
fileName,
|
||||
),
|
||||
)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
if (
|
||||
fileName.endsWith(FILE_SUFFIXES.REPOSITORY) &&
|
||||
!fileName.startsWith(PATTERN_WORDS.I_PREFIX)
|
||||
) {
|
||||
if (!NAMING_PATTERNS.INFRASTRUCTURE.REPOSITORY_IMPL.pattern.test(fileName)) {
|
||||
violations.push(
|
||||
NamingViolation.create(
|
||||
fileName,
|
||||
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
|
||||
LAYERS.INFRASTRUCTURE,
|
||||
filePath,
|
||||
NAMING_PATTERNS.INFRASTRUCTURE.REPOSITORY_IMPL.description,
|
||||
fileName,
|
||||
),
|
||||
)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
if (fileName.endsWith(FILE_SUFFIXES.SERVICE) || fileName.endsWith(FILE_SUFFIXES.ADAPTER)) {
|
||||
if (!NAMING_PATTERNS.INFRASTRUCTURE.SERVICE.pattern.test(fileName)) {
|
||||
violations.push(
|
||||
NamingViolation.create(
|
||||
fileName,
|
||||
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
|
||||
LAYERS.INFRASTRUCTURE,
|
||||
filePath,
|
||||
NAMING_PATTERNS.INFRASTRUCTURE.SERVICE.description,
|
||||
fileName,
|
||||
),
|
||||
)
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
private startsWithCommonVerb(fileName: string): boolean {
|
||||
const baseFileName = fileName.replace(/\.tsx?$/, "")
|
||||
|
||||
return USE_CASE_VERBS.some((verb) => baseFileName.startsWith(verb))
|
||||
return this.parser.parseJavaScript(code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,28 @@ export const NAMING_ERROR_MESSAGES = {
|
||||
USE_DTO_SUFFIX: "Use *Dto, *Request, or *Response suffix (e.g., UserResponseDto.ts)",
|
||||
USE_VERB_NOUN: "Use verb + noun in PascalCase (e.g., CreateUser.ts, UpdateProfile.ts)",
|
||||
USE_CASE_START_VERB: "Use cases should start with a verb",
|
||||
DOMAIN_SERVICE_PASCAL_CASE: "Domain services must be PascalCase ending with 'Service'",
|
||||
DOMAIN_ENTITY_PASCAL_CASE: "Domain entities must be PascalCase nouns",
|
||||
DTO_PASCAL_CASE: "DTOs must be PascalCase ending with 'Dto', 'Request', or 'Response'",
|
||||
MAPPER_PASCAL_CASE: "Mappers must be PascalCase ending with 'Mapper'",
|
||||
USE_CASE_VERB_NOUN: "Use cases must be PascalCase Verb+Noun (e.g., CreateUser)",
|
||||
CONTROLLER_PASCAL_CASE: "Controllers must be PascalCase ending with 'Controller'",
|
||||
REPOSITORY_IMPL_PASCAL_CASE:
|
||||
"Repository implementations must be PascalCase ending with 'Repository'",
|
||||
SERVICE_ADAPTER_PASCAL_CASE:
|
||||
"Services/Adapters must be PascalCase ending with 'Service' or 'Adapter'",
|
||||
FUNCTION_CAMEL_CASE: "Functions and methods must be camelCase",
|
||||
USE_CAMEL_CASE_FUNCTION: "Use camelCase for function names (e.g., getUserById, createOrder)",
|
||||
INTERFACE_PASCAL_CASE: "Interfaces must be PascalCase",
|
||||
USE_PASCAL_CASE_INTERFACE: "Use PascalCase for interface names",
|
||||
REPOSITORY_INTERFACE_I_PREFIX:
|
||||
"Domain repository interfaces must start with 'I' (e.g., IUserRepository)",
|
||||
REPOSITORY_INTERFACE_PATTERN: "Repository interfaces must be I + PascalCase + Repository",
|
||||
CONSTANT_UPPER_SNAKE_CASE: "Exported constants must be UPPER_SNAKE_CASE",
|
||||
USE_UPPER_SNAKE_CASE_CONSTANT:
|
||||
"Use UPPER_SNAKE_CASE for constant names (e.g., MAX_RETRIES, API_URL)",
|
||||
VARIABLE_CAMEL_CASE: "Variables must be camelCase",
|
||||
USE_CAMEL_CASE_VARIABLE: "Use camelCase for variable names (e.g., userId, orderList)",
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user