mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
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
|
||||
public detectMagicNumbers(code: string, filePath: string): HardcodedValue[] {
|
||||
if (this.constantsChecker.isConstantsFile(filePath)) {
|
||||
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
|
||||
public detectMagicStrings(code: string, filePath: string): HardcodedValue[] {
|
||||
if (this.constantsChecker.isConstantsFile(filePath)) {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user