mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
refactor: extract detector logic into focused strategy classes
Refactored three largest detectors to improve maintainability and reduce complexity: - AggregateBoundaryDetector: 381 → 162 lines (57% reduction) - HardcodeDetector: 459 → 89 lines (81% reduction) - RepositoryPatternDetector: 479 → 106 lines (78% reduction) Added 13 new strategy classes: - FolderRegistry - centralized DDD folder name management - AggregatePathAnalyzer - path parsing and aggregate extraction - ImportValidator - import validation logic - BraceTracker - brace and bracket counting - ConstantsFileChecker - constants file detection - ExportConstantAnalyzer - export const analysis - MagicNumberMatcher - magic number detection - MagicStringMatcher - magic string detection - OrmTypeMatcher - ORM type matching - MethodNameValidator - repository method validation - RepositoryFileAnalyzer - file role detection - RepositoryViolationDetector - violation detection logic All 519 tests passing, zero ESLint errors, no breaking changes.
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { IHardcodeDetector } from "../../domain/services/IHardcodeDetector"
|
||||
import { HardcodedValue } from "../../domain/value-objects/HardcodedValue"
|
||||
import { ALLOWED_NUMBERS, CODE_PATTERNS, DETECTION_KEYWORDS } from "../constants/defaults"
|
||||
import { HARDCODE_TYPES } from "../../shared/constants"
|
||||
import { BraceTracker } from "../strategies/BraceTracker"
|
||||
import { ConstantsFileChecker } from "../strategies/ConstantsFileChecker"
|
||||
import { ExportConstantAnalyzer } from "../strategies/ExportConstantAnalyzer"
|
||||
import { MagicNumberMatcher } from "../strategies/MagicNumberMatcher"
|
||||
import { MagicStringMatcher } from "../strategies/MagicStringMatcher"
|
||||
|
||||
/**
|
||||
* Detects hardcoded values (magic numbers and strings) in TypeScript/JavaScript code
|
||||
@@ -22,22 +25,19 @@ import { HARDCODE_TYPES } from "../../shared/constants"
|
||||
* ```
|
||||
*/
|
||||
export class HardcodeDetector implements IHardcodeDetector {
|
||||
private readonly ALLOWED_NUMBERS = ALLOWED_NUMBERS
|
||||
private readonly constantsChecker: ConstantsFileChecker
|
||||
private readonly braceTracker: BraceTracker
|
||||
private readonly exportAnalyzer: ExportConstantAnalyzer
|
||||
private readonly numberMatcher: MagicNumberMatcher
|
||||
private readonly stringMatcher: MagicStringMatcher
|
||||
|
||||
private readonly ALLOWED_STRING_PATTERNS = [/^[a-z]$/i, /^\/$/, /^\\$/, /^\s+$/, /^,$/, /^\.$/]
|
||||
|
||||
/**
|
||||
* Patterns to detect TypeScript type contexts where strings should be ignored
|
||||
*/
|
||||
private readonly TYPE_CONTEXT_PATTERNS = [
|
||||
/^\s*type\s+\w+\s*=/i, // type Foo = ...
|
||||
/^\s*interface\s+\w+/i, // interface Foo { ... }
|
||||
/^\s*\w+\s*:\s*['"`]/, // property: 'value' (in type or interface)
|
||||
/\s+as\s+['"`]/, // ... as 'type'
|
||||
/Record<.*,\s*import\(/, // Record with import type
|
||||
/typeof\s+\w+\s*===\s*['"`]/, // typeof x === 'string'
|
||||
/['"`]\s*===\s*typeof\s+\w+/, // 'string' === typeof x
|
||||
]
|
||||
constructor() {
|
||||
this.constantsChecker = new ConstantsFileChecker()
|
||||
this.braceTracker = new BraceTracker()
|
||||
this.exportAnalyzer = new ExportConstantAnalyzer(this.braceTracker)
|
||||
this.numberMatcher = new MagicNumberMatcher(this.exportAnalyzer)
|
||||
this.stringMatcher = new MagicStringMatcher(this.exportAnalyzer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects all hardcoded values (both numbers and strings) in the given code
|
||||
@@ -47,413 +47,43 @@ export class HardcodeDetector implements IHardcodeDetector {
|
||||
* @returns Array of detected hardcoded values with suggestions
|
||||
*/
|
||||
public detectAll(code: string, filePath: string): HardcodedValue[] {
|
||||
if (this.isConstantsFile(filePath)) {
|
||||
if (this.constantsChecker.isConstantsFile(filePath)) {
|
||||
return []
|
||||
}
|
||||
const magicNumbers = this.detectMagicNumbers(code, filePath)
|
||||
const magicStrings = this.detectMagicStrings(code, filePath)
|
||||
|
||||
const magicNumbers = this.numberMatcher.detect(code)
|
||||
const magicStrings = this.stringMatcher.detect(code)
|
||||
|
||||
return [...magicNumbers, ...magicStrings]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a constants definition file or DI tokens file
|
||||
*/
|
||||
private isConstantsFile(filePath: string): boolean {
|
||||
const _fileName = filePath.split("/").pop() ?? ""
|
||||
const constantsPatterns = [
|
||||
/^constants?\.(ts|js)$/i,
|
||||
/constants?\/.*\.(ts|js)$/i,
|
||||
/\/(constants|config|settings|defaults|tokens)\.ts$/i,
|
||||
/\/di\/tokens\.(ts|js)$/i,
|
||||
]
|
||||
return constantsPatterns.some((pattern) => pattern.test(filePath))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line is inside an exported constant definition
|
||||
*/
|
||||
private isInExportedConstant(lines: string[], lineIndex: number): boolean {
|
||||
const currentLineTrimmed = lines[lineIndex].trim()
|
||||
|
||||
if (this.isSingleLineExportConst(currentLineTrimmed)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const exportConstStart = this.findExportConstStart(lines, lineIndex)
|
||||
if (exportConstStart === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const { braces, brackets } = this.countUnclosedBraces(lines, exportConstStart, lineIndex)
|
||||
return braces > 0 || brackets > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line is a single-line export const declaration
|
||||
*/
|
||||
private isSingleLineExportConst(line: string): boolean {
|
||||
if (!line.startsWith(CODE_PATTERNS.EXPORT_CONST)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hasObjectOrArray =
|
||||
line.includes(CODE_PATTERNS.OBJECT_START) || line.includes(CODE_PATTERNS.ARRAY_START)
|
||||
|
||||
if (hasObjectOrArray) {
|
||||
const hasAsConstEnding =
|
||||
line.includes(CODE_PATTERNS.AS_CONST_OBJECT) ||
|
||||
line.includes(CODE_PATTERNS.AS_CONST_ARRAY) ||
|
||||
line.includes(CODE_PATTERNS.AS_CONST_END_SEMICOLON_OBJECT) ||
|
||||
line.includes(CODE_PATTERNS.AS_CONST_END_SEMICOLON_ARRAY)
|
||||
|
||||
return hasAsConstEnding
|
||||
}
|
||||
|
||||
return line.includes(CODE_PATTERNS.AS_CONST)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the starting line of an export const declaration
|
||||
*/
|
||||
private findExportConstStart(lines: string[], lineIndex: number): number {
|
||||
for (let currentLine = lineIndex; currentLine >= 0; currentLine--) {
|
||||
const trimmed = lines[currentLine].trim()
|
||||
|
||||
const isExportConst =
|
||||
trimmed.startsWith(CODE_PATTERNS.EXPORT_CONST) &&
|
||||
(trimmed.includes(CODE_PATTERNS.OBJECT_START) ||
|
||||
trimmed.includes(CODE_PATTERNS.ARRAY_START))
|
||||
|
||||
if (isExportConst) {
|
||||
return currentLine
|
||||
}
|
||||
|
||||
const isTopLevelStatement =
|
||||
currentLine < lineIndex &&
|
||||
(trimmed.startsWith(CODE_PATTERNS.EXPORT) ||
|
||||
trimmed.startsWith(CODE_PATTERNS.IMPORT))
|
||||
|
||||
if (isTopLevelStatement) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Count unclosed braces and brackets between two line indices
|
||||
*/
|
||||
private countUnclosedBraces(
|
||||
lines: string[],
|
||||
startLine: number,
|
||||
endLine: number,
|
||||
): { braces: number; brackets: number } {
|
||||
let braces = 0
|
||||
let brackets = 0
|
||||
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
const line = lines[i]
|
||||
let inString = false
|
||||
let stringChar = ""
|
||||
|
||||
for (let j = 0; j < line.length; j++) {
|
||||
const char = line[j]
|
||||
const prevChar = j > 0 ? line[j - 1] : ""
|
||||
|
||||
if ((char === "'" || char === '"' || char === "`") && prevChar !== "\\") {
|
||||
if (!inString) {
|
||||
inString = true
|
||||
stringChar = char
|
||||
} else if (char === stringChar) {
|
||||
inString = false
|
||||
stringChar = ""
|
||||
}
|
||||
}
|
||||
|
||||
if (!inString) {
|
||||
if (char === "{") {
|
||||
braces++
|
||||
} else if (char === "}") {
|
||||
braces--
|
||||
} else if (char === "[") {
|
||||
brackets++
|
||||
} else if (char === "]") {
|
||||
brackets--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { braces, brackets }
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects magic numbers in code (timeouts, ports, limits, retries, etc.)
|
||||
*
|
||||
* Skips allowed numbers (-1, 0, 1, 2, 10, 100, 1000) and values in exported constants
|
||||
* Detects magic numbers in code
|
||||
*
|
||||
* @param code - Source code to analyze
|
||||
* @param _filePath - File path (currently unused, reserved for future use)
|
||||
* @param filePath - File path (used for constants file check)
|
||||
* @returns Array of detected magic numbers
|
||||
*/
|
||||
public detectMagicNumbers(code: string, _filePath: string): HardcodedValue[] {
|
||||
const results: HardcodedValue[] = []
|
||||
const lines = code.split("\n")
|
||||
public detectMagicNumbers(code: string, filePath: string): HardcodedValue[] {
|
||||
if (this.constantsChecker.isConstantsFile(filePath)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const numberPatterns = [
|
||||
/(?:setTimeout|setInterval)\s*\(\s*[^,]+,\s*(\d+)/g,
|
||||
/(?:maxRetries|retries|attempts)\s*[=:]\s*(\d+)/gi,
|
||||
/(?:limit|max|min)\s*[=:]\s*(\d+)/gi,
|
||||
/(?:port|PORT)\s*[=:]\s*(\d+)/g,
|
||||
/(?:delay|timeout|TIMEOUT)\s*[=:]\s*(\d+)/gi,
|
||||
]
|
||||
|
||||
lines.forEach((line, lineIndex) => {
|
||||
if (line.trim().startsWith("//") || line.trim().startsWith("*")) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip lines inside exported constants
|
||||
if (this.isInExportedConstant(lines, lineIndex)) {
|
||||
return
|
||||
}
|
||||
|
||||
numberPatterns.forEach((pattern) => {
|
||||
let match
|
||||
const regex = new RegExp(pattern)
|
||||
|
||||
while ((match = regex.exec(line)) !== null) {
|
||||
const value = parseInt(match[1], 10)
|
||||
|
||||
if (!this.ALLOWED_NUMBERS.has(value)) {
|
||||
results.push(
|
||||
HardcodedValue.create(
|
||||
value,
|
||||
HARDCODE_TYPES.MAGIC_NUMBER,
|
||||
lineIndex + 1,
|
||||
match.index,
|
||||
line.trim(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const genericNumberRegex = /\b(\d{3,})\b/g
|
||||
let match
|
||||
|
||||
while ((match = genericNumberRegex.exec(line)) !== null) {
|
||||
const value = parseInt(match[1], 10)
|
||||
|
||||
if (
|
||||
!this.ALLOWED_NUMBERS.has(value) &&
|
||||
!this.isInComment(line, match.index) &&
|
||||
!this.isInString(line, match.index)
|
||||
) {
|
||||
const context = this.extractContext(line, match.index)
|
||||
if (this.looksLikeMagicNumber(context)) {
|
||||
results.push(
|
||||
HardcodedValue.create(
|
||||
value,
|
||||
HARDCODE_TYPES.MAGIC_NUMBER,
|
||||
lineIndex + 1,
|
||||
match.index,
|
||||
line.trim(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
return this.numberMatcher.detect(code)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects magic strings in code (URLs, connection strings, error messages, etc.)
|
||||
*
|
||||
* Skips short strings (≤3 chars), console logs, test descriptions, imports,
|
||||
* and values in exported constants
|
||||
* Detects magic strings in code
|
||||
*
|
||||
* @param code - Source code to analyze
|
||||
* @param _filePath - File path (currently unused, reserved for future use)
|
||||
* @param filePath - File path (used for constants file check)
|
||||
* @returns Array of detected magic strings
|
||||
*/
|
||||
public detectMagicStrings(code: string, _filePath: string): HardcodedValue[] {
|
||||
const results: HardcodedValue[] = []
|
||||
const lines = code.split("\n")
|
||||
|
||||
const stringRegex = /(['"`])(?:(?!\1).)+\1/g
|
||||
|
||||
lines.forEach((line, lineIndex) => {
|
||||
if (
|
||||
line.trim().startsWith("//") ||
|
||||
line.trim().startsWith("*") ||
|
||||
line.includes("import ") ||
|
||||
line.includes("from ")
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip lines inside exported constants
|
||||
if (this.isInExportedConstant(lines, lineIndex)) {
|
||||
return
|
||||
}
|
||||
|
||||
let match
|
||||
const regex = new RegExp(stringRegex)
|
||||
|
||||
while ((match = regex.exec(line)) !== null) {
|
||||
const fullMatch = match[0]
|
||||
const value = fullMatch.slice(1, -1)
|
||||
|
||||
// Skip template literals (backtick strings with ${} interpolation)
|
||||
if (fullMatch.startsWith("`") || value.includes("${")) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!this.isAllowedString(value) && this.looksLikeMagicString(line, value)) {
|
||||
results.push(
|
||||
HardcodedValue.create(
|
||||
value,
|
||||
HARDCODE_TYPES.MAGIC_STRING,
|
||||
lineIndex + 1,
|
||||
match.index,
|
||||
line.trim(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private isAllowedString(str: string): boolean {
|
||||
if (str.length <= 1) {
|
||||
return true
|
||||
public detectMagicStrings(code: string, filePath: string): HardcodedValue[] {
|
||||
if (this.constantsChecker.isConstantsFile(filePath)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return this.ALLOWED_STRING_PATTERNS.some((pattern) => pattern.test(str))
|
||||
}
|
||||
|
||||
private looksLikeMagicString(line: string, value: string): boolean {
|
||||
const lowerLine = line.toLowerCase()
|
||||
|
||||
if (
|
||||
lowerLine.includes(DETECTION_KEYWORDS.TEST) ||
|
||||
lowerLine.includes(DETECTION_KEYWORDS.DESCRIBE)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
lowerLine.includes(DETECTION_KEYWORDS.CONSOLE_LOG) ||
|
||||
lowerLine.includes(DETECTION_KEYWORDS.CONSOLE_ERROR)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.isInTypeContext(line)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.isInSymbolCall(line, value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.isInImportCall(line, value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (value.includes(DETECTION_KEYWORDS.HTTP) || value.includes(DETECTION_KEYWORDS.API)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (/^\d{2,}$/.test(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return value.length > 3
|
||||
}
|
||||
|
||||
private looksLikeMagicNumber(context: string): boolean {
|
||||
const lowerContext = context.toLowerCase()
|
||||
|
||||
const configKeywords = [
|
||||
DETECTION_KEYWORDS.TIMEOUT,
|
||||
DETECTION_KEYWORDS.DELAY,
|
||||
DETECTION_KEYWORDS.RETRY,
|
||||
DETECTION_KEYWORDS.LIMIT,
|
||||
DETECTION_KEYWORDS.MAX,
|
||||
DETECTION_KEYWORDS.MIN,
|
||||
DETECTION_KEYWORDS.PORT,
|
||||
DETECTION_KEYWORDS.INTERVAL,
|
||||
]
|
||||
|
||||
return configKeywords.some((keyword) => lowerContext.includes(keyword))
|
||||
}
|
||||
|
||||
private isInComment(line: string, index: number): boolean {
|
||||
const beforeIndex = line.substring(0, index)
|
||||
return beforeIndex.includes("//") || beforeIndex.includes("/*")
|
||||
}
|
||||
|
||||
private isInString(line: string, index: number): boolean {
|
||||
const beforeIndex = line.substring(0, index)
|
||||
const singleQuotes = (beforeIndex.match(/'/g) ?? []).length
|
||||
const doubleQuotes = (beforeIndex.match(/"/g) ?? []).length
|
||||
const backticks = (beforeIndex.match(/`/g) ?? []).length
|
||||
|
||||
return singleQuotes % 2 !== 0 || doubleQuotes % 2 !== 0 || backticks % 2 !== 0
|
||||
}
|
||||
|
||||
private extractContext(line: string, index: number): string {
|
||||
const start = Math.max(0, index - 30)
|
||||
const end = Math.min(line.length, index + 30)
|
||||
return line.substring(start, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line is in a TypeScript type definition context
|
||||
* Examples:
|
||||
* - type Foo = 'a' | 'b'
|
||||
* - interface Bar { prop: 'value' }
|
||||
* - Record<X, import('path')>
|
||||
* - ... as 'type'
|
||||
*/
|
||||
private isInTypeContext(line: string): boolean {
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
if (this.TYPE_CONTEXT_PATTERNS.some((pattern) => pattern.test(trimmedLine))) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (trimmedLine.includes("|") && /['"`][^'"`]+['"`]\s*\|/.test(trimmedLine)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is inside a Symbol() call
|
||||
* Example: Symbol('TOKEN_NAME')
|
||||
*/
|
||||
private isInSymbolCall(line: string, stringValue: string): boolean {
|
||||
const symbolPattern = new RegExp(
|
||||
`Symbol\\s*\\(\\s*['"\`]${stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"\`]\\s*\\)`,
|
||||
)
|
||||
return symbolPattern.test(line)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is inside an import() call
|
||||
* Example: import('../../path/to/module.js')
|
||||
*/
|
||||
private isInImportCall(line: string, stringValue: string): boolean {
|
||||
const importPattern = /import\s*\(\s*['"`][^'"`]+['"`]\s*\)/
|
||||
return importPattern.test(line) && line.includes(stringValue)
|
||||
return this.stringMatcher.detect(code)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user