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:
imfozilbek
2025-11-25 17:41:32 +05:00
parent 9fb9beb311
commit 8d400c9517
18 changed files with 1721 additions and 1092 deletions

View File

@@ -5,6 +5,45 @@ All notable changes to @samiyev/guardian will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.7.9] - 2025-11-25
### Changed
- ♻️ **Refactored large detectors** - significantly improved maintainability and reduced complexity:
- **AggregateBoundaryDetector**: Reduced from 381 to 162 lines (57% reduction)
- **HardcodeDetector**: Reduced from 459 to 89 lines (81% reduction)
- **RepositoryPatternDetector**: Reduced from 479 to 106 lines (78% reduction)
- Extracted 13 focused strategy classes for single responsibilities
- All 519 tests pass, no breaking changes
- Zero ESLint errors (1 pre-existing warning unrelated to refactoring)
- Improved code organization and separation of concerns
### Added
- 🏗️ **13 new strategy classes** for focused responsibilities:
- `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
- Enhanced testability with smaller, focused classes
### Improved
- 📊 **Code quality metrics**:
- Reduced cyclomatic complexity across all three detectors
- Better separation of concerns with strategy pattern
- More maintainable and extensible codebase
- Easier to add new detection patterns
- Improved code readability and self-documentation
## [0.7.8] - 2025-11-25
### Added

View File

@@ -413,24 +413,42 @@ Add integration tests for full pipeline and CLI.
---
### Version 0.7.9 - Refactor Large Detectors 🔧 (Optional)
### Version 0.7.9 - Refactor Large Detectors 🔧 ✅ RELEASED
**Released:** 2025-11-25
**Priority:** LOW
**Scope:** Single session (~128K tokens)
Refactor largest detectors to reduce complexity.
Refactored largest detectors to reduce complexity and improve maintainability.
**Targets:**
| Detector | Lines | Complexity |
|----------|-------|------------|
| RepositoryPatternDetector | 479 | 35 |
| HardcodeDetector | 459 | 41 |
| AggregateBoundaryDetector | 381 | 47 |
**Results:**
| Detector | Before | After | Reduction |
|----------|--------|-------|-----------|
| AggregateBoundaryDetector | 381 lines | 162 lines | 57% ✅ |
| HardcodeDetector | 459 lines | 89 lines | 81% ✅ |
| RepositoryPatternDetector | 479 lines | 106 lines | 78% ✅ |
**Deliverables:**
- [ ] Extract regex patterns into strategies
- [ ] Reduce cyclomatic complexity < 25
- [ ] Publish to npm
**Implemented Features:**
- Extracted 13 strategy classes for focused responsibilities
- ✅ Reduced file sizes by 57-81%
- ✅ Improved code organization and maintainability
- ✅ All 519 tests passing
- ✅ Zero ESLint errors, 1 pre-existing warning
- ✅ No breaking changes
**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
---

View File

@@ -1,6 +1,6 @@
{
"name": "@samiyev/guardian",
"version": "0.7.8",
"version": "0.7.9",
"description": "Research-backed code quality guardian for AI-assisted development. Detects hardcodes, circular deps, framework leaks, entity exposure, and 8 architecture violations. Enforces Clean Architecture/DDD principles. Works with GitHub Copilot, Cursor, Windsurf, Claude, ChatGPT, Cline, and any AI coding tool.",
"keywords": [
"puaros",

View File

@@ -1,8 +1,9 @@
import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector"
import { AggregateBoundaryViolation } from "../../domain/value-objects/AggregateBoundaryViolation"
import { LAYERS } from "../../shared/constants/rules"
import { IMPORT_PATTERNS } from "../constants/paths"
import { DDD_FOLDER_NAMES } from "../constants/detectorPatterns"
import { AggregatePathAnalyzer } from "../strategies/AggregatePathAnalyzer"
import { FolderRegistry } from "../strategies/FolderRegistry"
import { ImportValidator } from "../strategies/ImportValidator"
/**
* Detects aggregate boundary violations in Domain-Driven Design
@@ -38,42 +39,15 @@ import { DDD_FOLDER_NAMES } from "../constants/detectorPatterns"
* ```
*/
export class AggregateBoundaryDetector implements IAggregateBoundaryDetector {
private readonly entityFolderNames = new Set<string>([
DDD_FOLDER_NAMES.ENTITIES,
DDD_FOLDER_NAMES.AGGREGATES,
])
private readonly valueObjectFolderNames = new Set<string>([
DDD_FOLDER_NAMES.VALUE_OBJECTS,
DDD_FOLDER_NAMES.VO,
])
private readonly allowedFolderNames = new Set<string>([
DDD_FOLDER_NAMES.VALUE_OBJECTS,
DDD_FOLDER_NAMES.VO,
DDD_FOLDER_NAMES.EVENTS,
DDD_FOLDER_NAMES.DOMAIN_EVENTS,
DDD_FOLDER_NAMES.REPOSITORIES,
DDD_FOLDER_NAMES.SERVICES,
DDD_FOLDER_NAMES.SPECIFICATIONS,
DDD_FOLDER_NAMES.ERRORS,
DDD_FOLDER_NAMES.EXCEPTIONS,
])
private readonly nonAggregateFolderNames = new Set<string>([
DDD_FOLDER_NAMES.VALUE_OBJECTS,
DDD_FOLDER_NAMES.VO,
DDD_FOLDER_NAMES.EVENTS,
DDD_FOLDER_NAMES.DOMAIN_EVENTS,
DDD_FOLDER_NAMES.REPOSITORIES,
DDD_FOLDER_NAMES.SERVICES,
DDD_FOLDER_NAMES.SPECIFICATIONS,
DDD_FOLDER_NAMES.ENTITIES,
DDD_FOLDER_NAMES.CONSTANTS,
DDD_FOLDER_NAMES.SHARED,
DDD_FOLDER_NAMES.FACTORIES,
DDD_FOLDER_NAMES.PORTS,
DDD_FOLDER_NAMES.INTERFACES,
DDD_FOLDER_NAMES.ERRORS,
DDD_FOLDER_NAMES.EXCEPTIONS,
])
private readonly folderRegistry: FolderRegistry
private readonly pathAnalyzer: AggregatePathAnalyzer
private readonly importValidator: ImportValidator
constructor() {
this.folderRegistry = new FolderRegistry()
this.pathAnalyzer = new AggregatePathAnalyzer(this.folderRegistry)
this.importValidator = new ImportValidator(this.folderRegistry, this.pathAnalyzer)
}
/**
* Detects aggregate boundary violations in the given code
@@ -95,41 +69,12 @@ export class AggregateBoundaryDetector implements IAggregateBoundaryDetector {
return []
}
const currentAggregate = this.extractAggregateFromPath(filePath)
const currentAggregate = this.pathAnalyzer.extractAggregateFromPath(filePath)
if (!currentAggregate) {
return []
}
const violations: AggregateBoundaryViolation[] = []
const lines = code.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNumber = i + 1
const imports = this.extractImports(line)
for (const importPath of imports) {
if (this.isAggregateBoundaryViolation(importPath, currentAggregate)) {
const targetAggregate = this.extractAggregateFromImport(importPath)
const entityName = this.extractEntityName(importPath)
if (targetAggregate && entityName) {
violations.push(
AggregateBoundaryViolation.create(
currentAggregate,
targetAggregate,
entityName,
importPath,
filePath,
lineNumber,
),
)
}
}
}
}
return violations
return this.analyzeImports(code, filePath, currentAggregate)
}
/**
@@ -144,37 +89,7 @@ export class AggregateBoundaryDetector implements IAggregateBoundaryDetector {
* @returns The aggregate name if found, undefined otherwise
*/
public extractAggregateFromPath(filePath: string): string | undefined {
const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/")
const domainMatch = /(?:^|\/)(domain)\//.exec(normalizedPath)
if (!domainMatch) {
return undefined
}
const domainEndIndex = domainMatch.index + domainMatch[0].length
const pathAfterDomain = normalizedPath.substring(domainEndIndex)
const segments = pathAfterDomain.split("/").filter(Boolean)
if (segments.length < 2) {
return undefined
}
if (this.entityFolderNames.has(segments[0])) {
if (segments.length < 3) {
return undefined
}
const aggregate = segments[1]
if (this.nonAggregateFolderNames.has(aggregate)) {
return undefined
}
return aggregate
}
const aggregate = segments[0]
if (this.nonAggregateFolderNames.has(aggregate)) {
return undefined
}
return aggregate
return this.pathAnalyzer.extractAggregateFromPath(filePath)
}
/**
@@ -185,197 +100,68 @@ export class AggregateBoundaryDetector implements IAggregateBoundaryDetector {
* @returns True if the import crosses aggregate boundaries inappropriately
*/
public isAggregateBoundaryViolation(importPath: string, currentAggregate: string): boolean {
const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "").toLowerCase()
if (!normalizedPath.includes("/")) {
return false
}
if (!normalizedPath.startsWith(".") && !normalizedPath.startsWith("/")) {
return false
}
// Check if import stays within the same bounded context
if (this.isInternalBoundedContextImport(normalizedPath)) {
return false
}
const targetAggregate = this.extractAggregateFromImport(normalizedPath)
if (!targetAggregate || targetAggregate === currentAggregate) {
return false
}
if (this.isAllowedImport(normalizedPath)) {
return false
}
return this.seemsLikeEntityImport(normalizedPath)
return this.importValidator.isViolation(importPath, currentAggregate)
}
/**
* Checks if the import is internal to the same bounded context
*
* An import like "../aggregates/Entity" from "repositories/Repo" stays within
* the same bounded context (one level up goes to the bounded context root).
*
* An import like "../../other-context/Entity" crosses bounded context boundaries.
* Analyzes all imports in code and detects violations
*/
private isInternalBoundedContextImport(normalizedPath: string): boolean {
const parts = normalizedPath.split("/")
const dotDotCount = parts.filter((p) => p === "..").length
private analyzeImports(
code: string,
filePath: string,
currentAggregate: string,
): AggregateBoundaryViolation[] {
const violations: AggregateBoundaryViolation[] = []
const lines = code.split("\n")
/*
* If only one ".." and path goes into aggregates/entities folder,
* it's likely an internal import within the same bounded context
*/
if (dotDotCount === 1) {
const nonDotParts = parts.filter((p) => p !== ".." && p !== ".")
if (nonDotParts.length >= 1) {
const firstFolder = nonDotParts[0]
// Importing from aggregates/entities within same bounded context is allowed
if (this.entityFolderNames.has(firstFolder)) {
return true
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNumber = i + 1
const imports = this.importValidator.extractImports(line)
for (const importPath of imports) {
const violation = this.checkImport(
importPath,
currentAggregate,
filePath,
lineNumber,
)
if (violation) {
violations.push(violation)
}
}
}
return false
return violations
}
/**
* Checks if the import path is from an allowed folder (value-objects, events, etc.)
* Checks a single import for boundary violations
*/
private isAllowedImport(normalizedPath: string): boolean {
for (const folderName of this.allowedFolderNames) {
if (normalizedPath.includes(`/${folderName}/`)) {
return true
}
}
return false
}
/**
* Checks if the import seems to be an entity (not a value object, event, etc.)
*
* Note: normalizedPath is already lowercased, so we check if the first character
* is a letter (indicating it was likely PascalCase originally)
*/
private seemsLikeEntityImport(normalizedPath: string): boolean {
const pathParts = normalizedPath.split("/")
const lastPart = pathParts[pathParts.length - 1]
if (!lastPart) {
return false
}
const filename = lastPart.replace(/\.(ts|js)$/, "")
if (filename.length > 0 && /^[a-z][a-z]/.exec(filename)) {
return true
}
return false
}
/**
* Extracts the aggregate name from an import path
*
* Handles both absolute and relative paths:
* - ../user/User → user
* - ../../domain/user/User → user
* - ../user/value-objects/UserId → user (but filtered as value object)
*/
private extractAggregateFromImport(importPath: string): string | undefined {
const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "").toLowerCase()
const segments = normalizedPath.split("/").filter((seg) => seg !== ".." && seg !== ".")
if (segments.length === 0) {
private checkImport(
importPath: string,
currentAggregate: string,
filePath: string,
lineNumber: number,
): AggregateBoundaryViolation | undefined {
if (!this.importValidator.isViolation(importPath, currentAggregate)) {
return undefined
}
for (let i = 0; i < segments.length; i++) {
if (
segments[i] === DDD_FOLDER_NAMES.DOMAIN ||
segments[i] === DDD_FOLDER_NAMES.AGGREGATES
) {
if (i + 1 < segments.length) {
if (
this.entityFolderNames.has(segments[i + 1]) ||
segments[i + 1] === DDD_FOLDER_NAMES.AGGREGATES
) {
if (i + 2 < segments.length) {
return segments[i + 2]
}
} else {
return segments[i + 1]
}
}
}
}
const targetAggregate = this.pathAnalyzer.extractAggregateFromImport(importPath)
const entityName = this.pathAnalyzer.extractEntityName(importPath)
if (segments.length >= 2) {
const secondLastSegment = segments[segments.length - 2]
if (
!this.entityFolderNames.has(secondLastSegment) &&
!this.valueObjectFolderNames.has(secondLastSegment) &&
!this.allowedFolderNames.has(secondLastSegment) &&
secondLastSegment !== DDD_FOLDER_NAMES.DOMAIN
) {
return secondLastSegment
}
}
if (segments.length === 1) {
return undefined
if (targetAggregate && entityName) {
return AggregateBoundaryViolation.create(
currentAggregate,
targetAggregate,
entityName,
importPath,
filePath,
lineNumber,
)
}
return undefined
}
/**
* Extracts the entity name from an import path
*/
private extractEntityName(importPath: string): string | undefined {
const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "")
const segments = normalizedPath.split("/")
const lastSegment = segments[segments.length - 1]
if (lastSegment) {
return lastSegment.replace(/\.(ts|js)$/, "")
}
return undefined
}
/**
* Extracts import paths from a line of code
*
* Handles various import statement formats:
* - import { X } from 'path'
* - import X from 'path'
* - import * as X from 'path'
* - const X = require('path')
*
* @param line - A line of code to analyze
* @returns Array of import paths found in the line
*/
private extractImports(line: string): string[] {
const imports: string[] = []
let match = IMPORT_PATTERNS.ES_IMPORT.exec(line)
while (match) {
imports.push(match[1])
match = IMPORT_PATTERNS.ES_IMPORT.exec(line)
}
match = IMPORT_PATTERNS.REQUIRE.exec(line)
while (match) {
imports.push(match[1])
match = IMPORT_PATTERNS.REQUIRE.exec(line)
}
return imports
}
}

View File

@@ -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)
}
}

View File

@@ -1,9 +1,9 @@
import { IRepositoryPatternDetector } from "../../domain/services/RepositoryPatternDetectorService"
import { RepositoryViolation } from "../../domain/value-objects/RepositoryViolation"
import { LAYERS, REPOSITORY_VIOLATION_TYPES } from "../../shared/constants/rules"
import { ORM_QUERY_METHODS } from "../constants/orm-methods"
import { REPOSITORY_PATTERN_MESSAGES } from "../../domain/constants/Messages"
import { REPOSITORY_METHOD_SUGGESTIONS } from "../constants/detectorPatterns"
import { OrmTypeMatcher } from "../strategies/OrmTypeMatcher"
import { MethodNameValidator } from "../strategies/MethodNameValidator"
import { RepositoryFileAnalyzer } from "../strategies/RepositoryFileAnalyzer"
import { RepositoryViolationDetector } from "../strategies/RepositoryViolationDetector"
/**
* Detects Repository Pattern violations in the codebase
@@ -36,84 +36,20 @@ import { REPOSITORY_METHOD_SUGGESTIONS } from "../constants/detectorPatterns"
* ```
*/
export class RepositoryPatternDetector implements IRepositoryPatternDetector {
private readonly ormTypePatterns = [
/Prisma\./,
/PrismaClient/,
/TypeORM/,
/@Entity/,
/@Column/,
/@PrimaryColumn/,
/@PrimaryGeneratedColumn/,
/@ManyToOne/,
/@OneToMany/,
/@ManyToMany/,
/@JoinColumn/,
/@JoinTable/,
/Mongoose\./,
/Schema/,
/Model</,
/Document/,
/Sequelize\./,
/DataTypes\./,
/FindOptions/,
/WhereOptions/,
/IncludeOptions/,
/QueryInterface/,
/MikroORM/,
/EntityManager/,
/EntityRepository/,
/Collection</,
]
private readonly ormMatcher: OrmTypeMatcher
private readonly methodValidator: MethodNameValidator
private readonly fileAnalyzer: RepositoryFileAnalyzer
private readonly violationDetector: RepositoryViolationDetector
private readonly technicalMethodNames = ORM_QUERY_METHODS
private readonly domainMethodPatterns = [
/^findBy[A-Z]/,
/^findAll$/,
/^find[A-Z]/,
/^save$/,
/^saveAll$/,
/^create$/,
/^update$/,
/^delete$/,
/^deleteBy[A-Z]/,
/^deleteAll$/,
/^remove$/,
/^removeBy[A-Z]/,
/^removeAll$/,
/^add$/,
/^add[A-Z]/,
/^get[A-Z]/,
/^getAll$/,
/^search/,
/^list/,
/^has[A-Z]/,
/^is[A-Z]/,
/^exists$/,
/^exists[A-Z]/,
/^existsBy[A-Z]/,
/^clear[A-Z]/,
/^clearAll$/,
/^store[A-Z]/,
/^initialize$/,
/^initializeCollection$/,
/^close$/,
/^connect$/,
/^disconnect$/,
/^count$/,
/^countBy[A-Z]/,
]
private readonly concreteRepositoryPatterns = [
/PrismaUserRepository/,
/MongoUserRepository/,
/TypeOrmUserRepository/,
/SequelizeUserRepository/,
/InMemoryUserRepository/,
/PostgresUserRepository/,
/MySqlUserRepository/,
/Repository(?!Interface)/,
]
constructor() {
this.ormMatcher = new OrmTypeMatcher()
this.methodValidator = new MethodNameValidator(this.ormMatcher)
this.fileAnalyzer = new RepositoryFileAnalyzer()
this.violationDetector = new RepositoryViolationDetector(
this.ormMatcher,
this.methodValidator,
)
}
/**
* Detects all Repository Pattern violations in the given code
@@ -125,14 +61,16 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector {
): RepositoryViolation[] {
const violations: RepositoryViolation[] = []
if (this.isRepositoryInterface(filePath, layer)) {
violations.push(...this.detectOrmTypesInInterface(code, filePath, layer))
violations.push(...this.detectNonDomainMethodNames(code, filePath, layer))
if (this.fileAnalyzer.isRepositoryInterface(filePath, layer)) {
violations.push(...this.violationDetector.detectOrmTypes(code, filePath, layer))
violations.push(...this.violationDetector.detectNonDomainMethods(code, filePath, layer))
}
if (this.isUseCase(filePath, layer)) {
violations.push(...this.detectConcreteRepositoryUsage(code, filePath, layer))
violations.push(...this.detectNewRepositoryInstantiation(code, filePath, layer))
if (this.fileAnalyzer.isUseCase(filePath, layer)) {
violations.push(
...this.violationDetector.detectConcreteRepositoryUsage(code, filePath, layer),
)
violations.push(...this.violationDetector.detectNewInstantiation(code, filePath, layer))
}
return violations
@@ -142,338 +80,27 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector {
* Checks if a type is an ORM-specific type
*/
public isOrmType(typeName: string): boolean {
return this.ormTypePatterns.some((pattern) => pattern.test(typeName))
return this.ormMatcher.isOrmType(typeName)
}
/**
* Checks if a method name follows domain language conventions
*/
public isDomainMethodName(methodName: string): boolean {
if ((this.technicalMethodNames as readonly string[]).includes(methodName)) {
return false
}
return this.domainMethodPatterns.some((pattern) => pattern.test(methodName))
return this.methodValidator.isDomainMethodName(methodName)
}
/**
* Checks if a file is a repository interface
*/
public isRepositoryInterface(filePath: string, layer: string | undefined): boolean {
if (layer !== LAYERS.DOMAIN) {
return false
}
return /I[A-Z]\w*Repository\.ts$/.test(filePath) && /repositories?\//.test(filePath)
return this.fileAnalyzer.isRepositoryInterface(filePath, layer)
}
/**
* Checks if a file is a use case
*/
public isUseCase(filePath: string, layer: string | undefined): boolean {
if (layer !== LAYERS.APPLICATION) {
return false
}
return /use-cases?\//.test(filePath) && /[A-Z][a-z]+[A-Z]\w*\.ts$/.test(filePath)
}
/**
* Detects ORM-specific types in repository interfaces
*/
private detectOrmTypesInInterface(
code: string,
filePath: string,
layer: string | undefined,
): RepositoryViolation[] {
const violations: RepositoryViolation[] = []
const lines = code.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNumber = i + 1
const methodMatch =
/(\w+)\s*\([^)]*:\s*([^)]+)\)\s*:\s*.*?(?:Promise<([^>]+)>|([A-Z]\w+))/.exec(line)
if (methodMatch) {
const params = methodMatch[2]
const returnType = methodMatch[3] || methodMatch[4]
if (this.isOrmType(params)) {
const ormType = this.extractOrmType(params)
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
filePath,
layer || LAYERS.DOMAIN,
lineNumber,
`Method parameter uses ORM type: ${ormType}`,
ormType,
),
)
}
if (returnType && this.isOrmType(returnType)) {
const ormType = this.extractOrmType(returnType)
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
filePath,
layer || LAYERS.DOMAIN,
lineNumber,
`Method return type uses ORM type: ${ormType}`,
ormType,
),
)
}
}
for (const pattern of this.ormTypePatterns) {
if (pattern.test(line) && !line.trim().startsWith("//")) {
const ormType = this.extractOrmType(line)
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
filePath,
layer || LAYERS.DOMAIN,
lineNumber,
`Repository interface contains ORM-specific type: ${ormType}`,
ormType,
),
)
break
}
}
}
return violations
}
/**
* Suggests better domain method names based on the original method name
*/
private suggestDomainMethodName(methodName: string): string {
const lowerName = methodName.toLowerCase()
const suggestions: string[] = []
const suggestionMap: Record<string, string[]> = {
query: [
REPOSITORY_METHOD_SUGGESTIONS.SEARCH,
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
],
select: [
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
],
insert: [
REPOSITORY_METHOD_SUGGESTIONS.CREATE,
REPOSITORY_METHOD_SUGGESTIONS.ADD_ENTITY,
REPOSITORY_METHOD_SUGGESTIONS.STORE_ENTITY,
],
update: [
REPOSITORY_METHOD_SUGGESTIONS.UPDATE,
REPOSITORY_METHOD_SUGGESTIONS.MODIFY_ENTITY,
],
upsert: [
REPOSITORY_METHOD_SUGGESTIONS.SAVE,
REPOSITORY_METHOD_SUGGESTIONS.STORE_ENTITY,
],
remove: [
REPOSITORY_METHOD_SUGGESTIONS.DELETE,
REPOSITORY_METHOD_SUGGESTIONS.REMOVE_BY_PROPERTY,
],
fetch: [
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
],
retrieve: [
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
],
load: [
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
],
}
for (const [keyword, keywords] of Object.entries(suggestionMap)) {
if (lowerName.includes(keyword)) {
suggestions.push(...keywords)
}
}
if (lowerName.includes("get") && lowerName.includes("all")) {
suggestions.push(
REPOSITORY_METHOD_SUGGESTIONS.FIND_ALL,
REPOSITORY_METHOD_SUGGESTIONS.LIST_ALL,
)
}
if (suggestions.length === 0) {
return REPOSITORY_METHOD_SUGGESTIONS.DEFAULT_SUGGESTION
}
return `Consider: ${suggestions.slice(0, 3).join(", ")}`
}
/**
* Detects non-domain method names in repository interfaces
*/
private detectNonDomainMethodNames(
code: string,
filePath: string,
layer: string | undefined,
): RepositoryViolation[] {
const violations: RepositoryViolation[] = []
const lines = code.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNumber = i + 1
const methodMatch = /^\s*(\w+)\s*\(/.exec(line)
if (methodMatch) {
const methodName = methodMatch[1]
if (!this.isDomainMethodName(methodName) && !line.trim().startsWith("//")) {
const suggestion = this.suggestDomainMethodName(methodName)
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
filePath,
layer || LAYERS.DOMAIN,
lineNumber,
`Method '${methodName}' uses technical name instead of domain language. ${suggestion}`,
undefined,
undefined,
methodName,
),
)
}
}
}
return violations
}
/**
* Detects concrete repository usage in use cases
*/
private detectConcreteRepositoryUsage(
code: string,
filePath: string,
layer: string | undefined,
): RepositoryViolation[] {
const violations: RepositoryViolation[] = []
const lines = code.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNumber = i + 1
const constructorParamMatch =
/constructor\s*\([^)]*(?:private|public|protected)\s+(?:readonly\s+)?(\w+)\s*:\s*([A-Z]\w*Repository)/.exec(
line,
)
if (constructorParamMatch) {
const repositoryType = constructorParamMatch[2]
if (!repositoryType.startsWith("I")) {
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
filePath,
layer || LAYERS.APPLICATION,
lineNumber,
`Use case depends on concrete repository '${repositoryType}'`,
undefined,
repositoryType,
),
)
}
}
const fieldMatch =
/(?:private|public|protected)\s+(?:readonly\s+)?(\w+)\s*:\s*([A-Z]\w*Repository)/.exec(
line,
)
if (fieldMatch) {
const repositoryType = fieldMatch[2]
if (
!repositoryType.startsWith("I") &&
!line.includes(REPOSITORY_PATTERN_MESSAGES.CONSTRUCTOR)
) {
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
filePath,
layer || LAYERS.APPLICATION,
lineNumber,
`Use case field uses concrete repository '${repositoryType}'`,
undefined,
repositoryType,
),
)
}
}
}
return violations
}
/**
* Detects 'new Repository()' instantiation in use cases
*/
private detectNewRepositoryInstantiation(
code: string,
filePath: string,
layer: string | undefined,
): RepositoryViolation[] {
const violations: RepositoryViolation[] = []
const lines = code.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNumber = i + 1
const newRepositoryMatch = /new\s+([A-Z]\w*Repository)\s*\(/.exec(line)
if (newRepositoryMatch && !line.trim().startsWith("//")) {
const repositoryName = newRepositoryMatch[1]
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE,
filePath,
layer || LAYERS.APPLICATION,
lineNumber,
`Use case creates repository with 'new ${repositoryName}()'`,
undefined,
repositoryName,
),
)
}
}
return violations
}
/**
* Extracts ORM type name from a code line
*/
private extractOrmType(line: string): string {
for (const pattern of this.ormTypePatterns) {
const match = line.match(pattern)
if (match) {
const startIdx = match.index || 0
const typeMatch = /[\w.]+/.exec(line.slice(startIdx))
return typeMatch ? typeMatch[0] : REPOSITORY_PATTERN_MESSAGES.UNKNOWN_TYPE
}
}
return REPOSITORY_PATTERN_MESSAGES.UNKNOWN_TYPE
return this.fileAnalyzer.isUseCase(filePath, layer)
}
}

View File

@@ -0,0 +1,177 @@
import { DDD_FOLDER_NAMES } from "../constants/detectorPatterns"
import { IMPORT_PATTERNS } from "../constants/paths"
import { FolderRegistry } from "./FolderRegistry"
/**
* Analyzes file paths and imports to extract aggregate information
*
* Handles path normalization, aggregate extraction, and entity name detection
* for aggregate boundary validation.
*/
export class AggregatePathAnalyzer {
constructor(private readonly folderRegistry: FolderRegistry) {}
/**
* Extracts the aggregate name from a file path
*
* Handles patterns like:
* - domain/aggregates/order/Order.ts → 'order'
* - domain/order/Order.ts → 'order'
* - domain/entities/order/Order.ts → 'order'
*/
public extractAggregateFromPath(filePath: string): string | undefined {
const normalizedPath = this.normalizePath(filePath)
const segments = this.getPathSegmentsAfterDomain(normalizedPath)
if (!segments || segments.length < 2) {
return undefined
}
return this.findAggregateInSegments(segments)
}
/**
* Extracts the aggregate name from an import path
*/
public extractAggregateFromImport(importPath: string): string | undefined {
const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "").toLowerCase()
const segments = normalizedPath.split("/").filter((seg) => seg !== ".." && seg !== ".")
if (segments.length === 0) {
return undefined
}
return this.findAggregateInImportSegments(segments)
}
/**
* Extracts the entity name from an import path
*/
public extractEntityName(importPath: string): string | undefined {
const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "")
const segments = normalizedPath.split("/")
const lastSegment = segments[segments.length - 1]
if (lastSegment) {
return lastSegment.replace(/\.(ts|js)$/, "")
}
return undefined
}
/**
* Normalizes a file path for consistent processing
*/
private normalizePath(filePath: string): string {
return filePath.toLowerCase().replace(/\\/g, "/")
}
/**
* Gets path segments after the 'domain' folder
*/
private getPathSegmentsAfterDomain(normalizedPath: string): string[] | undefined {
const domainMatch = /(?:^|\/)(domain)\//.exec(normalizedPath)
if (!domainMatch) {
return undefined
}
const domainEndIndex = domainMatch.index + domainMatch[0].length
const pathAfterDomain = normalizedPath.substring(domainEndIndex)
return pathAfterDomain.split("/").filter(Boolean)
}
/**
* Finds aggregate name in path segments after domain folder
*/
private findAggregateInSegments(segments: string[]): string | undefined {
if (this.folderRegistry.isEntityFolder(segments[0])) {
return this.extractFromEntityFolder(segments)
}
const aggregate = segments[0]
if (this.folderRegistry.isNonAggregateFolder(aggregate)) {
return undefined
}
return aggregate
}
/**
* Extracts aggregate from entity folder structure
*/
private extractFromEntityFolder(segments: string[]): string | undefined {
if (segments.length < 3) {
return undefined
}
const aggregate = segments[1]
if (this.folderRegistry.isNonAggregateFolder(aggregate)) {
return undefined
}
return aggregate
}
/**
* Finds aggregate in import path segments
*/
private findAggregateInImportSegments(segments: string[]): string | undefined {
const aggregateFromDomainFolder = this.findAggregateAfterDomainFolder(segments)
if (aggregateFromDomainFolder) {
return aggregateFromDomainFolder
}
return this.findAggregateFromSecondLastSegment(segments)
}
/**
* Finds aggregate after 'domain' or 'aggregates' folder in import
*/
private findAggregateAfterDomainFolder(segments: string[]): string | undefined {
for (let i = 0; i < segments.length; i++) {
const isDomainOrAggregatesFolder =
segments[i] === DDD_FOLDER_NAMES.DOMAIN ||
segments[i] === DDD_FOLDER_NAMES.AGGREGATES
if (!isDomainOrAggregatesFolder) {
continue
}
if (i + 1 >= segments.length) {
continue
}
const nextSegment = segments[i + 1]
const isEntityOrAggregateFolder =
this.folderRegistry.isEntityFolder(nextSegment) ||
nextSegment === DDD_FOLDER_NAMES.AGGREGATES
if (isEntityOrAggregateFolder) {
return i + 2 < segments.length ? segments[i + 2] : undefined
}
return nextSegment
}
return undefined
}
/**
* Extracts aggregate from second-to-last segment if applicable
*/
private findAggregateFromSecondLastSegment(segments: string[]): string | undefined {
if (segments.length >= 2) {
const secondLastSegment = segments[segments.length - 2]
if (
!this.folderRegistry.isEntityFolder(secondLastSegment) &&
!this.folderRegistry.isValueObjectFolder(secondLastSegment) &&
!this.folderRegistry.isAllowedFolder(secondLastSegment) &&
secondLastSegment !== DDD_FOLDER_NAMES.DOMAIN
) {
return secondLastSegment
}
}
return undefined
}
}

View File

@@ -0,0 +1,96 @@
/**
* Tracks braces and brackets in code for context analysis
*
* Used to determine if a line is inside an exported constant
* by counting unclosed braces and brackets.
*/
export class BraceTracker {
/**
* Counts unclosed braces and brackets between two line indices
*/
public countUnclosed(
lines: string[],
startLine: number,
endLine: number,
): { braces: number; brackets: number } {
let braces = 0
let brackets = 0
for (let i = startLine; i <= endLine; i++) {
const counts = this.countInLine(lines[i])
braces += counts.braces
brackets += counts.brackets
}
return { braces, brackets }
}
/**
* Counts braces and brackets in a single line
*/
private countInLine(line: string): { braces: number; brackets: number } {
let braces = 0
let brackets = 0
let inString = false
let stringChar = ""
for (let j = 0; j < line.length; j++) {
const char = line[j]
const prevChar = j > 0 ? line[j - 1] : ""
this.updateStringState(
char,
prevChar,
inString,
stringChar,
(newInString, newStringChar) => {
inString = newInString
stringChar = newStringChar
},
)
if (!inString) {
const counts = this.countChar(char)
braces += counts.braces
brackets += counts.brackets
}
}
return { braces, brackets }
}
/**
* Updates string tracking state
*/
private updateStringState(
char: string,
prevChar: string,
inString: boolean,
stringChar: string,
callback: (inString: boolean, stringChar: string) => void,
): void {
if ((char === "'" || char === '"' || char === "`") && prevChar !== "\\") {
if (!inString) {
callback(true, char)
} else if (char === stringChar) {
callback(false, "")
}
}
}
/**
* Counts a single character
*/
private countChar(char: string): { braces: number; brackets: number } {
if (char === "{") {
return { braces: 1, brackets: 0 }
} else if (char === "}") {
return { braces: -1, brackets: 0 }
} else if (char === "[") {
return { braces: 0, brackets: 1 }
} else if (char === "]") {
return { braces: 0, brackets: -1 }
}
return { braces: 0, brackets: 0 }
}
}

View File

@@ -0,0 +1,21 @@
/**
* Checks if a file is a constants definition file
*
* Identifies files that should be skipped for hardcode detection
* since they are meant to contain constant definitions.
*/
export class ConstantsFileChecker {
private readonly constantsPatterns = [
/^constants?\.(ts|js)$/i,
/constants?\/.*\.(ts|js)$/i,
/\/(constants|config|settings|defaults|tokens)\.ts$/i,
/\/di\/tokens\.(ts|js)$/i,
]
/**
* Checks if a file path represents a constants file
*/
public isConstantsFile(filePath: string): boolean {
return this.constantsPatterns.some((pattern) => pattern.test(filePath))
}
}

View File

@@ -0,0 +1,112 @@
import { CODE_PATTERNS } from "../constants/defaults"
import { BraceTracker } from "./BraceTracker"
/**
* Analyzes export const declarations in code
*
* Determines if a line is inside an exported constant declaration
* to skip hardcode detection in constant definitions.
*/
export class ExportConstantAnalyzer {
constructor(private readonly braceTracker: BraceTracker) {}
/**
* Checks if a line is inside an exported constant definition
*/
public 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.braceTracker.countUnclosed(
lines,
exportConstStart,
lineIndex,
)
return braces > 0 || brackets > 0
}
/**
* Checks if a line is a single-line export const declaration
*/
public isSingleLineExportConst(line: string): boolean {
if (!line.startsWith(CODE_PATTERNS.EXPORT_CONST)) {
return false
}
const hasObjectOrArray = this.hasObjectOrArray(line)
if (hasObjectOrArray) {
return this.hasAsConstEnding(line)
}
return line.includes(CODE_PATTERNS.AS_CONST)
}
/**
* Finds the starting line of an export const declaration
*/
public findExportConstStart(lines: string[], lineIndex: number): number {
for (let currentLine = lineIndex; currentLine >= 0; currentLine--) {
const trimmed = lines[currentLine].trim()
if (this.isExportConstWithStructure(trimmed)) {
return currentLine
}
if (this.isTopLevelStatement(trimmed, currentLine, lineIndex)) {
break
}
}
return -1
}
/**
* Checks if line has object or array structure
*/
private hasObjectOrArray(line: string): boolean {
return line.includes(CODE_PATTERNS.OBJECT_START) || line.includes(CODE_PATTERNS.ARRAY_START)
}
/**
* Checks if line has 'as const' ending
*/
private hasAsConstEnding(line: string): boolean {
return (
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)
)
}
/**
* Checks if line is export const with object or array
*/
private isExportConstWithStructure(trimmed: string): boolean {
return (
trimmed.startsWith(CODE_PATTERNS.EXPORT_CONST) &&
(trimmed.includes(CODE_PATTERNS.OBJECT_START) ||
trimmed.includes(CODE_PATTERNS.ARRAY_START))
)
}
/**
* Checks if line is a top-level statement
*/
private isTopLevelStatement(trimmed: string, currentLine: number, lineIndex: number): boolean {
return (
currentLine < lineIndex &&
(trimmed.startsWith(CODE_PATTERNS.EXPORT) || trimmed.startsWith(CODE_PATTERNS.IMPORT))
)
}
}

View File

@@ -0,0 +1,72 @@
import { DDD_FOLDER_NAMES } from "../constants/detectorPatterns"
/**
* Registry for DDD folder names used in aggregate boundary detection
*
* Centralizes folder name management for cleaner code organization
* and easier maintenance of folder name rules.
*/
export class FolderRegistry {
public readonly entityFolders: Set<string>
public readonly valueObjectFolders: Set<string>
public readonly allowedFolders: Set<string>
public readonly nonAggregateFolders: Set<string>
constructor() {
this.entityFolders = new Set<string>([
DDD_FOLDER_NAMES.ENTITIES,
DDD_FOLDER_NAMES.AGGREGATES,
])
this.valueObjectFolders = new Set<string>([
DDD_FOLDER_NAMES.VALUE_OBJECTS,
DDD_FOLDER_NAMES.VO,
])
this.allowedFolders = new Set<string>([
DDD_FOLDER_NAMES.VALUE_OBJECTS,
DDD_FOLDER_NAMES.VO,
DDD_FOLDER_NAMES.EVENTS,
DDD_FOLDER_NAMES.DOMAIN_EVENTS,
DDD_FOLDER_NAMES.REPOSITORIES,
DDD_FOLDER_NAMES.SERVICES,
DDD_FOLDER_NAMES.SPECIFICATIONS,
DDD_FOLDER_NAMES.ERRORS,
DDD_FOLDER_NAMES.EXCEPTIONS,
])
this.nonAggregateFolders = new Set<string>([
DDD_FOLDER_NAMES.VALUE_OBJECTS,
DDD_FOLDER_NAMES.VO,
DDD_FOLDER_NAMES.EVENTS,
DDD_FOLDER_NAMES.DOMAIN_EVENTS,
DDD_FOLDER_NAMES.REPOSITORIES,
DDD_FOLDER_NAMES.SERVICES,
DDD_FOLDER_NAMES.SPECIFICATIONS,
DDD_FOLDER_NAMES.ENTITIES,
DDD_FOLDER_NAMES.CONSTANTS,
DDD_FOLDER_NAMES.SHARED,
DDD_FOLDER_NAMES.FACTORIES,
DDD_FOLDER_NAMES.PORTS,
DDD_FOLDER_NAMES.INTERFACES,
DDD_FOLDER_NAMES.ERRORS,
DDD_FOLDER_NAMES.EXCEPTIONS,
])
}
public isEntityFolder(folderName: string): boolean {
return this.entityFolders.has(folderName)
}
public isValueObjectFolder(folderName: string): boolean {
return this.valueObjectFolders.has(folderName)
}
public isAllowedFolder(folderName: string): boolean {
return this.allowedFolders.has(folderName)
}
public isNonAggregateFolder(folderName: string): boolean {
return this.nonAggregateFolders.has(folderName)
}
}

View File

@@ -0,0 +1,150 @@
import { IMPORT_PATTERNS } from "../constants/paths"
import { AggregatePathAnalyzer } from "./AggregatePathAnalyzer"
import { FolderRegistry } from "./FolderRegistry"
/**
* Validates imports for aggregate boundary violations
*
* Checks if imports cross aggregate boundaries inappropriately
* and ensures proper encapsulation in DDD architecture.
*/
export class ImportValidator {
constructor(
private readonly folderRegistry: FolderRegistry,
private readonly pathAnalyzer: AggregatePathAnalyzer,
) {}
/**
* Checks if an import violates aggregate boundaries
*/
public isViolation(importPath: string, currentAggregate: string): boolean {
const normalizedPath = this.normalizeImportPath(importPath)
if (!this.isValidImportPath(normalizedPath)) {
return false
}
if (this.isInternalBoundedContextImport(normalizedPath)) {
return false
}
const targetAggregate = this.pathAnalyzer.extractAggregateFromImport(normalizedPath)
if (!targetAggregate || targetAggregate === currentAggregate) {
return false
}
if (this.isAllowedImport(normalizedPath)) {
return false
}
return this.seemsLikeEntityImport(normalizedPath)
}
/**
* Extracts all import paths from a line of code
*/
public extractImports(line: string): string[] {
const imports: string[] = []
this.extractEsImports(line, imports)
this.extractRequireImports(line, imports)
return imports
}
/**
* Normalizes an import path for consistent processing
*/
private normalizeImportPath(importPath: string): string {
return importPath.replace(IMPORT_PATTERNS.QUOTE, "").toLowerCase()
}
/**
* Checks if import path is valid for analysis
*/
private isValidImportPath(normalizedPath: string): boolean {
if (!normalizedPath.includes("/")) {
return false
}
if (!normalizedPath.startsWith(".") && !normalizedPath.startsWith("/")) {
return false
}
return true
}
/**
* Checks if import is internal to the same bounded context
*/
private isInternalBoundedContextImport(normalizedPath: string): boolean {
const parts = normalizedPath.split("/")
const dotDotCount = parts.filter((p) => p === "..").length
if (dotDotCount === 1) {
const nonDotParts = parts.filter((p) => p !== ".." && p !== ".")
if (nonDotParts.length >= 1) {
const firstFolder = nonDotParts[0]
if (this.folderRegistry.isEntityFolder(firstFolder)) {
return true
}
}
}
return false
}
/**
* Checks if import is from an allowed folder
*/
private isAllowedImport(normalizedPath: string): boolean {
for (const folderName of this.folderRegistry.allowedFolders) {
if (normalizedPath.includes(`/${folderName}/`)) {
return true
}
}
return false
}
/**
* Checks if import seems to be an entity
*/
private seemsLikeEntityImport(normalizedPath: string): boolean {
const pathParts = normalizedPath.split("/")
const lastPart = pathParts[pathParts.length - 1]
if (!lastPart) {
return false
}
const filename = lastPart.replace(/\.(ts|js)$/, "")
if (filename.length > 0 && /^[a-z][a-z]/.exec(filename)) {
return true
}
return false
}
/**
* Extracts ES6 imports from a line
*/
private extractEsImports(line: string, imports: string[]): void {
let match = IMPORT_PATTERNS.ES_IMPORT.exec(line)
while (match) {
imports.push(match[1])
match = IMPORT_PATTERNS.ES_IMPORT.exec(line)
}
}
/**
* Extracts CommonJS requires from a line
*/
private extractRequireImports(line: string, imports: string[]): void {
let match = IMPORT_PATTERNS.REQUIRE.exec(line)
while (match) {
imports.push(match[1])
match = IMPORT_PATTERNS.REQUIRE.exec(line)
}
}
}

View File

@@ -0,0 +1,171 @@
import { HardcodedValue } from "../../domain/value-objects/HardcodedValue"
import { ALLOWED_NUMBERS, DETECTION_KEYWORDS } from "../constants/defaults"
import { HARDCODE_TYPES } from "../../shared/constants"
import { ExportConstantAnalyzer } from "./ExportConstantAnalyzer"
/**
* Detects magic numbers in code
*
* Identifies hardcoded numeric values that should be extracted
* to constants, excluding allowed values and exported constants.
*/
export class MagicNumberMatcher {
private readonly 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,
]
constructor(private readonly exportAnalyzer: ExportConstantAnalyzer) {}
/**
* Detects magic numbers in code
*/
public detect(code: string): HardcodedValue[] {
const results: HardcodedValue[] = []
const lines = code.split("\n")
lines.forEach((line, lineIndex) => {
if (this.shouldSkipLine(line, lines, lineIndex)) {
return
}
this.detectInPatterns(line, lineIndex, results)
this.detectGenericNumbers(line, lineIndex, results)
})
return results
}
/**
* Checks if line should be skipped
*/
private shouldSkipLine(line: string, lines: string[], lineIndex: number): boolean {
if (line.trim().startsWith("//") || line.trim().startsWith("*")) {
return true
}
return this.exportAnalyzer.isInExportedConstant(lines, lineIndex)
}
/**
* Detects numbers in specific patterns
*/
private detectInPatterns(line: string, lineIndex: number, results: HardcodedValue[]): void {
this.numberPatterns.forEach((pattern) => {
let match
const regex = new RegExp(pattern)
while ((match = regex.exec(line)) !== null) {
const value = parseInt(match[1], 10)
if (!ALLOWED_NUMBERS.has(value)) {
results.push(
HardcodedValue.create(
value,
HARDCODE_TYPES.MAGIC_NUMBER,
lineIndex + 1,
match.index,
line.trim(),
),
)
}
}
})
}
/**
* Detects generic 3+ digit numbers
*/
private detectGenericNumbers(line: string, lineIndex: number, results: HardcodedValue[]): void {
const genericNumberRegex = /\b(\d{3,})\b/g
let match
while ((match = genericNumberRegex.exec(line)) !== null) {
const value = parseInt(match[1], 10)
if (this.shouldDetectNumber(value, line, match.index)) {
results.push(
HardcodedValue.create(
value,
HARDCODE_TYPES.MAGIC_NUMBER,
lineIndex + 1,
match.index,
line.trim(),
),
)
}
}
}
/**
* Checks if number should be detected
*/
private shouldDetectNumber(value: number, line: string, index: number): boolean {
if (ALLOWED_NUMBERS.has(value)) {
return false
}
if (this.isInComment(line, index)) {
return false
}
if (this.isInString(line, index)) {
return false
}
const context = this.extractContext(line, index)
return this.looksLikeMagicNumber(context)
}
/**
* Checks if position is in a comment
*/
private isInComment(line: string, index: number): boolean {
const beforeIndex = line.substring(0, index)
return beforeIndex.includes("//") || beforeIndex.includes("/*")
}
/**
* Checks if position is in a string
*/
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
}
/**
* Extracts context around a position
*/
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)
}
/**
* Checks if context suggests a magic number
*/
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))
}
}

View File

@@ -0,0 +1,212 @@
import { HardcodedValue } from "../../domain/value-objects/HardcodedValue"
import { DETECTION_KEYWORDS } from "../constants/defaults"
import { HARDCODE_TYPES } from "../../shared/constants"
import { ExportConstantAnalyzer } from "./ExportConstantAnalyzer"
/**
* Detects magic strings in code
*
* Identifies hardcoded string values that should be extracted
* to constants, excluding test code, console logs, and type contexts.
*/
export class MagicStringMatcher {
private readonly stringRegex = /(['"`])(?:(?!\1).)+\1/g
private readonly allowedPatterns = [/^[a-z]$/i, /^\/$/, /^\\$/, /^\s+$/, /^,$/, /^\.$/]
private readonly typeContextPatterns = [
/^\s*type\s+\w+\s*=/i,
/^\s*interface\s+\w+/i,
/^\s*\w+\s*:\s*['"`]/,
/\s+as\s+['"`]/,
/Record<.*,\s*import\(/,
/typeof\s+\w+\s*===\s*['"`]/,
/['"`]\s*===\s*typeof\s+\w+/,
]
constructor(private readonly exportAnalyzer: ExportConstantAnalyzer) {}
/**
* Detects magic strings in code
*/
public detect(code: string): HardcodedValue[] {
const results: HardcodedValue[] = []
const lines = code.split("\n")
lines.forEach((line, lineIndex) => {
if (this.shouldSkipLine(line, lines, lineIndex)) {
return
}
this.detectStringsInLine(line, lineIndex, results)
})
return results
}
/**
* Checks if line should be skipped
*/
private shouldSkipLine(line: string, lines: string[], lineIndex: number): boolean {
if (
line.trim().startsWith("//") ||
line.trim().startsWith("*") ||
line.includes("import ") ||
line.includes("from ")
) {
return true
}
return this.exportAnalyzer.isInExportedConstant(lines, lineIndex)
}
/**
* Detects strings in a single line
*/
private detectStringsInLine(line: string, lineIndex: number, results: HardcodedValue[]): void {
let match
const regex = new RegExp(this.stringRegex)
while ((match = regex.exec(line)) !== null) {
const fullMatch = match[0]
const value = fullMatch.slice(1, -1)
if (this.shouldDetectString(fullMatch, value, line)) {
results.push(
HardcodedValue.create(
value,
HARDCODE_TYPES.MAGIC_STRING,
lineIndex + 1,
match.index,
line.trim(),
),
)
}
}
}
/**
* Checks if string should be detected
*/
private shouldDetectString(fullMatch: string, value: string, line: string): boolean {
if (fullMatch.startsWith("`") || value.includes("${")) {
return false
}
if (this.isAllowedString(value)) {
return false
}
return this.looksLikeMagicString(line, value)
}
/**
* Checks if string is allowed (short strings, single chars, etc.)
*/
private isAllowedString(str: string): boolean {
if (str.length <= 1) {
return true
}
return this.allowedPatterns.some((pattern) => pattern.test(str))
}
/**
* Checks if line context suggests a magic string
*/
private looksLikeMagicString(line: string, value: string): boolean {
const lowerLine = line.toLowerCase()
if (this.isTestCode(lowerLine)) {
return false
}
if (this.isConsoleLog(lowerLine)) {
return false
}
if (this.isInTypeContext(line)) {
return false
}
if (this.isInSymbolCall(line, value)) {
return false
}
if (this.isInImportCall(line, value)) {
return false
}
if (this.isUrlOrApi(value)) {
return true
}
if (/^\d{2,}$/.test(value)) {
return false
}
return value.length > 3
}
/**
* Checks if line is test code
*/
private isTestCode(lowerLine: string): boolean {
return (
lowerLine.includes(DETECTION_KEYWORDS.TEST) ||
lowerLine.includes(DETECTION_KEYWORDS.DESCRIBE)
)
}
/**
* Checks if line is console log
*/
private isConsoleLog(lowerLine: string): boolean {
return (
lowerLine.includes(DETECTION_KEYWORDS.CONSOLE_LOG) ||
lowerLine.includes(DETECTION_KEYWORDS.CONSOLE_ERROR)
)
}
/**
* Checks if line is in type context
*/
private isInTypeContext(line: string): boolean {
const trimmedLine = line.trim()
if (this.typeContextPatterns.some((pattern) => pattern.test(trimmedLine))) {
return true
}
if (trimmedLine.includes("|") && /['"`][^'"`]+['"`]\s*\|/.test(trimmedLine)) {
return true
}
return false
}
/**
* Checks if string is inside Symbol() call
*/
private isInSymbolCall(line: string, stringValue: string): boolean {
const symbolPattern = new RegExp(
`Symbol\\s*\\(\\s*['"\`]${stringValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"\`]\\s*\\)`,
)
return symbolPattern.test(line)
}
/**
* Checks if string is inside import() call
*/
private isInImportCall(line: string, stringValue: string): boolean {
const importPattern = /import\s*\(\s*['"`][^'"`]+['"`]\s*\)/
return importPattern.test(line) && line.includes(stringValue)
}
/**
* Checks if string contains URL or API reference
*/
private isUrlOrApi(value: string): boolean {
return value.includes(DETECTION_KEYWORDS.HTTP) || value.includes(DETECTION_KEYWORDS.API)
}
}

View File

@@ -0,0 +1,134 @@
import { REPOSITORY_METHOD_SUGGESTIONS } from "../constants/detectorPatterns"
import { OrmTypeMatcher } from "./OrmTypeMatcher"
/**
* Validates repository method names for domain language compliance
*
* Ensures repository methods use domain language instead of
* technical database terminology.
*/
export class MethodNameValidator {
private readonly domainMethodPatterns = [
/^findBy[A-Z]/,
/^findAll$/,
/^find[A-Z]/,
/^save$/,
/^saveAll$/,
/^create$/,
/^update$/,
/^delete$/,
/^deleteBy[A-Z]/,
/^deleteAll$/,
/^remove$/,
/^removeBy[A-Z]/,
/^removeAll$/,
/^add$/,
/^add[A-Z]/,
/^get[A-Z]/,
/^getAll$/,
/^search/,
/^list/,
/^has[A-Z]/,
/^is[A-Z]/,
/^exists$/,
/^exists[A-Z]/,
/^existsBy[A-Z]/,
/^clear[A-Z]/,
/^clearAll$/,
/^store[A-Z]/,
/^initialize$/,
/^initializeCollection$/,
/^close$/,
/^connect$/,
/^disconnect$/,
/^count$/,
/^countBy[A-Z]/,
]
constructor(private readonly ormMatcher: OrmTypeMatcher) {}
/**
* Checks if a method name follows domain language conventions
*/
public isDomainMethodName(methodName: string): boolean {
if (this.ormMatcher.isTechnicalMethod(methodName)) {
return false
}
return this.domainMethodPatterns.some((pattern) => pattern.test(methodName))
}
/**
* Suggests better domain method names
*/
public suggestDomainMethodName(methodName: string): string {
const lowerName = methodName.toLowerCase()
const suggestions: string[] = []
this.collectSuggestions(lowerName, suggestions)
if (lowerName.includes("get") && lowerName.includes("all")) {
suggestions.push(
REPOSITORY_METHOD_SUGGESTIONS.FIND_ALL,
REPOSITORY_METHOD_SUGGESTIONS.LIST_ALL,
)
}
if (suggestions.length === 0) {
return REPOSITORY_METHOD_SUGGESTIONS.DEFAULT_SUGGESTION
}
return `Consider: ${suggestions.slice(0, 3).join(", ")}`
}
/**
* Collects method name suggestions based on keywords
*/
private collectSuggestions(lowerName: string, suggestions: string[]): void {
const suggestionMap: Record<string, string[]> = {
query: [
REPOSITORY_METHOD_SUGGESTIONS.SEARCH,
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
],
select: [
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
],
insert: [
REPOSITORY_METHOD_SUGGESTIONS.CREATE,
REPOSITORY_METHOD_SUGGESTIONS.ADD_ENTITY,
REPOSITORY_METHOD_SUGGESTIONS.STORE_ENTITY,
],
update: [
REPOSITORY_METHOD_SUGGESTIONS.UPDATE,
REPOSITORY_METHOD_SUGGESTIONS.MODIFY_ENTITY,
],
upsert: [
REPOSITORY_METHOD_SUGGESTIONS.SAVE,
REPOSITORY_METHOD_SUGGESTIONS.STORE_ENTITY,
],
remove: [
REPOSITORY_METHOD_SUGGESTIONS.DELETE,
REPOSITORY_METHOD_SUGGESTIONS.REMOVE_BY_PROPERTY,
],
fetch: [
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
],
retrieve: [
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
],
load: [
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
],
}
for (const [keyword, keywords] of Object.entries(suggestionMap)) {
if (lowerName.includes(keyword)) {
suggestions.push(...keywords)
}
}
}
}

View File

@@ -0,0 +1,68 @@
import { ORM_QUERY_METHODS } from "../constants/orm-methods"
import { REPOSITORY_PATTERN_MESSAGES } from "../../domain/constants/Messages"
/**
* Matches and validates ORM-specific types and patterns
*
* Identifies ORM-specific types (Prisma, TypeORM, Mongoose, etc.)
* that should not appear in domain layer repository interfaces.
*/
export class OrmTypeMatcher {
private readonly ormTypePatterns = [
/Prisma\./,
/PrismaClient/,
/TypeORM/,
/@Entity/,
/@Column/,
/@PrimaryColumn/,
/@PrimaryGeneratedColumn/,
/@ManyToOne/,
/@OneToMany/,
/@ManyToMany/,
/@JoinColumn/,
/@JoinTable/,
/Mongoose\./,
/Schema/,
/Model</,
/Document/,
/Sequelize\./,
/DataTypes\./,
/FindOptions/,
/WhereOptions/,
/IncludeOptions/,
/QueryInterface/,
/MikroORM/,
/EntityManager/,
/EntityRepository/,
/Collection</,
]
/**
* Checks if a type name is an ORM-specific type
*/
public isOrmType(typeName: string): boolean {
return this.ormTypePatterns.some((pattern) => pattern.test(typeName))
}
/**
* Extracts ORM type name from a code line
*/
public extractOrmType(line: string): string {
for (const pattern of this.ormTypePatterns) {
const match = line.match(pattern)
if (match) {
const startIdx = match.index || 0
const typeMatch = /[\w.]+/.exec(line.slice(startIdx))
return typeMatch ? typeMatch[0] : REPOSITORY_PATTERN_MESSAGES.UNKNOWN_TYPE
}
}
return REPOSITORY_PATTERN_MESSAGES.UNKNOWN_TYPE
}
/**
* Checks if a method name is a technical ORM method
*/
public isTechnicalMethod(methodName: string): boolean {
return (ORM_QUERY_METHODS as readonly string[]).includes(methodName)
}
}

View File

@@ -0,0 +1,31 @@
import { LAYERS } from "../../shared/constants/rules"
/**
* Analyzes files to determine their role in the repository pattern
*
* Identifies repository interfaces and use cases based on file paths
* and architectural layer conventions.
*/
export class RepositoryFileAnalyzer {
/**
* Checks if a file is a repository interface
*/
public isRepositoryInterface(filePath: string, layer: string | undefined): boolean {
if (layer !== LAYERS.DOMAIN) {
return false
}
return /I[A-Z]\w*Repository\.ts$/.test(filePath) && /repositories?\//.test(filePath)
}
/**
* Checks if a file is a use case
*/
public isUseCase(filePath: string, layer: string | undefined): boolean {
if (layer !== LAYERS.APPLICATION) {
return false
}
return /use-cases?\//.test(filePath) && /[A-Z][a-z]+[A-Z]\w*\.ts$/.test(filePath)
}
}

View File

@@ -0,0 +1,285 @@
import { RepositoryViolation } from "../../domain/value-objects/RepositoryViolation"
import { LAYERS, REPOSITORY_VIOLATION_TYPES } from "../../shared/constants/rules"
import { REPOSITORY_PATTERN_MESSAGES } from "../../domain/constants/Messages"
import { OrmTypeMatcher } from "./OrmTypeMatcher"
import { MethodNameValidator } from "./MethodNameValidator"
/**
* Detects specific repository pattern violations
*
* Handles detection of ORM types, non-domain methods, concrete repositories,
* and repository instantiation violations.
*/
export class RepositoryViolationDetector {
constructor(
private readonly ormMatcher: OrmTypeMatcher,
private readonly methodValidator: MethodNameValidator,
) {}
/**
* Detects ORM types in repository interface
*/
public detectOrmTypes(
code: string,
filePath: string,
layer: string | undefined,
): RepositoryViolation[] {
const violations: RepositoryViolation[] = []
const lines = code.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNumber = i + 1
this.detectOrmInMethod(line, lineNumber, filePath, layer, violations)
this.detectOrmInLine(line, lineNumber, filePath, layer, violations)
}
return violations
}
/**
* Detects non-domain method names
*/
public detectNonDomainMethods(
code: string,
filePath: string,
layer: string | undefined,
): RepositoryViolation[] {
const violations: RepositoryViolation[] = []
const lines = code.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNumber = i + 1
const methodMatch = /^\s*(\w+)\s*\(/.exec(line)
if (methodMatch) {
const methodName = methodMatch[1]
if (
!this.methodValidator.isDomainMethodName(methodName) &&
!line.trim().startsWith("//")
) {
const suggestion = this.methodValidator.suggestDomainMethodName(methodName)
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
filePath,
layer || LAYERS.DOMAIN,
lineNumber,
`Method '${methodName}' uses technical name instead of domain language. ${suggestion}`,
undefined,
undefined,
methodName,
),
)
}
}
}
return violations
}
/**
* Detects concrete repository usage
*/
public detectConcreteRepositoryUsage(
code: string,
filePath: string,
layer: string | undefined,
): RepositoryViolation[] {
const violations: RepositoryViolation[] = []
const lines = code.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNumber = i + 1
this.detectConcreteInConstructor(line, lineNumber, filePath, layer, violations)
this.detectConcreteInField(line, lineNumber, filePath, layer, violations)
}
return violations
}
/**
* Detects new Repository() instantiation
*/
public detectNewInstantiation(
code: string,
filePath: string,
layer: string | undefined,
): RepositoryViolation[] {
const violations: RepositoryViolation[] = []
const lines = code.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const lineNumber = i + 1
const newRepositoryMatch = /new\s+([A-Z]\w*Repository)\s*\(/.exec(line)
if (newRepositoryMatch && !line.trim().startsWith("//")) {
const repositoryName = newRepositoryMatch[1]
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE,
filePath,
layer || LAYERS.APPLICATION,
lineNumber,
`Use case creates repository with 'new ${repositoryName}()'`,
undefined,
repositoryName,
),
)
}
}
return violations
}
/**
* Detects ORM types in method signatures
*/
private detectOrmInMethod(
line: string,
lineNumber: number,
filePath: string,
layer: string | undefined,
violations: RepositoryViolation[],
): void {
const methodMatch =
/(\w+)\s*\([^)]*:\s*([^)]+)\)\s*:\s*.*?(?:Promise<([^>]+)>|([A-Z]\w+))/.exec(line)
if (methodMatch) {
const params = methodMatch[2]
const returnType = methodMatch[3] || methodMatch[4]
if (this.ormMatcher.isOrmType(params)) {
const ormType = this.ormMatcher.extractOrmType(params)
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
filePath,
layer || LAYERS.DOMAIN,
lineNumber,
`Method parameter uses ORM type: ${ormType}`,
ormType,
),
)
}
if (returnType && this.ormMatcher.isOrmType(returnType)) {
const ormType = this.ormMatcher.extractOrmType(returnType)
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
filePath,
layer || LAYERS.DOMAIN,
lineNumber,
`Method return type uses ORM type: ${ormType}`,
ormType,
),
)
}
}
}
/**
* Detects ORM types in general code line
*/
private detectOrmInLine(
line: string,
lineNumber: number,
filePath: string,
layer: string | undefined,
violations: RepositoryViolation[],
): void {
if (this.ormMatcher.isOrmType(line) && !line.trim().startsWith("//")) {
const ormType = this.ormMatcher.extractOrmType(line)
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
filePath,
layer || LAYERS.DOMAIN,
lineNumber,
`Repository interface contains ORM-specific type: ${ormType}`,
ormType,
),
)
}
}
/**
* Detects concrete repository in constructor
*/
private detectConcreteInConstructor(
line: string,
lineNumber: number,
filePath: string,
layer: string | undefined,
violations: RepositoryViolation[],
): void {
const constructorParamMatch =
/constructor\s*\([^)]*(?:private|public|protected)\s+(?:readonly\s+)?(\w+)\s*:\s*([A-Z]\w*Repository)/.exec(
line,
)
if (constructorParamMatch) {
const repositoryType = constructorParamMatch[2]
if (!repositoryType.startsWith("I")) {
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
filePath,
layer || LAYERS.APPLICATION,
lineNumber,
`Use case depends on concrete repository '${repositoryType}'`,
undefined,
repositoryType,
),
)
}
}
}
/**
* Detects concrete repository in field
*/
private detectConcreteInField(
line: string,
lineNumber: number,
filePath: string,
layer: string | undefined,
violations: RepositoryViolation[],
): void {
const fieldMatch =
/(?:private|public|protected)\s+(?:readonly\s+)?(\w+)\s*:\s*([A-Z]\w*Repository)/.exec(
line,
)
if (fieldMatch) {
const repositoryType = fieldMatch[2]
if (
!repositoryType.startsWith("I") &&
!line.includes(REPOSITORY_PATTERN_MESSAGES.CONSTRUCTOR)
) {
violations.push(
RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
filePath,
layer || LAYERS.APPLICATION,
lineNumber,
`Use case field uses concrete repository '${repositoryType}'`,
undefined,
repositoryType,
),
)
}
}
}
}