mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
- Replace regex-based matchers with tree-sitter AST traversal - Add duplicate value tracking across files - Implement boolean literal detection - Add value type classification (email, url, ip, api_key, etc.) - Improve context awareness with AST node analysis - Reduce false positives with better constant detection Breaking changes removed: - BraceTracker.ts - ExportConstantAnalyzer.ts - MagicNumberMatcher.ts - MagicStringMatcher.ts New components added: - AstTreeTraverser for AST walking - DuplicateValueTracker for cross-file tracking - AstContextChecker for node context analysis - AstNumberAnalyzer, AstStringAnalyzer, AstBooleanAnalyzer - ValuePatternMatcher for type detection Test coverage: 87.97% statements, 96.75% functions
319 lines
9.4 KiB
TypeScript
319 lines
9.4 KiB
TypeScript
import { IAnemicModelDetector } from "../../domain/services/IAnemicModelDetector"
|
|
import { AnemicModelViolation } from "../../domain/value-objects/AnemicModelViolation"
|
|
import { CLASS_KEYWORDS } from "../../shared/constants"
|
|
import { ANALYZER_DEFAULTS, ANEMIC_MODEL_FLAGS, LAYERS } from "../../shared/constants/rules"
|
|
|
|
/**
|
|
* Detects anemic domain model violations
|
|
*
|
|
* This detector identifies entities that lack business logic and contain
|
|
* only getters/setters. Anemic models violate Domain-Driven Design principles.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* const detector = new AnemicModelDetector()
|
|
*
|
|
* // Detect anemic models in entity file
|
|
* const code = `
|
|
* class Order {
|
|
* getStatus() { return this.status }
|
|
* setStatus(status: string) { this.status = status }
|
|
* getTotal() { return this.total }
|
|
* setTotal(total: number) { this.total = total }
|
|
* }
|
|
* `
|
|
* const violations = detector.detectAnemicModels(
|
|
* code,
|
|
* 'src/domain/entities/Order.ts',
|
|
* 'domain'
|
|
* )
|
|
*
|
|
* // violations will contain anemic model violation
|
|
* console.log(violations.length) // 1
|
|
* console.log(violations[0].className) // 'Order'
|
|
* ```
|
|
*/
|
|
export class AnemicModelDetector implements IAnemicModelDetector {
|
|
private readonly entityPatterns = [/\/entities\//, /\/aggregates\//]
|
|
private readonly excludePatterns = [
|
|
/\.test\.ts$/,
|
|
/\.spec\.ts$/,
|
|
/Dto\.ts$/,
|
|
/Request\.ts$/,
|
|
/Response\.ts$/,
|
|
/Mapper\.ts$/,
|
|
]
|
|
|
|
/**
|
|
* Detects anemic model violations in the given code
|
|
*/
|
|
public detectAnemicModels(
|
|
code: string,
|
|
filePath: string,
|
|
layer: string | undefined,
|
|
): AnemicModelViolation[] {
|
|
if (!this.shouldAnalyze(filePath, layer)) {
|
|
return []
|
|
}
|
|
|
|
const violations: AnemicModelViolation[] = []
|
|
const classes = this.extractClasses(code)
|
|
|
|
for (const classInfo of classes) {
|
|
const violation = this.analyzeClass(classInfo, filePath, layer || LAYERS.DOMAIN)
|
|
if (violation) {
|
|
violations.push(violation)
|
|
}
|
|
}
|
|
|
|
return violations
|
|
}
|
|
|
|
/**
|
|
* Checks if file should be analyzed
|
|
*/
|
|
private shouldAnalyze(filePath: string, layer: string | undefined): boolean {
|
|
if (layer !== LAYERS.DOMAIN) {
|
|
return false
|
|
}
|
|
|
|
if (this.excludePatterns.some((pattern) => pattern.test(filePath))) {
|
|
return false
|
|
}
|
|
|
|
return this.entityPatterns.some((pattern) => pattern.test(filePath))
|
|
}
|
|
|
|
/**
|
|
* Extracts class information from code
|
|
*/
|
|
private extractClasses(code: string): ClassInfo[] {
|
|
const classes: ClassInfo[] = []
|
|
const lines = code.split("\n")
|
|
let currentClass: { name: string; startLine: number; startIndex: number } | null = null
|
|
let braceCount = 0
|
|
let classBody = ""
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i]
|
|
|
|
if (!currentClass) {
|
|
const classRegex = /^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/
|
|
const classMatch = classRegex.exec(line)
|
|
if (classMatch) {
|
|
currentClass = {
|
|
name: classMatch[1],
|
|
startLine: i + 1,
|
|
startIndex: lines.slice(0, i).join("\n").length,
|
|
}
|
|
braceCount = 0
|
|
classBody = ""
|
|
}
|
|
}
|
|
|
|
if (currentClass) {
|
|
for (const char of line) {
|
|
if (char === "{") {
|
|
braceCount++
|
|
} else if (char === "}") {
|
|
braceCount--
|
|
}
|
|
}
|
|
|
|
if (braceCount > 0) {
|
|
classBody = `${classBody}${line}\n`
|
|
} else if (braceCount === 0 && classBody.length > 0) {
|
|
const properties = this.extractProperties(classBody)
|
|
const methods = this.extractMethods(classBody)
|
|
|
|
classes.push({
|
|
className: currentClass.name,
|
|
lineNumber: currentClass.startLine,
|
|
properties,
|
|
methods,
|
|
})
|
|
|
|
currentClass = null
|
|
classBody = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
return classes
|
|
}
|
|
|
|
/**
|
|
* Extracts properties from class body
|
|
*/
|
|
private extractProperties(classBody: string): PropertyInfo[] {
|
|
const properties: PropertyInfo[] = []
|
|
const propertyRegex = /(?:private|protected|public|readonly)*\s*(\w+)(?:\?)?:\s*\w+/g
|
|
|
|
let match
|
|
while ((match = propertyRegex.exec(classBody)) !== null) {
|
|
const propertyName = match[1]
|
|
|
|
if (!this.isMethodSignature(match[0])) {
|
|
properties.push({ name: propertyName })
|
|
}
|
|
}
|
|
|
|
return properties
|
|
}
|
|
|
|
/**
|
|
* Extracts methods from class body
|
|
*/
|
|
private extractMethods(classBody: string): MethodInfo[] {
|
|
const methods: MethodInfo[] = []
|
|
const methodRegex =
|
|
/(public|private|protected)?\s*(get|set)?\s+(\w+)\s*\([^)]*\)(?:\s*:\s*\w+)?/g
|
|
|
|
let match
|
|
while ((match = methodRegex.exec(classBody)) !== null) {
|
|
const visibility = match[1] || CLASS_KEYWORDS.PUBLIC
|
|
const accessor = match[2]
|
|
const methodName = match[3]
|
|
|
|
if (methodName === CLASS_KEYWORDS.CONSTRUCTOR) {
|
|
continue
|
|
}
|
|
|
|
const isGetter = accessor === "get" || this.isGetterMethod(methodName)
|
|
const isSetter = accessor === "set" || this.isSetterMethod(methodName, classBody)
|
|
const isPublic = visibility === CLASS_KEYWORDS.PUBLIC || !visibility
|
|
|
|
methods.push({
|
|
name: methodName,
|
|
isGetter,
|
|
isSetter,
|
|
isPublic,
|
|
isBusinessLogic: !isGetter && !isSetter,
|
|
})
|
|
}
|
|
|
|
return methods
|
|
}
|
|
|
|
/**
|
|
* Analyzes class for anemic model violations
|
|
*/
|
|
private analyzeClass(
|
|
classInfo: ClassInfo,
|
|
filePath: string,
|
|
layer: string,
|
|
): AnemicModelViolation | null {
|
|
const { className, lineNumber, properties, methods } = classInfo
|
|
|
|
if (properties.length === 0 && methods.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const businessMethods = methods.filter((m) => m.isBusinessLogic)
|
|
const hasOnlyGettersSetters = businessMethods.length === 0 && methods.length > 0
|
|
const hasPublicSetters = methods.some((m) => m.isSetter && m.isPublic)
|
|
|
|
const methodCount = methods.length
|
|
const propertyCount = properties.length
|
|
|
|
if (hasPublicSetters) {
|
|
return AnemicModelViolation.create(
|
|
className,
|
|
filePath,
|
|
layer,
|
|
lineNumber,
|
|
methodCount,
|
|
propertyCount,
|
|
ANEMIC_MODEL_FLAGS.HAS_ONLY_GETTERS_SETTERS_FALSE,
|
|
ANEMIC_MODEL_FLAGS.HAS_PUBLIC_SETTERS_TRUE,
|
|
)
|
|
}
|
|
|
|
if (hasOnlyGettersSetters && methodCount >= 2 && propertyCount > 0) {
|
|
return AnemicModelViolation.create(
|
|
className,
|
|
filePath,
|
|
layer,
|
|
lineNumber,
|
|
methodCount,
|
|
propertyCount,
|
|
ANEMIC_MODEL_FLAGS.HAS_ONLY_GETTERS_SETTERS_TRUE,
|
|
ANEMIC_MODEL_FLAGS.HAS_PUBLIC_SETTERS_FALSE,
|
|
)
|
|
}
|
|
|
|
const methodToPropertyRatio = methodCount / Math.max(propertyCount, 1)
|
|
if (
|
|
propertyCount > 0 &&
|
|
businessMethods.length < 2 &&
|
|
methodToPropertyRatio < 1.0 &&
|
|
methodCount > 0
|
|
) {
|
|
return AnemicModelViolation.create(
|
|
className,
|
|
filePath,
|
|
layer,
|
|
lineNumber,
|
|
methodCount,
|
|
propertyCount,
|
|
ANALYZER_DEFAULTS.HAS_ONLY_GETTERS_SETTERS,
|
|
ANALYZER_DEFAULTS.HAS_PUBLIC_SETTERS,
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Checks if method name is a getter pattern
|
|
*/
|
|
private isGetterMethod(methodName: string): boolean {
|
|
return (
|
|
methodName.startsWith("get") ||
|
|
methodName.startsWith("is") ||
|
|
methodName.startsWith("has")
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Checks if method is a setter pattern
|
|
*/
|
|
private isSetterMethod(methodName: string, _classBody: string): boolean {
|
|
return methodName.startsWith("set")
|
|
}
|
|
|
|
/**
|
|
* Checks if property declaration is actually a method signature
|
|
*/
|
|
private isMethodSignature(propertyDeclaration: string): boolean {
|
|
return propertyDeclaration.includes("(") && propertyDeclaration.includes(")")
|
|
}
|
|
|
|
/**
|
|
* Gets line number for a position in code
|
|
*/
|
|
private getLineNumber(code: string, position: number): number {
|
|
const lines = code.substring(0, position).split("\n")
|
|
return lines.length
|
|
}
|
|
}
|
|
|
|
interface ClassInfo {
|
|
className: string
|
|
lineNumber: number
|
|
properties: PropertyInfo[]
|
|
methods: MethodInfo[]
|
|
}
|
|
|
|
interface PropertyInfo {
|
|
name: string
|
|
}
|
|
|
|
interface MethodInfo {
|
|
name: string
|
|
isGetter: boolean
|
|
isSetter: boolean
|
|
isPublic: boolean
|
|
isBusinessLogic: boolean
|
|
}
|