diff --git a/packages/guardian/src/infrastructure/strategies/naming/AstClassNameAnalyzer.ts b/packages/guardian/src/infrastructure/strategies/naming/AstClassNameAnalyzer.ts new file mode 100644 index 0000000..72eba2c --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/naming/AstClassNameAnalyzer.ts @@ -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)) + } +} diff --git a/packages/guardian/src/infrastructure/strategies/naming/AstFunctionNameAnalyzer.ts b/packages/guardian/src/infrastructure/strategies/naming/AstFunctionNameAnalyzer.ts new file mode 100644 index 0000000..4cda5ac --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/naming/AstFunctionNameAnalyzer.ts @@ -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 + } +} diff --git a/packages/guardian/src/infrastructure/strategies/naming/AstInterfaceNameAnalyzer.ts b/packages/guardian/src/infrastructure/strategies/naming/AstInterfaceNameAnalyzer.ts new file mode 100644 index 0000000..be79755 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/naming/AstInterfaceNameAnalyzer.ts @@ -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 + } +} diff --git a/packages/guardian/src/infrastructure/strategies/naming/AstNamingTraverser.ts b/packages/guardian/src/infrastructure/strategies/naming/AstNamingTraverser.ts new file mode 100644 index 0000000..1aa6d38 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/naming/AstNamingTraverser.ts @@ -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() + } + } +} diff --git a/packages/guardian/src/infrastructure/strategies/naming/AstVariableNameAnalyzer.ts b/packages/guardian/src/infrastructure/strategies/naming/AstVariableNameAnalyzer.ts new file mode 100644 index 0000000..41dc4a0 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/naming/AstVariableNameAnalyzer.ts @@ -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 + } +}