diff --git a/packages/guardian/CHANGELOG.md b/packages/guardian/CHANGELOG.md index e69bf6d..755da06 100644 --- a/packages/guardian/CHANGELOG.md +++ b/packages/guardian/CHANGELOG.md @@ -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 diff --git a/packages/guardian/ROADMAP.md b/packages/guardian/ROADMAP.md index 2cfe188..e4328fb 100644 --- a/packages/guardian/ROADMAP.md +++ b/packages/guardian/ROADMAP.md @@ -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 --- diff --git a/packages/guardian/package.json b/packages/guardian/package.json index 8244fe1..a46ad43 100644 --- a/packages/guardian/package.json +++ b/packages/guardian/package.json @@ -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", diff --git a/packages/guardian/src/infrastructure/analyzers/AggregateBoundaryDetector.ts b/packages/guardian/src/infrastructure/analyzers/AggregateBoundaryDetector.ts index 48f4a94..74f3425 100644 --- a/packages/guardian/src/infrastructure/analyzers/AggregateBoundaryDetector.ts +++ b/packages/guardian/src/infrastructure/analyzers/AggregateBoundaryDetector.ts @@ -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([ - DDD_FOLDER_NAMES.ENTITIES, - DDD_FOLDER_NAMES.AGGREGATES, - ]) - private readonly valueObjectFolderNames = new Set([ - DDD_FOLDER_NAMES.VALUE_OBJECTS, - DDD_FOLDER_NAMES.VO, - ]) - private readonly allowedFolderNames = new Set([ - 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([ - 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 - } } diff --git a/packages/guardian/src/infrastructure/analyzers/HardcodeDetector.ts b/packages/guardian/src/infrastructure/analyzers/HardcodeDetector.ts index 30f599b..8b0383b 100644 --- a/packages/guardian/src/infrastructure/analyzers/HardcodeDetector.ts +++ b/packages/guardian/src/infrastructure/analyzers/HardcodeDetector.ts @@ -1,7 +1,10 @@ import { IHardcodeDetector } from "../../domain/services/IHardcodeDetector" import { HardcodedValue } from "../../domain/value-objects/HardcodedValue" -import { ALLOWED_NUMBERS, CODE_PATTERNS, DETECTION_KEYWORDS } from "../constants/defaults" -import { HARDCODE_TYPES } from "../../shared/constants" +import { BraceTracker } from "../strategies/BraceTracker" +import { ConstantsFileChecker } from "../strategies/ConstantsFileChecker" +import { ExportConstantAnalyzer } from "../strategies/ExportConstantAnalyzer" +import { MagicNumberMatcher } from "../strategies/MagicNumberMatcher" +import { MagicStringMatcher } from "../strategies/MagicStringMatcher" /** * Detects hardcoded values (magic numbers and strings) in TypeScript/JavaScript code @@ -22,22 +25,19 @@ import { HARDCODE_TYPES } from "../../shared/constants" * ``` */ export class HardcodeDetector implements IHardcodeDetector { - private readonly ALLOWED_NUMBERS = ALLOWED_NUMBERS + private readonly constantsChecker: ConstantsFileChecker + private readonly braceTracker: BraceTracker + private readonly exportAnalyzer: ExportConstantAnalyzer + private readonly numberMatcher: MagicNumberMatcher + private readonly stringMatcher: MagicStringMatcher - private readonly ALLOWED_STRING_PATTERNS = [/^[a-z]$/i, /^\/$/, /^\\$/, /^\s+$/, /^,$/, /^\.$/] - - /** - * Patterns to detect TypeScript type contexts where strings should be ignored - */ - private readonly TYPE_CONTEXT_PATTERNS = [ - /^\s*type\s+\w+\s*=/i, // type Foo = ... - /^\s*interface\s+\w+/i, // interface Foo { ... } - /^\s*\w+\s*:\s*['"`]/, // property: 'value' (in type or interface) - /\s+as\s+['"`]/, // ... as 'type' - /Record<.*,\s*import\(/, // Record with import type - /typeof\s+\w+\s*===\s*['"`]/, // typeof x === 'string' - /['"`]\s*===\s*typeof\s+\w+/, // 'string' === typeof x - ] + constructor() { + this.constantsChecker = new ConstantsFileChecker() + this.braceTracker = new BraceTracker() + this.exportAnalyzer = new ExportConstantAnalyzer(this.braceTracker) + this.numberMatcher = new MagicNumberMatcher(this.exportAnalyzer) + this.stringMatcher = new MagicStringMatcher(this.exportAnalyzer) + } /** * Detects all hardcoded values (both numbers and strings) in the given code @@ -47,413 +47,43 @@ export class HardcodeDetector implements IHardcodeDetector { * @returns Array of detected hardcoded values with suggestions */ public detectAll(code: string, filePath: string): HardcodedValue[] { - if (this.isConstantsFile(filePath)) { + if (this.constantsChecker.isConstantsFile(filePath)) { return [] } - const magicNumbers = this.detectMagicNumbers(code, filePath) - const magicStrings = this.detectMagicStrings(code, filePath) + + const magicNumbers = this.numberMatcher.detect(code) + const magicStrings = this.stringMatcher.detect(code) + return [...magicNumbers, ...magicStrings] } /** - * Check if a file is a constants definition file or DI tokens file - */ - private isConstantsFile(filePath: string): boolean { - const _fileName = filePath.split("/").pop() ?? "" - const constantsPatterns = [ - /^constants?\.(ts|js)$/i, - /constants?\/.*\.(ts|js)$/i, - /\/(constants|config|settings|defaults|tokens)\.ts$/i, - /\/di\/tokens\.(ts|js)$/i, - ] - return constantsPatterns.some((pattern) => pattern.test(filePath)) - } - - /** - * Check if a line is inside an exported constant definition - */ - private isInExportedConstant(lines: string[], lineIndex: number): boolean { - const currentLineTrimmed = lines[lineIndex].trim() - - if (this.isSingleLineExportConst(currentLineTrimmed)) { - return true - } - - const exportConstStart = this.findExportConstStart(lines, lineIndex) - if (exportConstStart === -1) { - return false - } - - const { braces, brackets } = this.countUnclosedBraces(lines, exportConstStart, lineIndex) - return braces > 0 || brackets > 0 - } - - /** - * Check if a line is a single-line export const declaration - */ - private isSingleLineExportConst(line: string): boolean { - if (!line.startsWith(CODE_PATTERNS.EXPORT_CONST)) { - return false - } - - const hasObjectOrArray = - line.includes(CODE_PATTERNS.OBJECT_START) || line.includes(CODE_PATTERNS.ARRAY_START) - - if (hasObjectOrArray) { - const hasAsConstEnding = - line.includes(CODE_PATTERNS.AS_CONST_OBJECT) || - line.includes(CODE_PATTERNS.AS_CONST_ARRAY) || - line.includes(CODE_PATTERNS.AS_CONST_END_SEMICOLON_OBJECT) || - line.includes(CODE_PATTERNS.AS_CONST_END_SEMICOLON_ARRAY) - - return hasAsConstEnding - } - - return line.includes(CODE_PATTERNS.AS_CONST) - } - - /** - * Find the starting line of an export const declaration - */ - private findExportConstStart(lines: string[], lineIndex: number): number { - for (let currentLine = lineIndex; currentLine >= 0; currentLine--) { - const trimmed = lines[currentLine].trim() - - const isExportConst = - trimmed.startsWith(CODE_PATTERNS.EXPORT_CONST) && - (trimmed.includes(CODE_PATTERNS.OBJECT_START) || - trimmed.includes(CODE_PATTERNS.ARRAY_START)) - - if (isExportConst) { - return currentLine - } - - const isTopLevelStatement = - currentLine < lineIndex && - (trimmed.startsWith(CODE_PATTERNS.EXPORT) || - trimmed.startsWith(CODE_PATTERNS.IMPORT)) - - if (isTopLevelStatement) { - break - } - } - - return -1 - } - - /** - * Count unclosed braces and brackets between two line indices - */ - private countUnclosedBraces( - lines: string[], - startLine: number, - endLine: number, - ): { braces: number; brackets: number } { - let braces = 0 - let brackets = 0 - - for (let i = startLine; i <= endLine; i++) { - const line = lines[i] - let inString = false - let stringChar = "" - - for (let j = 0; j < line.length; j++) { - const char = line[j] - const prevChar = j > 0 ? line[j - 1] : "" - - if ((char === "'" || char === '"' || char === "`") && prevChar !== "\\") { - if (!inString) { - inString = true - stringChar = char - } else if (char === stringChar) { - inString = false - stringChar = "" - } - } - - if (!inString) { - if (char === "{") { - braces++ - } else if (char === "}") { - braces-- - } else if (char === "[") { - brackets++ - } else if (char === "]") { - brackets-- - } - } - } - } - - return { braces, brackets } - } - - /** - * Detects magic numbers in code (timeouts, ports, limits, retries, etc.) - * - * Skips allowed numbers (-1, 0, 1, 2, 10, 100, 1000) and values in exported constants + * Detects magic numbers in code * * @param code - Source code to analyze - * @param _filePath - File path (currently unused, reserved for future use) + * @param filePath - File path (used for constants file check) * @returns Array of detected magic numbers */ - public detectMagicNumbers(code: string, _filePath: string): HardcodedValue[] { - const results: HardcodedValue[] = [] - const lines = code.split("\n") + public detectMagicNumbers(code: string, filePath: string): HardcodedValue[] { + if (this.constantsChecker.isConstantsFile(filePath)) { + return [] + } - const numberPatterns = [ - /(?:setTimeout|setInterval)\s*\(\s*[^,]+,\s*(\d+)/g, - /(?:maxRetries|retries|attempts)\s*[=:]\s*(\d+)/gi, - /(?:limit|max|min)\s*[=:]\s*(\d+)/gi, - /(?:port|PORT)\s*[=:]\s*(\d+)/g, - /(?:delay|timeout|TIMEOUT)\s*[=:]\s*(\d+)/gi, - ] - - lines.forEach((line, lineIndex) => { - if (line.trim().startsWith("//") || line.trim().startsWith("*")) { - return - } - - // Skip lines inside exported constants - if (this.isInExportedConstant(lines, lineIndex)) { - return - } - - numberPatterns.forEach((pattern) => { - let match - const regex = new RegExp(pattern) - - while ((match = regex.exec(line)) !== null) { - const value = parseInt(match[1], 10) - - if (!this.ALLOWED_NUMBERS.has(value)) { - results.push( - HardcodedValue.create( - value, - HARDCODE_TYPES.MAGIC_NUMBER, - lineIndex + 1, - match.index, - line.trim(), - ), - ) - } - } - }) - - const genericNumberRegex = /\b(\d{3,})\b/g - let match - - while ((match = genericNumberRegex.exec(line)) !== null) { - const value = parseInt(match[1], 10) - - if ( - !this.ALLOWED_NUMBERS.has(value) && - !this.isInComment(line, match.index) && - !this.isInString(line, match.index) - ) { - const context = this.extractContext(line, match.index) - if (this.looksLikeMagicNumber(context)) { - results.push( - HardcodedValue.create( - value, - HARDCODE_TYPES.MAGIC_NUMBER, - lineIndex + 1, - match.index, - line.trim(), - ), - ) - } - } - } - }) - - return results + return this.numberMatcher.detect(code) } /** - * Detects magic strings in code (URLs, connection strings, error messages, etc.) - * - * Skips short strings (≤3 chars), console logs, test descriptions, imports, - * and values in exported constants + * Detects magic strings in code * * @param code - Source code to analyze - * @param _filePath - File path (currently unused, reserved for future use) + * @param filePath - File path (used for constants file check) * @returns Array of detected magic strings */ - public detectMagicStrings(code: string, _filePath: string): HardcodedValue[] { - const results: HardcodedValue[] = [] - const lines = code.split("\n") - - const stringRegex = /(['"`])(?:(?!\1).)+\1/g - - lines.forEach((line, lineIndex) => { - if ( - line.trim().startsWith("//") || - line.trim().startsWith("*") || - line.includes("import ") || - line.includes("from ") - ) { - return - } - - // Skip lines inside exported constants - if (this.isInExportedConstant(lines, lineIndex)) { - return - } - - let match - const regex = new RegExp(stringRegex) - - while ((match = regex.exec(line)) !== null) { - const fullMatch = match[0] - const value = fullMatch.slice(1, -1) - - // Skip template literals (backtick strings with ${} interpolation) - if (fullMatch.startsWith("`") || value.includes("${")) { - continue - } - - if (!this.isAllowedString(value) && this.looksLikeMagicString(line, value)) { - results.push( - HardcodedValue.create( - value, - HARDCODE_TYPES.MAGIC_STRING, - lineIndex + 1, - match.index, - line.trim(), - ), - ) - } - } - }) - - return results - } - - private isAllowedString(str: string): boolean { - if (str.length <= 1) { - return true + public detectMagicStrings(code: string, filePath: string): HardcodedValue[] { + if (this.constantsChecker.isConstantsFile(filePath)) { + return [] } - return this.ALLOWED_STRING_PATTERNS.some((pattern) => pattern.test(str)) - } - - private looksLikeMagicString(line: string, value: string): boolean { - const lowerLine = line.toLowerCase() - - if ( - lowerLine.includes(DETECTION_KEYWORDS.TEST) || - lowerLine.includes(DETECTION_KEYWORDS.DESCRIBE) - ) { - return false - } - - if ( - lowerLine.includes(DETECTION_KEYWORDS.CONSOLE_LOG) || - lowerLine.includes(DETECTION_KEYWORDS.CONSOLE_ERROR) - ) { - return false - } - - if (this.isInTypeContext(line)) { - return false - } - - if (this.isInSymbolCall(line, value)) { - return false - } - - if (this.isInImportCall(line, value)) { - return false - } - - if (value.includes(DETECTION_KEYWORDS.HTTP) || value.includes(DETECTION_KEYWORDS.API)) { - return true - } - - if (/^\d{2,}$/.test(value)) { - return false - } - - return value.length > 3 - } - - private looksLikeMagicNumber(context: string): boolean { - const lowerContext = context.toLowerCase() - - const configKeywords = [ - DETECTION_KEYWORDS.TIMEOUT, - DETECTION_KEYWORDS.DELAY, - DETECTION_KEYWORDS.RETRY, - DETECTION_KEYWORDS.LIMIT, - DETECTION_KEYWORDS.MAX, - DETECTION_KEYWORDS.MIN, - DETECTION_KEYWORDS.PORT, - DETECTION_KEYWORDS.INTERVAL, - ] - - return configKeywords.some((keyword) => lowerContext.includes(keyword)) - } - - private isInComment(line: string, index: number): boolean { - const beforeIndex = line.substring(0, index) - return beforeIndex.includes("//") || beforeIndex.includes("/*") - } - - private isInString(line: string, index: number): boolean { - const beforeIndex = line.substring(0, index) - const singleQuotes = (beforeIndex.match(/'/g) ?? []).length - const doubleQuotes = (beforeIndex.match(/"/g) ?? []).length - const backticks = (beforeIndex.match(/`/g) ?? []).length - - return singleQuotes % 2 !== 0 || doubleQuotes % 2 !== 0 || backticks % 2 !== 0 - } - - private extractContext(line: string, index: number): string { - const start = Math.max(0, index - 30) - const end = Math.min(line.length, index + 30) - return line.substring(start, end) - } - - /** - * Check if a line is in a TypeScript type definition context - * Examples: - * - type Foo = 'a' | 'b' - * - interface Bar { prop: 'value' } - * - Record - * - ... 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) } } diff --git a/packages/guardian/src/infrastructure/analyzers/RepositoryPatternDetector.ts b/packages/guardian/src/infrastructure/analyzers/RepositoryPatternDetector.ts index 497225b..daa0298 100644 --- a/packages/guardian/src/infrastructure/analyzers/RepositoryPatternDetector.ts +++ b/packages/guardian/src/infrastructure/analyzers/RepositoryPatternDetector.ts @@ -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 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 = { - 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) } } diff --git a/packages/guardian/src/infrastructure/strategies/AggregatePathAnalyzer.ts b/packages/guardian/src/infrastructure/strategies/AggregatePathAnalyzer.ts new file mode 100644 index 0000000..949b762 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/AggregatePathAnalyzer.ts @@ -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 + } +} diff --git a/packages/guardian/src/infrastructure/strategies/BraceTracker.ts b/packages/guardian/src/infrastructure/strategies/BraceTracker.ts new file mode 100644 index 0000000..8df3983 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/BraceTracker.ts @@ -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 } + } +} diff --git a/packages/guardian/src/infrastructure/strategies/ConstantsFileChecker.ts b/packages/guardian/src/infrastructure/strategies/ConstantsFileChecker.ts new file mode 100644 index 0000000..7668a7d --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/ConstantsFileChecker.ts @@ -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)) + } +} diff --git a/packages/guardian/src/infrastructure/strategies/ExportConstantAnalyzer.ts b/packages/guardian/src/infrastructure/strategies/ExportConstantAnalyzer.ts new file mode 100644 index 0000000..4666e20 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/ExportConstantAnalyzer.ts @@ -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)) + ) + } +} diff --git a/packages/guardian/src/infrastructure/strategies/FolderRegistry.ts b/packages/guardian/src/infrastructure/strategies/FolderRegistry.ts new file mode 100644 index 0000000..29c41cb --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/FolderRegistry.ts @@ -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 + public readonly valueObjectFolders: Set + public readonly allowedFolders: Set + public readonly nonAggregateFolders: Set + + constructor() { + this.entityFolders = new Set([ + DDD_FOLDER_NAMES.ENTITIES, + DDD_FOLDER_NAMES.AGGREGATES, + ]) + + this.valueObjectFolders = new Set([ + DDD_FOLDER_NAMES.VALUE_OBJECTS, + DDD_FOLDER_NAMES.VO, + ]) + + this.allowedFolders = new Set([ + 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([ + 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) + } +} diff --git a/packages/guardian/src/infrastructure/strategies/ImportValidator.ts b/packages/guardian/src/infrastructure/strategies/ImportValidator.ts new file mode 100644 index 0000000..a3f499c --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/ImportValidator.ts @@ -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) + } + } +} diff --git a/packages/guardian/src/infrastructure/strategies/MagicNumberMatcher.ts b/packages/guardian/src/infrastructure/strategies/MagicNumberMatcher.ts new file mode 100644 index 0000000..a083685 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/MagicNumberMatcher.ts @@ -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)) + } +} diff --git a/packages/guardian/src/infrastructure/strategies/MagicStringMatcher.ts b/packages/guardian/src/infrastructure/strategies/MagicStringMatcher.ts new file mode 100644 index 0000000..61193c1 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/MagicStringMatcher.ts @@ -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) + } +} diff --git a/packages/guardian/src/infrastructure/strategies/MethodNameValidator.ts b/packages/guardian/src/infrastructure/strategies/MethodNameValidator.ts new file mode 100644 index 0000000..1d7681b --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/MethodNameValidator.ts @@ -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 = { + 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) + } + } + } +} diff --git a/packages/guardian/src/infrastructure/strategies/OrmTypeMatcher.ts b/packages/guardian/src/infrastructure/strategies/OrmTypeMatcher.ts new file mode 100644 index 0000000..e46fb87 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/OrmTypeMatcher.ts @@ -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 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) + } +} diff --git a/packages/guardian/src/infrastructure/strategies/RepositoryFileAnalyzer.ts b/packages/guardian/src/infrastructure/strategies/RepositoryFileAnalyzer.ts new file mode 100644 index 0000000..7a0a743 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/RepositoryFileAnalyzer.ts @@ -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) + } +} diff --git a/packages/guardian/src/infrastructure/strategies/RepositoryViolationDetector.ts b/packages/guardian/src/infrastructure/strategies/RepositoryViolationDetector.ts new file mode 100644 index 0000000..30bf3e0 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/RepositoryViolationDetector.ts @@ -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, + ), + ) + } + } + } +}