refactor: create AST-based naming analyzers for enhanced detection

This commit is contained in:
imfozilbek
2025-11-27 19:26:24 +05:00
parent 1d6aebcd87
commit ce78183c6e
5 changed files with 636 additions and 0 deletions

View File

@@ -0,0 +1,230 @@
import Parser from "tree-sitter"
import { NamingViolation } from "../../../domain/value-objects/NamingViolation"
import { AST_CLASS_TYPES, AST_FIELD_NAMES } from "../../../shared/constants"
import { LAYERS, NAMING_VIOLATION_TYPES, USE_CASE_VERBS } from "../../../shared/constants/rules"
import {
FILE_SUFFIXES,
NAMING_ERROR_MESSAGES,
PATTERN_WORDS,
} from "../../constants/detectorPatterns"
/**
* AST-based analyzer for detecting class naming violations
*
* Analyzes class declaration nodes to ensure proper naming conventions:
* - Domain layer: PascalCase entities and services (*Service)
* - Application layer: PascalCase use cases (Verb+Noun), DTOs (*Dto/*Request/*Response)
* - Infrastructure layer: PascalCase controllers, repositories, services
*/
export class AstClassNameAnalyzer {
/**
* Analyzes a class declaration node
*/
public analyze(
node: Parser.SyntaxNode,
layer: string,
filePath: string,
_lines: string[],
): NamingViolation | null {
if (node.type !== AST_CLASS_TYPES.CLASS_DECLARATION) {
return null
}
const nameNode = node.childForFieldName(AST_FIELD_NAMES.NAME)
if (!nameNode) {
return null
}
const className = nameNode.text
const lineNumber = nameNode.startPosition.row + 1
switch (layer) {
case LAYERS.DOMAIN:
return this.checkDomainClass(className, filePath, lineNumber)
case LAYERS.APPLICATION:
return this.checkApplicationClass(className, filePath, lineNumber)
case LAYERS.INFRASTRUCTURE:
return this.checkInfrastructureClass(className, filePath, lineNumber)
default:
return null
}
}
/**
* Checks domain layer class naming
*/
private checkDomainClass(
className: string,
filePath: string,
lineNumber: number,
): NamingViolation | null {
if (className.endsWith(FILE_SUFFIXES.SERVICE.replace(".ts", ""))) {
if (!/^[A-Z][a-zA-Z0-9]*Service$/.test(className)) {
return NamingViolation.create(
className,
NAMING_VIOLATION_TYPES.WRONG_CASE,
LAYERS.DOMAIN,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.DOMAIN_SERVICE_PASCAL_CASE,
className,
)
}
return null
}
if (!/^[A-Z][a-zA-Z0-9]*$/.test(className)) {
return NamingViolation.create(
className,
NAMING_VIOLATION_TYPES.WRONG_CASE,
LAYERS.DOMAIN,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.DOMAIN_ENTITY_PASCAL_CASE,
className,
NAMING_ERROR_MESSAGES.USE_PASCAL_CASE,
)
}
return null
}
/**
* Checks application layer class naming
*/
private checkApplicationClass(
className: string,
filePath: string,
lineNumber: number,
): NamingViolation | null {
if (
className.endsWith("Dto") ||
className.endsWith("Request") ||
className.endsWith("Response")
) {
if (!/^[A-Z][a-zA-Z0-9]*(Dto|Request|Response)$/.test(className)) {
return NamingViolation.create(
className,
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
LAYERS.APPLICATION,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.DTO_PASCAL_CASE,
className,
NAMING_ERROR_MESSAGES.USE_DTO_SUFFIX,
)
}
return null
}
if (className.endsWith("Mapper")) {
if (!/^[A-Z][a-zA-Z0-9]*Mapper$/.test(className)) {
return NamingViolation.create(
className,
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
LAYERS.APPLICATION,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.MAPPER_PASCAL_CASE,
className,
)
}
return null
}
const startsWithVerb = this.startsWithCommonVerb(className)
const startsWithLowercaseVerb = this.startsWithLowercaseVerb(className)
if (startsWithVerb) {
if (!/^[A-Z][a-z]+[A-Z][a-zA-Z0-9]*$/.test(className)) {
return NamingViolation.create(
className,
NAMING_VIOLATION_TYPES.WRONG_VERB_NOUN,
LAYERS.APPLICATION,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.USE_CASE_VERB_NOUN,
className,
NAMING_ERROR_MESSAGES.USE_VERB_NOUN,
)
}
} else if (startsWithLowercaseVerb) {
return NamingViolation.create(
className,
NAMING_VIOLATION_TYPES.WRONG_VERB_NOUN,
LAYERS.APPLICATION,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.USE_CASE_VERB_NOUN,
className,
NAMING_ERROR_MESSAGES.USE_VERB_NOUN,
)
}
return null
}
/**
* Checks infrastructure layer class naming
*/
private checkInfrastructureClass(
className: string,
filePath: string,
lineNumber: number,
): NamingViolation | null {
if (className.endsWith("Controller")) {
if (!/^[A-Z][a-zA-Z0-9]*Controller$/.test(className)) {
return NamingViolation.create(
className,
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
LAYERS.INFRASTRUCTURE,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.CONTROLLER_PASCAL_CASE,
className,
)
}
return null
}
if (
className.endsWith(PATTERN_WORDS.REPOSITORY) &&
!className.startsWith(PATTERN_WORDS.I_PREFIX)
) {
if (!/^[A-Z][a-zA-Z0-9]*Repository$/.test(className)) {
return NamingViolation.create(
className,
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
LAYERS.INFRASTRUCTURE,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.REPOSITORY_IMPL_PASCAL_CASE,
className,
)
}
return null
}
if (className.endsWith("Service") || className.endsWith("Adapter")) {
if (!/^[A-Z][a-zA-Z0-9]*(Service|Adapter)$/.test(className)) {
return NamingViolation.create(
className,
NAMING_VIOLATION_TYPES.WRONG_SUFFIX,
LAYERS.INFRASTRUCTURE,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.SERVICE_ADAPTER_PASCAL_CASE,
className,
)
}
return null
}
return null
}
/**
* Checks if class name starts with a common use case verb
*/
private startsWithCommonVerb(className: string): boolean {
return USE_CASE_VERBS.some((verb) => className.startsWith(verb))
}
/**
* Checks if class name starts with a lowercase verb (camelCase use case)
*/
private startsWithLowercaseVerb(className: string): boolean {
const lowercaseVerbs = USE_CASE_VERBS.map((verb) => verb.toLowerCase())
return lowercaseVerbs.some((verb) => className.startsWith(verb))
}
}

View File

@@ -0,0 +1,65 @@
import Parser from "tree-sitter"
import { NamingViolation } from "../../../domain/value-objects/NamingViolation"
import { AST_FIELD_NAMES, AST_FUNCTION_TYPES, CLASS_KEYWORDS } from "../../../shared/constants"
import { NAMING_VIOLATION_TYPES } from "../../../shared/constants/rules"
import { NAMING_ERROR_MESSAGES } from "../../constants/detectorPatterns"
/**
* AST-based analyzer for detecting function and method naming violations
*
* Analyzes function declaration, method definition, and arrow function nodes
* to ensure proper naming conventions:
* - Functions and methods should be camelCase
* - Private methods with underscore prefix are allowed
*/
export class AstFunctionNameAnalyzer {
/**
* Analyzes a function or method declaration node
*/
public analyze(
node: Parser.SyntaxNode,
layer: string,
filePath: string,
_lines: string[],
): NamingViolation | null {
const functionNodeTypes = [
AST_FUNCTION_TYPES.FUNCTION_DECLARATION,
AST_FUNCTION_TYPES.METHOD_DEFINITION,
AST_FUNCTION_TYPES.FUNCTION_SIGNATURE,
] as const
if (!(functionNodeTypes as readonly string[]).includes(node.type)) {
return null
}
const nameNode = node.childForFieldName(AST_FIELD_NAMES.NAME)
if (!nameNode) {
return null
}
const functionName = nameNode.text
const lineNumber = nameNode.startPosition.row + 1
if (functionName.startsWith("_")) {
return null
}
if (functionName === CLASS_KEYWORDS.CONSTRUCTOR) {
return null
}
if (!/^[a-z][a-zA-Z0-9]*$/.test(functionName)) {
return NamingViolation.create(
functionName,
NAMING_VIOLATION_TYPES.WRONG_CASE,
layer,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.FUNCTION_CAMEL_CASE,
functionName,
NAMING_ERROR_MESSAGES.USE_CAMEL_CASE_FUNCTION,
)
}
return null
}
}

View File

@@ -0,0 +1,90 @@
import Parser from "tree-sitter"
import { NamingViolation } from "../../../domain/value-objects/NamingViolation"
import { AST_CLASS_TYPES, AST_FIELD_NAMES } from "../../../shared/constants"
import { LAYERS, NAMING_VIOLATION_TYPES } from "../../../shared/constants/rules"
import { NAMING_ERROR_MESSAGES, PATTERN_WORDS } from "../../constants/detectorPatterns"
/**
* AST-based analyzer for detecting interface naming violations
*
* Analyzes interface declaration nodes to ensure proper naming conventions:
* - Domain layer: Repository interfaces must start with 'I' (e.g., IUserRepository)
* - All layers: Interfaces should be PascalCase
*/
export class AstInterfaceNameAnalyzer {
/**
* Analyzes an interface declaration node
*/
public analyze(
node: Parser.SyntaxNode,
layer: string,
filePath: string,
_lines: string[],
): NamingViolation | null {
if (node.type !== AST_CLASS_TYPES.INTERFACE_DECLARATION) {
return null
}
const nameNode = node.childForFieldName(AST_FIELD_NAMES.NAME)
if (!nameNode) {
return null
}
const interfaceName = nameNode.text
const lineNumber = nameNode.startPosition.row + 1
if (!/^[A-Z][a-zA-Z0-9]*$/.test(interfaceName)) {
return NamingViolation.create(
interfaceName,
NAMING_VIOLATION_TYPES.WRONG_CASE,
layer,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.INTERFACE_PASCAL_CASE,
interfaceName,
NAMING_ERROR_MESSAGES.USE_PASCAL_CASE_INTERFACE,
)
}
if (layer === LAYERS.DOMAIN) {
return this.checkDomainInterface(interfaceName, filePath, lineNumber)
}
return null
}
/**
* Checks domain layer interface naming
*/
private checkDomainInterface(
interfaceName: string,
filePath: string,
lineNumber: number,
): NamingViolation | null {
if (interfaceName.endsWith(PATTERN_WORDS.REPOSITORY)) {
if (!interfaceName.startsWith(PATTERN_WORDS.I_PREFIX)) {
return NamingViolation.create(
interfaceName,
NAMING_VIOLATION_TYPES.WRONG_PREFIX,
LAYERS.DOMAIN,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.REPOSITORY_INTERFACE_I_PREFIX,
interfaceName,
`Rename to I${interfaceName}`,
)
}
if (!/^I[A-Z][a-zA-Z0-9]*Repository$/.test(interfaceName)) {
return NamingViolation.create(
interfaceName,
NAMING_VIOLATION_TYPES.WRONG_CASE,
LAYERS.DOMAIN,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.REPOSITORY_INTERFACE_PATTERN,
interfaceName,
)
}
}
return null
}
}

View File

@@ -0,0 +1,92 @@
import Parser from "tree-sitter"
import { NamingViolation } from "../../../domain/value-objects/NamingViolation"
import { AST_CLASS_TYPES, AST_FUNCTION_TYPES, AST_VARIABLE_TYPES } from "../../../shared/constants"
import { AstClassNameAnalyzer } from "./AstClassNameAnalyzer"
import { AstFunctionNameAnalyzer } from "./AstFunctionNameAnalyzer"
import { AstInterfaceNameAnalyzer } from "./AstInterfaceNameAnalyzer"
import { AstVariableNameAnalyzer } from "./AstVariableNameAnalyzer"
/**
* AST tree traverser for detecting naming convention violations
*
* Walks through the Abstract Syntax Tree and uses analyzers
* to detect naming violations in classes, interfaces, functions, and variables.
*/
export class AstNamingTraverser {
constructor(
private readonly classAnalyzer: AstClassNameAnalyzer,
private readonly interfaceAnalyzer: AstInterfaceNameAnalyzer,
private readonly functionAnalyzer: AstFunctionNameAnalyzer,
private readonly variableAnalyzer: AstVariableNameAnalyzer,
) {}
/**
* Traverses the AST tree and collects naming violations
*/
public traverse(
tree: Parser.Tree,
sourceCode: string,
layer: string,
filePath: string,
): NamingViolation[] {
const results: NamingViolation[] = []
const lines = sourceCode.split("\n")
const cursor = tree.walk()
this.visit(cursor, lines, layer, filePath, results)
return results
}
/**
* Recursively visits AST nodes
*/
private visit(
cursor: Parser.TreeCursor,
lines: string[],
layer: string,
filePath: string,
results: NamingViolation[],
): void {
const node = cursor.currentNode
if (node.type === AST_CLASS_TYPES.CLASS_DECLARATION) {
const violation = this.classAnalyzer.analyze(node, layer, filePath, lines)
if (violation) {
results.push(violation)
}
} else if (node.type === AST_CLASS_TYPES.INTERFACE_DECLARATION) {
const violation = this.interfaceAnalyzer.analyze(node, layer, filePath, lines)
if (violation) {
results.push(violation)
}
} else if (
node.type === AST_FUNCTION_TYPES.FUNCTION_DECLARATION ||
node.type === AST_FUNCTION_TYPES.METHOD_DEFINITION ||
node.type === AST_FUNCTION_TYPES.FUNCTION_SIGNATURE
) {
const violation = this.functionAnalyzer.analyze(node, layer, filePath, lines)
if (violation) {
results.push(violation)
}
} else if (
node.type === AST_VARIABLE_TYPES.VARIABLE_DECLARATOR ||
node.type === AST_VARIABLE_TYPES.REQUIRED_PARAMETER ||
node.type === AST_VARIABLE_TYPES.OPTIONAL_PARAMETER ||
node.type === AST_VARIABLE_TYPES.PUBLIC_FIELD_DEFINITION ||
node.type === AST_VARIABLE_TYPES.PROPERTY_SIGNATURE
) {
const violation = this.variableAnalyzer.analyze(node, layer, filePath, lines)
if (violation) {
results.push(violation)
}
}
if (cursor.gotoFirstChild()) {
do {
this.visit(cursor, lines, layer, filePath, results)
} while (cursor.gotoNextSibling())
cursor.gotoParent()
}
}
}

View File

@@ -0,0 +1,159 @@
import Parser from "tree-sitter"
import { NamingViolation } from "../../../domain/value-objects/NamingViolation"
import {
AST_FIELD_NAMES,
AST_FIELD_TYPES,
AST_MODIFIER_TYPES,
AST_PATTERN_TYPES,
AST_STATEMENT_TYPES,
AST_VARIABLE_TYPES,
} from "../../../shared/constants"
import { NAMING_VIOLATION_TYPES } from "../../../shared/constants/rules"
import { NAMING_ERROR_MESSAGES } from "../../constants/detectorPatterns"
/**
* AST-based analyzer for detecting variable naming violations
*
* Analyzes variable declarations to ensure proper naming conventions:
* - Regular variables: camelCase
* - Constants (exported UPPER_CASE): UPPER_SNAKE_CASE
* - Class properties: camelCase
* - Private properties with underscore prefix are allowed
*/
export class AstVariableNameAnalyzer {
/**
* Analyzes a variable declaration node
*/
public analyze(
node: Parser.SyntaxNode,
layer: string,
filePath: string,
_lines: string[],
): NamingViolation | null {
const variableNodeTypes = [
AST_VARIABLE_TYPES.VARIABLE_DECLARATOR,
AST_VARIABLE_TYPES.REQUIRED_PARAMETER,
AST_VARIABLE_TYPES.OPTIONAL_PARAMETER,
AST_VARIABLE_TYPES.PUBLIC_FIELD_DEFINITION,
AST_VARIABLE_TYPES.PROPERTY_SIGNATURE,
] as const
if (!(variableNodeTypes as readonly string[]).includes(node.type)) {
return null
}
const nameNode = node.childForFieldName(AST_FIELD_NAMES.NAME)
if (!nameNode) {
return null
}
if (this.isDestructuringPattern(nameNode)) {
return null
}
const variableName = nameNode.text
const lineNumber = nameNode.startPosition.row + 1
if (variableName.startsWith("_")) {
return null
}
const isConstant = this.isConstantVariable(node)
if (isConstant) {
if (!/^[A-Z][A-Z0-9_]*$/.test(variableName)) {
return NamingViolation.create(
variableName,
NAMING_VIOLATION_TYPES.WRONG_CASE,
layer,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.CONSTANT_UPPER_SNAKE_CASE,
variableName,
NAMING_ERROR_MESSAGES.USE_UPPER_SNAKE_CASE_CONSTANT,
)
}
} else {
if (!/^[a-z][a-zA-Z0-9]*$/.test(variableName)) {
return NamingViolation.create(
variableName,
NAMING_VIOLATION_TYPES.WRONG_CASE,
layer,
`${filePath}:${String(lineNumber)}`,
NAMING_ERROR_MESSAGES.VARIABLE_CAMEL_CASE,
variableName,
NAMING_ERROR_MESSAGES.USE_CAMEL_CASE_VARIABLE,
)
}
}
return null
}
/**
* Checks if node is a destructuring pattern (object or array)
*/
private isDestructuringPattern(node: Parser.SyntaxNode): boolean {
return (
node.type === AST_PATTERN_TYPES.OBJECT_PATTERN ||
node.type === AST_PATTERN_TYPES.ARRAY_PATTERN
)
}
/**
* Checks if a variable is a constant (exported UPPER_CASE)
*/
private isConstantVariable(node: Parser.SyntaxNode): boolean {
const variableName = node.childForFieldName(AST_FIELD_NAMES.NAME)?.text
if (!variableName || !/^[A-Z]/.test(variableName)) {
return false
}
if (
node.type === AST_VARIABLE_TYPES.PUBLIC_FIELD_DEFINITION ||
node.type === AST_FIELD_TYPES.FIELD_DEFINITION
) {
return this.hasConstModifiers(node)
}
let current: Parser.SyntaxNode | null = node.parent
while (current) {
if (current.type === AST_STATEMENT_TYPES.LEXICAL_DECLARATION) {
const firstChild = current.child(0)
if (firstChild?.type === AST_MODIFIER_TYPES.CONST) {
return true
}
}
if (
current.type === AST_VARIABLE_TYPES.PUBLIC_FIELD_DEFINITION ||
current.type === AST_FIELD_TYPES.FIELD_DEFINITION
) {
return this.hasConstModifiers(current)
}
current = current.parent
}
return false
}
/**
* Checks if field has readonly or static modifiers (indicating a constant)
*/
private hasConstModifiers(fieldNode: Parser.SyntaxNode): boolean {
for (let i = 0; i < fieldNode.childCount; i++) {
const child = fieldNode.child(i)
const childText = child?.text
if (
child?.type === AST_MODIFIER_TYPES.READONLY ||
child?.type === AST_MODIFIER_TYPES.STATIC ||
childText === AST_MODIFIER_TYPES.READONLY ||
childText === AST_MODIFIER_TYPES.STATIC
) {
return true
}
}
return false
}
}