Compare commits

..

13 Commits

Author SHA1 Message Date
imfozilbek
0b9b8564bf test: improve test coverage for domain files from 46-58% to 92-100%
- Add 31 tests for SourceFile.ts (46% → 100%)
- Add 31 tests for ProjectPath.ts (50% → 100%)
- Add 18 tests for ValueObject.ts (25% → 100%)
- Add 32 tests for RepositoryViolation.ts (58% → 92.68%)
- Total test count: 345 → 457 tests (all passing)
- Overall coverage: 95.4% statements, 86.25% branches, 96.68% functions
- Update version to 0.7.7
- Update ROADMAP.md and CHANGELOG.md
2025-11-25 16:50:00 +05:00
imfozilbek
0da25d9046 docs: mark v0.7.6 as published to npm 2025-11-25 16:31:23 +05:00
imfozilbek
7fea9a8fdb refactor: split CLI module into focused formatters and groupers
- Created cli/groupers/ViolationGrouper.ts for severity filtering
- Created cli/formatters/OutputFormatter.ts for violation formatting
- Created cli/formatters/StatisticsFormatter.ts for metrics display
- Reduced cli/index.ts from 484 to 260 lines (46% reduction)
- All 345 tests pass, CLI output identical to before
- No breaking changes
2025-11-25 16:30:04 +05:00
imfozilbek
b5f54fc3f8 docs: mark v0.7.5 as released in ROADMAP 2025-11-25 16:09:17 +05:00
imfozilbek
8a2c6fdc0e refactor: split AnalyzeProject into pipeline components
Split 615-line God Use-Case into focused pipeline components:
- FileCollectionStep.ts (66 lines) - file scanning and basic parsing
- ParsingStep.ts (51 lines) - AST parsing and dependency graph
- DetectionPipeline.ts (371 lines) - all 7 detectors
- ResultAggregator.ts (81 lines) - response DTO builder

Reduced AnalyzeProject.ts from 615 to 245 lines (60% reduction).

All 345 tests pass, no breaking changes.
Improved separation of concerns and testability.

Closes #0.7.5 roadmap task.
2025-11-25 16:07:20 +05:00
imfozilbek
2479bde9a8 docs: update CHANGELOG for v0.7.5-beta.1 2025-11-25 15:50:30 +05:00
imfozilbek
f6bb65f2f1 chore: bump version to 0.7.5-beta.1 2025-11-25 15:48:31 +05:00
imfozilbek
8916ce9eab feat(cli): add AI Agent Instructions to --help output
Add dedicated section in help for AI coding assistants with:
- Step-by-step workflow (scan → fix → verify → expand)
- Recommended commands for each step
- Output format description for parsing
- Priority order guidance (CRITICAL → HIGH → MEDIUM → LOW)

This helps AI agents (Claude, Copilot, Cursor) immediately
understand how to use Guardian and take action.
2025-11-25 15:48:03 +05:00
imfozilbek
24f54d4b57 docs: add hardening releases v0.7.5-v0.7.9 to ROADMAP
Plan internal improvements before v0.8.0:
- v0.7.5: Refactor AnalyzeProject use-case (614 -> ~100 lines)
- v0.7.6: Refactor CLI module (470 -> ~100 lines)
- v0.7.7: Improve test coverage for domain files
- v0.7.8: Add E2E tests for pipeline and CLI
- v0.7.9: Refactor large detectors (optional)

Each release scoped to fit single session (~128K tokens).
2025-11-25 15:42:12 +05:00
imfozilbek
d038f90bd2 docs: add SecretDetector feature to ROADMAP v0.8.0
- Add comprehensive SecretDetector feature specification for v0.8.0
- Shift all future roadmap versions by +1 (0.8.0→0.9.0, 0.9.0→0.10.0, etc.)
- Document Secretlint integration approach
- Specify 350+ secret patterns detection (AWS, GitHub, NPM, SSH, GCP, Slack)
- Define architecture with ISecretDetector interface and SecretViolation value object
- Highlight separation from HardcodeDetector (two focused detectors)
- Target: Q1 2025, Priority: CRITICAL
2025-11-25 15:18:27 +05:00
imfozilbek
e79874e420 chore: bump version to 0.7.4 2025-11-25 13:27:38 +05:00
imfozilbek
1663d191ee docs: update CHANGELOG for v0.7.4 2025-11-25 12:16:17 +05:00
imfozilbek
7b4cb60f13 feat: reduce false positives in hardcode detector by 35%
Add TypeScript-aware filtering to HardcodeDetector to ignore legitimate
language constructs that are not actually hardcoded values.

Changes:
- Add detection and filtering of TypeScript type contexts:
  * Union types (type Status = 'active' | 'inactive')
  * Interface property types (interface { mode: 'development' })
  * Type assertions (as 'read' | 'write')
  * typeof checks (typeof x === 'string')
- Add Symbol() call detection for DI container tokens
- Add import() dynamic import detection
- Extend constants file patterns to include tokens.ts/tokens.js
- Add 13 new tests covering TypeScript type context filtering

Impact:
- Tested on real project (puaro/core): 985 → 633 issues (35.7% reduction)
- All 345 tests pass
- Zero new linting errors
2025-11-25 12:12:36 +05:00
19 changed files with 2840 additions and 710 deletions

View File

@@ -5,6 +5,80 @@ 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.7] - 2025-11-25
### Added
- 🧪 **Comprehensive test coverage for under-tested domain files**:
- Added 31 tests for `SourceFile.ts` - coverage improved from 46% to 100%
- Added 31 tests for `ProjectPath.ts` - coverage improved from 50% to 100%
- Added 18 tests for `ValueObject.ts` - coverage improved from 25% to 100%
- Added 32 tests for `RepositoryViolation.ts` - coverage improved from 58% to 92.68%
- Total test count increased from 345 to 457 tests
- Overall coverage improved to 95.4% statements, 86.25% branches, 96.68% functions
- All tests pass with no breaking changes
### Changed
- 📊 **Improved code quality and maintainability**:
- Enhanced test suite for core domain entities and value objects
- Better coverage of edge cases and error handling
- Increased confidence in domain layer correctness
## [0.7.6] - 2025-11-25
### Changed
- ♻️ **Refactored CLI module** - improved maintainability and separation of concerns:
- Split 484-line `cli/index.ts` into focused modules
- Created `cli/groupers/ViolationGrouper.ts` for severity grouping and filtering (29 lines)
- Created `cli/formatters/OutputFormatter.ts` for violation formatting (190 lines)
- Created `cli/formatters/StatisticsFormatter.ts` for metrics and summary (58 lines)
- Reduced `cli/index.ts` from 484 to 260 lines (46% reduction)
- All 345 tests pass, CLI output identical to before
- No breaking changes
## [0.7.5] - 2025-11-25
### Changed
- ♻️ **Refactored AnalyzeProject use-case** - improved maintainability and testability:
- Split 615-line God Use-Case into focused pipeline components
- Created `FileCollectionStep.ts` for file scanning and basic parsing (66 lines)
- Created `ParsingStep.ts` for AST parsing and dependency graph construction (51 lines)
- Created `DetectionPipeline.ts` for running all 7 detectors (371 lines)
- Created `ResultAggregator.ts` for building response DTO (81 lines)
- Reduced `AnalyzeProject.ts` from 615 to 245 lines (60% reduction)
- All 345 tests pass, no breaking changes
- Improved separation of concerns and single responsibility
- Easier to test and modify individual pipeline steps
### Added
- 🤖 **AI Agent Instructions in CLI help** - dedicated section for AI coding assistants:
- Step-by-step workflow: scan → fix → verify → expand scope
- Recommended commands for each step (`--only-critical --limit 5`)
- Output format description for easy parsing
- Priority order guidance (CRITICAL → HIGH → MEDIUM → LOW)
- Helps Claude, Copilot, Cursor, and other AI agents immediately take action
Run `guardian --help` to see the new "AI AGENT INSTRUCTIONS" section.
## [0.7.4] - 2025-11-25
### Fixed
- 🐛 **TypeScript-aware hardcode detection** - dramatically reduces false positives by 35.7%:
- Ignore strings in TypeScript union types (`type Status = 'active' | 'inactive'`)
- Ignore strings in interface property types (`interface { mode: 'development' | 'production' }`)
- Ignore strings in type assertions (`as 'read' | 'write'`)
- Ignore strings in typeof checks (`typeof x === 'string'`)
- Ignore strings in Symbol() calls for DI tokens (`Symbol('LOGGER')`)
- Ignore strings in dynamic import() calls (`import('../../module.js')`)
- Exclude tokens.ts/tokens.js files completely (DI container files)
- Tested on real-world TypeScript project: 985 → 633 issues (352 false positives eliminated)
-**Added 13 new tests** for TypeScript type context filtering
## [0.7.3] - 2025-11-25
### Fixed

View File

@@ -2,9 +2,9 @@
This document outlines the current features and future plans for @puaros/guardian.
## Current Version: 0.6.0 ✅ RELEASED
## Current Version: 0.7.5 ✅ RELEASED
**Released:** 2025-11-24
**Released:** 2025-11-25
### Features Included in 0.1.0
@@ -301,7 +301,226 @@ class Order {
---
### Version 0.8.0 - Anemic Domain Model Detection 🩺
### Version 0.7.5 - Refactor AnalyzeProject Use-Case 🔧 ✅ RELEASED
**Released:** 2025-11-25
**Priority:** HIGH
**Scope:** Single session (~128K tokens)
Split `AnalyzeProject.ts` (615 lines) into focused pipeline components.
**Problem:**
- God Use-Case with 615 lines
- Mixing: file scanning, parsing, detection, aggregation
- Hard to test and modify individual steps
**Solution:**
```
application/use-cases/
├── AnalyzeProject.ts # Orchestrator (245 lines)
├── pipeline/
│ ├── FileCollectionStep.ts # File scanning (66 lines)
│ ├── ParsingStep.ts # AST + dependency graph (51 lines)
│ ├── DetectionPipeline.ts # All 7 detectors (371 lines)
│ └── ResultAggregator.ts # Build response DTO (81 lines)
```
**Deliverables:**
- ✅ Extract 4 pipeline components
- ✅ Reduce `AnalyzeProject.ts` from 615 to 245 lines (60% reduction)
- ✅ All 345 tests pass, no breaking changes
- ✅ Publish to npm
---
### Version 0.7.6 - Refactor CLI Module 🔧 ✅ RELEASED
**Released:** 2025-11-25
**Priority:** MEDIUM
**Scope:** Single session (~128K tokens)
Split `cli/index.ts` (484 lines) into focused formatters.
**Problem:**
- CLI file has 484 lines
- Mixing: command setup, formatting, grouping, statistics
**Solution:**
```
cli/
├── index.ts # Commands only (260 lines)
├── formatters/
│ ├── OutputFormatter.ts # Violation formatting (190 lines)
│ └── StatisticsFormatter.ts # Metrics & summary (58 lines)
├── groupers/
│ └── ViolationGrouper.ts # Sorting & grouping (29 lines)
```
**Deliverables:**
- ✅ Extract formatters and groupers
- ✅ Reduce `cli/index.ts` from 484 to 260 lines (46% reduction)
- ✅ CLI output identical to before
- ✅ All 345 tests pass, no breaking changes
- ✅ Publish to npm
---
### Version 0.7.7 - Improve Test Coverage 🧪 ✅ RELEASED
**Released:** 2025-11-25
**Priority:** MEDIUM
**Scope:** Single session (~128K tokens)
Increase coverage for under-tested domain files.
**Results:**
| File | Before | After |
|------|--------|-------|
| SourceFile.ts | 46% | 100% ✅ |
| ProjectPath.ts | 50% | 100% ✅ |
| ValueObject.ts | 25% | 100% ✅ |
| RepositoryViolation.ts | 58% | 92.68% ✅ |
**Deliverables:**
- ✅ SourceFile.ts → 100% (31 tests)
- ✅ ProjectPath.ts → 100% (31 tests)
- ✅ ValueObject.ts → 100% (18 tests)
- ✅ RepositoryViolation.ts → 92.68% (32 tests)
- ✅ All 457 tests passing
- ✅ Overall coverage: 95.4% statements, 86.25% branches, 96.68% functions
- [ ] Publish to npm
---
### Version 0.7.8 - Add E2E Tests 🧪
**Priority:** MEDIUM
**Scope:** Single session (~128K tokens)
Add integration tests for full pipeline and CLI.
**Deliverables:**
- [ ] E2E test: `AnalyzeProject` full pipeline
- [ ] CLI smoke test (spawn process, check output)
- [ ] Test `examples/good-architecture/` → 0 violations
- [ ] Test `examples/bad/` → specific violations
- [ ] Test JSON output format
- [ ] Publish to npm
---
### Version 0.7.9 - Refactor Large Detectors 🔧 (Optional)
**Priority:** LOW
**Scope:** Single session (~128K tokens)
Refactor largest detectors to reduce complexity.
**Targets:**
| Detector | Lines | Complexity |
|----------|-------|------------|
| RepositoryPatternDetector | 479 | 35 |
| HardcodeDetector | 459 | 41 |
| AggregateBoundaryDetector | 381 | 47 |
**Deliverables:**
- [ ] Extract regex patterns into strategies
- [ ] Reduce cyclomatic complexity < 25
- [ ] Publish to npm
---
### Version 0.8.0 - Secret Detection 🔐
**Target:** Q1 2025
**Priority:** CRITICAL
Detect hardcoded secrets (API keys, tokens, credentials) using industry-standard Secretlint library.
**🎯 SecretDetector - NEW standalone detector:**
```typescript
// ❌ CRITICAL: Hardcoded AWS credentials
const AWS_KEY = "AKIA1234567890ABCDEF" // VIOLATION!
const AWS_SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" // VIOLATION!
// ❌ CRITICAL: Hardcoded GitHub token
const GITHUB_TOKEN = "ghp_1234567890abcdefghijklmnopqrstuv" // VIOLATION!
// ❌ CRITICAL: SSH Private Key in code
const privateKey = `-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...` // VIOLATION!
// ❌ CRITICAL: NPM token
//registry.npmjs.org/:_authToken=npm_abc123xyz // VIOLATION!
// ✅ GOOD: Use environment variables
const AWS_KEY = process.env.AWS_ACCESS_KEY_ID
const AWS_SECRET = process.env.AWS_SECRET_ACCESS_KEY
const GITHUB_TOKEN = process.env.GITHUB_TOKEN
```
**Planned Features:**
-**SecretDetector** - Standalone detector (separate from HardcodeDetector)
-**Secretlint Integration** - Industry-standard library (@secretlint/node)
-**350+ Secret Patterns** - AWS, GitHub, NPM, SSH, GCP, Slack, Basic Auth, etc.
-**CRITICAL Severity** - All secret violations marked as critical
-**Smart Suggestions** - Context-aware remediation per secret type
-**Clean Architecture** - New ISecretDetector interface, SecretViolation value object
-**CLI Integration** - New "🔐 Secrets" section in output
-**Parallel Execution** - Runs alongside existing detectors
**Secret Types Detected:**
- AWS Access Keys & Secret Keys
- GitHub Tokens (ghp_, github_pat_, gho_, etc.)
- NPM tokens in .npmrc and code
- SSH Private Keys
- GCP Service Account credentials
- Slack tokens (xoxb-, xoxp-, etc.)
- Basic Auth credentials
- JWT tokens
- Private encryption keys
**Architecture:**
```typescript
// New domain layer
interface ISecretDetector {
detectAll(code: string, filePath: string): Promise<SecretViolation[]>
}
class SecretViolation {
file: string
line: number
secretType: string // AWS, GitHub, NPM, etc.
message: string
severity: "critical"
suggestion: string // Context-aware guidance
}
// New infrastructure implementation
class SecretDetector implements ISecretDetector {
// Uses @secretlint/node internally
}
```
**Why Secretlint?**
- ✅ Actively maintained (updates weekly)
- ✅ TypeScript native
- ✅ Pluggable architecture
- ✅ Low false positives
- ✅ Industry standard
**Why NOT custom implementation?**
- ❌ No good npm library for magic numbers/strings
- ❌ Our HardcodeDetector is better than existing solutions
- ✅ Secretlint is perfect for secrets (don't reinvent the wheel)
- ✅ Two focused detectors better than one bloated detector
**Impact:**
Guardian will now catch critical security issues BEFORE they reach production, complementing existing magic number/string detection.
---
### Version 0.9.0 - Anemic Domain Model Detection 🩺
**Target:** Q2 2026
**Priority:** MEDIUM
@@ -342,7 +561,7 @@ class Order {
---
### Version 0.8.0 - Domain Event Usage Validation 📢
### Version 0.10.0 - Domain Event Usage Validation 📢
**Target:** Q2 2026
**Priority:** MEDIUM
@@ -381,7 +600,7 @@ class Order {
---
### Version 0.9.0 - Value Object Immutability Check 🔐
### Version 0.11.0 - Value Object Immutability Check 🔐
**Target:** Q2 2026
**Priority:** MEDIUM
@@ -424,7 +643,7 @@ class Email {
---
### Version 0.10.0 - Use Case Single Responsibility 🎯
### Version 0.12.0 - Use Case Single Responsibility 🎯
**Target:** Q2 2026
**Priority:** LOW
@@ -461,7 +680,7 @@ class SendWelcomeEmail {
---
### Version 0.11.0 - Interface Segregation Validation 🔌
### Version 0.13.0 - Interface Segregation Validation 🔌
**Target:** Q2 2026
**Priority:** LOW
@@ -506,7 +725,7 @@ interface IUserExporter {
---
### Version 0.12.0 - Port-Adapter Pattern Validation 🔌
### Version 0.14.0 - Port-Adapter Pattern Validation 🔌
**Target:** Q2 2026
**Priority:** MEDIUM
@@ -545,7 +764,7 @@ class TwilioAdapter implements INotificationPort {
---
### Version 0.13.0 - Configuration File Support ⚙️
### Version 0.15.0 - Configuration File Support ⚙️
**Target:** Q3 2026
**Priority:** MEDIUM
@@ -596,7 +815,7 @@ export default {
---
### Version 0.14.0 - Command Query Separation (CQS/CQRS) 📝
### Version 0.16.0 - Command Query Separation (CQS/CQRS) 📝
**Target:** Q3 2026
**Priority:** MEDIUM
@@ -657,7 +876,7 @@ class GetUser { // Query
---
### Version 0.15.0 - Factory Pattern Validation 🏭
### Version 0.17.0 - Factory Pattern Validation 🏭
**Target:** Q3 2026
**Priority:** LOW
@@ -740,7 +959,7 @@ class Order {
---
### Version 0.16.0 - Specification Pattern Detection 🔍
### Version 0.18.0 - Specification Pattern Detection 🔍
**Target:** Q3 2026
**Priority:** MEDIUM
@@ -812,7 +1031,7 @@ class ApproveOrder {
---
### Version 0.17.0 - Layered Service Anti-pattern Detection ⚠️
### Version 0.19.0 - Layered Service Anti-pattern Detection ⚠️
**Target:** Q3 2026
**Priority:** MEDIUM
@@ -889,7 +1108,7 @@ class OrderService {
---
### Version 0.18.0 - Bounded Context Leak Detection 🚧
### Version 0.20.0 - Bounded Context Leak Detection 🚧
**Target:** Q3 2026
**Priority:** LOW
@@ -954,7 +1173,7 @@ class ProductPriceChangedHandler {
---
### Version 0.19.0 - Transaction Script vs Domain Model Detection 📜
### Version 0.21.0 - Transaction Script vs Domain Model Detection 📜
**Target:** Q3 2026
**Priority:** LOW
@@ -1021,7 +1240,7 @@ class Order {
---
### Version 0.20.0 - Persistence Ignorance Validation 💾
### Version 0.22.0 - Persistence Ignorance Validation 💾
**Target:** Q3 2026
**Priority:** MEDIUM
@@ -1107,7 +1326,7 @@ class UserEntityMapper {
---
### Version 0.21.0 - Null Object Pattern Detection 🎭
### Version 0.23.0 - Null Object Pattern Detection 🎭
**Target:** Q3 2026
**Priority:** LOW
@@ -1189,7 +1408,7 @@ class ProcessOrder {
---
### Version 0.22.0 - Primitive Obsession in Methods 🔢
### Version 0.24.0 - Primitive Obsession in Methods 🔢
**Target:** Q3 2026
**Priority:** MEDIUM
@@ -1256,7 +1475,7 @@ class Order {
---
### Version 0.23.0 - Service Locator Anti-pattern 🔍
### Version 0.25.0 - Service Locator Anti-pattern 🔍
**Target:** Q4 2026
**Priority:** MEDIUM
@@ -1316,7 +1535,7 @@ class CreateUser {
---
### Version 0.24.0 - Double Dispatch Pattern Validation 🎯
### Version 0.26.0 - Double Dispatch Pattern Validation 🎯
**Target:** Q4 2026
**Priority:** LOW
@@ -1393,7 +1612,7 @@ class ShippingCostCalculator implements IOrderItemVisitor {
---
### Version 0.25.0 - Entity Identity Validation 🆔
### Version 0.27.0 - Entity Identity Validation 🆔
**Target:** Q4 2026
**Priority:** MEDIUM
@@ -1486,7 +1705,7 @@ class UserId {
---
### Version 0.26.0 - Saga Pattern Detection 🔄
### Version 0.28.0 - Saga Pattern Detection 🔄
**Target:** Q4 2026
**Priority:** LOW
@@ -1584,7 +1803,7 @@ abstract class SagaStep {
---
### Version 0.27.0 - Anti-Corruption Layer Detection 🛡️
### Version 0.29.0 - Anti-Corruption Layer Detection 🛡️
**Target:** Q4 2026
**Priority:** MEDIUM
@@ -1670,7 +1889,7 @@ interface IOrderSyncPort {
---
### Version 0.28.0 - Ubiquitous Language Validation 📖
### Version 0.30.0 - Ubiquitous Language Validation 📖
**Target:** Q4 2026
**Priority:** HIGH
@@ -1857,5 +2076,5 @@ Until we reach 1.0.0, minor version bumps (0.x.0) may include breaking changes a
---
**Last Updated:** 2025-11-24
**Current Version:** 0.6.0
**Last Updated:** 2025-11-25
**Current Version:** 0.7.7

View File

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

View File

@@ -11,18 +11,17 @@ import { IRepositoryPatternDetector } from "../../domain/services/RepositoryPatt
import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector"
import { SourceFile } from "../../domain/entities/SourceFile"
import { DependencyGraph } from "../../domain/entities/DependencyGraph"
import { ProjectPath } from "../../domain/value-objects/ProjectPath"
import { FileCollectionStep } from "./pipeline/FileCollectionStep"
import { ParsingStep } from "./pipeline/ParsingStep"
import { DetectionPipeline } from "./pipeline/DetectionPipeline"
import { ResultAggregator } from "./pipeline/ResultAggregator"
import {
ERROR_MESSAGES,
HARDCODE_TYPES,
LAYERS,
NAMING_VIOLATION_TYPES,
REGEX_PATTERNS,
REPOSITORY_VIOLATION_TYPES,
RULES,
SEVERITY_ORDER,
type SeverityLevel,
VIOLATION_SEVERITY_MAP,
} from "../../shared/constants"
export interface AnalyzeProjectRequest {
@@ -173,442 +172,74 @@ export interface ProjectMetrics {
/**
* Main use case for analyzing a project's codebase
* Orchestrates the analysis pipeline through focused components
*/
export class AnalyzeProject extends UseCase<
AnalyzeProjectRequest,
ResponseDto<AnalyzeProjectResponse>
> {
private readonly fileCollectionStep: FileCollectionStep
private readonly parsingStep: ParsingStep
private readonly detectionPipeline: DetectionPipeline
private readonly resultAggregator: ResultAggregator
constructor(
private readonly fileScanner: IFileScanner,
private readonly codeParser: ICodeParser,
private readonly hardcodeDetector: IHardcodeDetector,
private readonly namingConventionDetector: INamingConventionDetector,
private readonly frameworkLeakDetector: IFrameworkLeakDetector,
private readonly entityExposureDetector: IEntityExposureDetector,
private readonly dependencyDirectionDetector: IDependencyDirectionDetector,
private readonly repositoryPatternDetector: IRepositoryPatternDetector,
private readonly aggregateBoundaryDetector: IAggregateBoundaryDetector,
fileScanner: IFileScanner,
codeParser: ICodeParser,
hardcodeDetector: IHardcodeDetector,
namingConventionDetector: INamingConventionDetector,
frameworkLeakDetector: IFrameworkLeakDetector,
entityExposureDetector: IEntityExposureDetector,
dependencyDirectionDetector: IDependencyDirectionDetector,
repositoryPatternDetector: IRepositoryPatternDetector,
aggregateBoundaryDetector: IAggregateBoundaryDetector,
) {
super()
this.fileCollectionStep = new FileCollectionStep(fileScanner)
this.parsingStep = new ParsingStep(codeParser)
this.detectionPipeline = new DetectionPipeline(
hardcodeDetector,
namingConventionDetector,
frameworkLeakDetector,
entityExposureDetector,
dependencyDirectionDetector,
repositoryPatternDetector,
aggregateBoundaryDetector,
)
this.resultAggregator = new ResultAggregator()
}
public async execute(
request: AnalyzeProjectRequest,
): Promise<ResponseDto<AnalyzeProjectResponse>> {
try {
const filePaths = await this.fileScanner.scan({
const { sourceFiles } = await this.fileCollectionStep.execute({
rootDir: request.rootDir,
include: request.include,
exclude: request.exclude,
})
const sourceFiles: SourceFile[] = []
const dependencyGraph = new DependencyGraph()
let totalFunctions = 0
for (const filePath of filePaths) {
const content = await this.fileScanner.readFile(filePath)
const projectPath = ProjectPath.create(filePath, request.rootDir)
const imports = this.extractImports(content)
const exports = this.extractExports(content)
const sourceFile = new SourceFile(projectPath, content, imports, exports)
sourceFiles.push(sourceFile)
dependencyGraph.addFile(sourceFile)
if (projectPath.isTypeScript()) {
const tree = this.codeParser.parseTypeScript(content)
const functions = this.codeParser.extractFunctions(tree)
totalFunctions += functions.length
}
for (const imp of imports) {
dependencyGraph.addDependency(
projectPath.relative,
this.resolveImportPath(imp, filePath, request.rootDir),
)
}
}
const violations = this.sortBySeverity(this.detectViolations(sourceFiles))
const hardcodeViolations = this.sortBySeverity(this.detectHardcode(sourceFiles))
const circularDependencyViolations = this.sortBySeverity(
this.detectCircularDependencies(dependencyGraph),
)
const namingViolations = this.sortBySeverity(this.detectNamingConventions(sourceFiles))
const frameworkLeakViolations = this.sortBySeverity(
this.detectFrameworkLeaks(sourceFiles),
)
const entityExposureViolations = this.sortBySeverity(
this.detectEntityExposures(sourceFiles),
)
const dependencyDirectionViolations = this.sortBySeverity(
this.detectDependencyDirections(sourceFiles),
)
const repositoryPatternViolations = this.sortBySeverity(
this.detectRepositoryPatternViolations(sourceFiles),
)
const aggregateBoundaryViolations = this.sortBySeverity(
this.detectAggregateBoundaryViolations(sourceFiles),
)
const metrics = this.calculateMetrics(sourceFiles, totalFunctions, dependencyGraph)
return ResponseDto.ok({
files: sourceFiles,
dependencyGraph,
violations,
hardcodeViolations,
circularDependencyViolations,
namingViolations,
frameworkLeakViolations,
entityExposureViolations,
dependencyDirectionViolations,
repositoryPatternViolations,
aggregateBoundaryViolations,
metrics,
const { dependencyGraph, totalFunctions } = this.parsingStep.execute({
sourceFiles,
rootDir: request.rootDir,
})
const detectionResult = this.detectionPipeline.execute({
sourceFiles,
dependencyGraph,
})
const response = this.resultAggregator.execute({
sourceFiles,
dependencyGraph,
totalFunctions,
...detectionResult,
})
return ResponseDto.ok(response)
} catch (error) {
const errorMessage = `${ERROR_MESSAGES.FAILED_TO_ANALYZE}: ${error instanceof Error ? error.message : String(error)}`
return ResponseDto.fail(errorMessage)
}
}
private extractImports(content: string): string[] {
const imports: string[] = []
let match
while ((match = REGEX_PATTERNS.IMPORT_STATEMENT.exec(content)) !== null) {
imports.push(match[1])
}
return imports
}
private extractExports(content: string): string[] {
const exports: string[] = []
let match
while ((match = REGEX_PATTERNS.EXPORT_STATEMENT.exec(content)) !== null) {
exports.push(match[1])
}
return exports
}
private resolveImportPath(importPath: string, _currentFile: string, _rootDir: string): string {
if (importPath.startsWith(".")) {
return importPath
}
return importPath
}
private detectViolations(sourceFiles: SourceFile[]): ArchitectureViolation[] {
const violations: ArchitectureViolation[] = []
const layerRules: Record<string, string[]> = {
[LAYERS.DOMAIN]: [LAYERS.SHARED],
[LAYERS.APPLICATION]: [LAYERS.DOMAIN, LAYERS.SHARED],
[LAYERS.INFRASTRUCTURE]: [LAYERS.DOMAIN, LAYERS.APPLICATION, LAYERS.SHARED],
[LAYERS.SHARED]: [],
}
for (const file of sourceFiles) {
if (!file.layer) {
continue
}
const allowedLayers = layerRules[file.layer]
for (const imp of file.imports) {
const importedLayer = this.detectLayerFromImport(imp)
if (
importedLayer &&
importedLayer !== file.layer &&
!allowedLayers.includes(importedLayer)
) {
violations.push({
rule: RULES.CLEAN_ARCHITECTURE,
message: `Layer "${file.layer}" cannot import from "${importedLayer}"`,
file: file.path.relative,
severity: VIOLATION_SEVERITY_MAP.ARCHITECTURE,
})
}
}
}
return violations
}
private detectLayerFromImport(importPath: string): string | undefined {
const layers = Object.values(LAYERS)
for (const layer of layers) {
if (importPath.toLowerCase().includes(layer)) {
return layer
}
}
return undefined
}
private detectHardcode(sourceFiles: SourceFile[]): HardcodeViolation[] {
const violations: HardcodeViolation[] = []
for (const file of sourceFiles) {
const hardcodedValues = this.hardcodeDetector.detectAll(
file.content,
file.path.relative,
)
for (const hardcoded of hardcodedValues) {
violations.push({
rule: RULES.HARDCODED_VALUE,
type: hardcoded.type,
value: hardcoded.value,
file: file.path.relative,
line: hardcoded.line,
column: hardcoded.column,
context: hardcoded.context,
suggestion: {
constantName: hardcoded.suggestConstantName(),
location: hardcoded.suggestLocation(file.layer),
},
severity: VIOLATION_SEVERITY_MAP.HARDCODE,
})
}
}
return violations
}
private detectCircularDependencies(
dependencyGraph: DependencyGraph,
): CircularDependencyViolation[] {
const violations: CircularDependencyViolation[] = []
const cycles = dependencyGraph.findCycles()
for (const cycle of cycles) {
const cycleChain = [...cycle, cycle[0]].join(" → ")
violations.push({
rule: RULES.CIRCULAR_DEPENDENCY,
message: `Circular dependency detected: ${cycleChain}`,
cycle,
severity: VIOLATION_SEVERITY_MAP.CIRCULAR_DEPENDENCY,
})
}
return violations
}
private detectNamingConventions(sourceFiles: SourceFile[]): NamingConventionViolation[] {
const violations: NamingConventionViolation[] = []
for (const file of sourceFiles) {
const namingViolations = this.namingConventionDetector.detectViolations(
file.path.filename,
file.layer,
file.path.relative,
)
for (const violation of namingViolations) {
violations.push({
rule: RULES.NAMING_CONVENTION,
type: violation.violationType,
fileName: violation.fileName,
layer: violation.layer,
file: violation.filePath,
expected: violation.expected,
actual: violation.actual,
message: violation.getMessage(),
suggestion: violation.suggestion,
severity: VIOLATION_SEVERITY_MAP.NAMING_CONVENTION,
})
}
}
return violations
}
private detectFrameworkLeaks(sourceFiles: SourceFile[]): FrameworkLeakViolation[] {
const violations: FrameworkLeakViolation[] = []
for (const file of sourceFiles) {
const leaks = this.frameworkLeakDetector.detectLeaks(
file.imports,
file.path.relative,
file.layer,
)
for (const leak of leaks) {
violations.push({
rule: RULES.FRAMEWORK_LEAK,
packageName: leak.packageName,
category: leak.category,
categoryDescription: leak.getCategoryDescription(),
file: file.path.relative,
layer: leak.layer,
line: leak.line,
message: leak.getMessage(),
suggestion: leak.getSuggestion(),
severity: VIOLATION_SEVERITY_MAP.FRAMEWORK_LEAK,
})
}
}
return violations
}
private detectEntityExposures(sourceFiles: SourceFile[]): EntityExposureViolation[] {
const violations: EntityExposureViolation[] = []
for (const file of sourceFiles) {
const exposures = this.entityExposureDetector.detectExposures(
file.content,
file.path.relative,
file.layer,
)
for (const exposure of exposures) {
violations.push({
rule: RULES.ENTITY_EXPOSURE,
entityName: exposure.entityName,
returnType: exposure.returnType,
file: file.path.relative,
layer: exposure.layer,
line: exposure.line,
methodName: exposure.methodName,
message: exposure.getMessage(),
suggestion: exposure.getSuggestion(),
severity: VIOLATION_SEVERITY_MAP.ENTITY_EXPOSURE,
})
}
}
return violations
}
private detectDependencyDirections(sourceFiles: SourceFile[]): DependencyDirectionViolation[] {
const violations: DependencyDirectionViolation[] = []
for (const file of sourceFiles) {
const directionViolations = this.dependencyDirectionDetector.detectViolations(
file.content,
file.path.relative,
file.layer,
)
for (const violation of directionViolations) {
violations.push({
rule: RULES.DEPENDENCY_DIRECTION,
fromLayer: violation.fromLayer,
toLayer: violation.toLayer,
importPath: violation.importPath,
file: file.path.relative,
line: violation.line,
message: violation.getMessage(),
suggestion: violation.getSuggestion(),
severity: VIOLATION_SEVERITY_MAP.DEPENDENCY_DIRECTION,
})
}
}
return violations
}
private detectRepositoryPatternViolations(
sourceFiles: SourceFile[],
): RepositoryPatternViolation[] {
const violations: RepositoryPatternViolation[] = []
for (const file of sourceFiles) {
const patternViolations = this.repositoryPatternDetector.detectViolations(
file.content,
file.path.relative,
file.layer,
)
for (const violation of patternViolations) {
violations.push({
rule: RULES.REPOSITORY_PATTERN,
violationType: violation.violationType as
| typeof REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE
| typeof REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE
| typeof REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE
| typeof REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
file: file.path.relative,
layer: violation.layer,
line: violation.line,
details: violation.details,
message: violation.getMessage(),
suggestion: violation.getSuggestion(),
severity: VIOLATION_SEVERITY_MAP.REPOSITORY_PATTERN,
})
}
}
return violations
}
private detectAggregateBoundaryViolations(
sourceFiles: SourceFile[],
): AggregateBoundaryViolation[] {
const violations: AggregateBoundaryViolation[] = []
for (const file of sourceFiles) {
const boundaryViolations = this.aggregateBoundaryDetector.detectViolations(
file.content,
file.path.relative,
file.layer,
)
for (const violation of boundaryViolations) {
violations.push({
rule: RULES.AGGREGATE_BOUNDARY,
fromAggregate: violation.fromAggregate,
toAggregate: violation.toAggregate,
entityName: violation.entityName,
importPath: violation.importPath,
file: file.path.relative,
line: violation.line,
message: violation.getMessage(),
suggestion: violation.getSuggestion(),
severity: VIOLATION_SEVERITY_MAP.AGGREGATE_BOUNDARY,
})
}
}
return violations
}
private calculateMetrics(
sourceFiles: SourceFile[],
totalFunctions: number,
_dependencyGraph: DependencyGraph,
): ProjectMetrics {
const layerDistribution: Record<string, number> = {}
let totalImports = 0
for (const file of sourceFiles) {
if (file.layer) {
layerDistribution[file.layer] = (layerDistribution[file.layer] || 0) + 1
}
totalImports += file.imports.length
}
return {
totalFiles: sourceFiles.length,
totalFunctions,
totalImports,
layerDistribution,
}
}
private sortBySeverity<T extends { severity: SeverityLevel }>(violations: T[]): T[] {
return violations.sort((a, b) => {
return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
})
}
}

View File

@@ -0,0 +1,373 @@
import { IHardcodeDetector } from "../../../domain/services/IHardcodeDetector"
import { INamingConventionDetector } from "../../../domain/services/INamingConventionDetector"
import { IFrameworkLeakDetector } from "../../../domain/services/IFrameworkLeakDetector"
import { IEntityExposureDetector } from "../../../domain/services/IEntityExposureDetector"
import { IDependencyDirectionDetector } from "../../../domain/services/IDependencyDirectionDetector"
import { IRepositoryPatternDetector } from "../../../domain/services/RepositoryPatternDetectorService"
import { IAggregateBoundaryDetector } from "../../../domain/services/IAggregateBoundaryDetector"
import { SourceFile } from "../../../domain/entities/SourceFile"
import { DependencyGraph } from "../../../domain/entities/DependencyGraph"
import {
LAYERS,
REPOSITORY_VIOLATION_TYPES,
RULES,
SEVERITY_ORDER,
type SeverityLevel,
VIOLATION_SEVERITY_MAP,
} from "../../../shared/constants"
import type {
AggregateBoundaryViolation,
ArchitectureViolation,
CircularDependencyViolation,
DependencyDirectionViolation,
EntityExposureViolation,
FrameworkLeakViolation,
HardcodeViolation,
NamingConventionViolation,
RepositoryPatternViolation,
} from "../AnalyzeProject"
export interface DetectionRequest {
sourceFiles: SourceFile[]
dependencyGraph: DependencyGraph
}
export interface DetectionResult {
violations: ArchitectureViolation[]
hardcodeViolations: HardcodeViolation[]
circularDependencyViolations: CircularDependencyViolation[]
namingViolations: NamingConventionViolation[]
frameworkLeakViolations: FrameworkLeakViolation[]
entityExposureViolations: EntityExposureViolation[]
dependencyDirectionViolations: DependencyDirectionViolation[]
repositoryPatternViolations: RepositoryPatternViolation[]
aggregateBoundaryViolations: AggregateBoundaryViolation[]
}
/**
* Pipeline step responsible for running all detectors
*/
export class DetectionPipeline {
constructor(
private readonly hardcodeDetector: IHardcodeDetector,
private readonly namingConventionDetector: INamingConventionDetector,
private readonly frameworkLeakDetector: IFrameworkLeakDetector,
private readonly entityExposureDetector: IEntityExposureDetector,
private readonly dependencyDirectionDetector: IDependencyDirectionDetector,
private readonly repositoryPatternDetector: IRepositoryPatternDetector,
private readonly aggregateBoundaryDetector: IAggregateBoundaryDetector,
) {}
public execute(request: DetectionRequest): DetectionResult {
return {
violations: this.sortBySeverity(this.detectViolations(request.sourceFiles)),
hardcodeViolations: this.sortBySeverity(this.detectHardcode(request.sourceFiles)),
circularDependencyViolations: this.sortBySeverity(
this.detectCircularDependencies(request.dependencyGraph),
),
namingViolations: this.sortBySeverity(
this.detectNamingConventions(request.sourceFiles),
),
frameworkLeakViolations: this.sortBySeverity(
this.detectFrameworkLeaks(request.sourceFiles),
),
entityExposureViolations: this.sortBySeverity(
this.detectEntityExposures(request.sourceFiles),
),
dependencyDirectionViolations: this.sortBySeverity(
this.detectDependencyDirections(request.sourceFiles),
),
repositoryPatternViolations: this.sortBySeverity(
this.detectRepositoryPatternViolations(request.sourceFiles),
),
aggregateBoundaryViolations: this.sortBySeverity(
this.detectAggregateBoundaryViolations(request.sourceFiles),
),
}
}
private detectViolations(sourceFiles: SourceFile[]): ArchitectureViolation[] {
const violations: ArchitectureViolation[] = []
const layerRules: Record<string, string[]> = {
[LAYERS.DOMAIN]: [LAYERS.SHARED],
[LAYERS.APPLICATION]: [LAYERS.DOMAIN, LAYERS.SHARED],
[LAYERS.INFRASTRUCTURE]: [LAYERS.DOMAIN, LAYERS.APPLICATION, LAYERS.SHARED],
[LAYERS.SHARED]: [],
}
for (const file of sourceFiles) {
if (!file.layer) {
continue
}
const allowedLayers = layerRules[file.layer]
for (const imp of file.imports) {
const importedLayer = this.detectLayerFromImport(imp)
if (
importedLayer &&
importedLayer !== file.layer &&
!allowedLayers.includes(importedLayer)
) {
violations.push({
rule: RULES.CLEAN_ARCHITECTURE,
message: `Layer "${file.layer}" cannot import from "${importedLayer}"`,
file: file.path.relative,
severity: VIOLATION_SEVERITY_MAP.ARCHITECTURE,
})
}
}
}
return violations
}
private detectLayerFromImport(importPath: string): string | undefined {
const layers = Object.values(LAYERS)
for (const layer of layers) {
if (importPath.toLowerCase().includes(layer)) {
return layer
}
}
return undefined
}
private detectHardcode(sourceFiles: SourceFile[]): HardcodeViolation[] {
const violations: HardcodeViolation[] = []
for (const file of sourceFiles) {
const hardcodedValues = this.hardcodeDetector.detectAll(
file.content,
file.path.relative,
)
for (const hardcoded of hardcodedValues) {
violations.push({
rule: RULES.HARDCODED_VALUE,
type: hardcoded.type,
value: hardcoded.value,
file: file.path.relative,
line: hardcoded.line,
column: hardcoded.column,
context: hardcoded.context,
suggestion: {
constantName: hardcoded.suggestConstantName(),
location: hardcoded.suggestLocation(file.layer),
},
severity: VIOLATION_SEVERITY_MAP.HARDCODE,
})
}
}
return violations
}
private detectCircularDependencies(
dependencyGraph: DependencyGraph,
): CircularDependencyViolation[] {
const violations: CircularDependencyViolation[] = []
const cycles = dependencyGraph.findCycles()
for (const cycle of cycles) {
const cycleChain = [...cycle, cycle[0]].join(" → ")
violations.push({
rule: RULES.CIRCULAR_DEPENDENCY,
message: `Circular dependency detected: ${cycleChain}`,
cycle,
severity: VIOLATION_SEVERITY_MAP.CIRCULAR_DEPENDENCY,
})
}
return violations
}
private detectNamingConventions(sourceFiles: SourceFile[]): NamingConventionViolation[] {
const violations: NamingConventionViolation[] = []
for (const file of sourceFiles) {
const namingViolations = this.namingConventionDetector.detectViolations(
file.path.filename,
file.layer,
file.path.relative,
)
for (const violation of namingViolations) {
violations.push({
rule: RULES.NAMING_CONVENTION,
type: violation.violationType,
fileName: violation.fileName,
layer: violation.layer,
file: violation.filePath,
expected: violation.expected,
actual: violation.actual,
message: violation.getMessage(),
suggestion: violation.suggestion,
severity: VIOLATION_SEVERITY_MAP.NAMING_CONVENTION,
})
}
}
return violations
}
private detectFrameworkLeaks(sourceFiles: SourceFile[]): FrameworkLeakViolation[] {
const violations: FrameworkLeakViolation[] = []
for (const file of sourceFiles) {
const leaks = this.frameworkLeakDetector.detectLeaks(
file.imports,
file.path.relative,
file.layer,
)
for (const leak of leaks) {
violations.push({
rule: RULES.FRAMEWORK_LEAK,
packageName: leak.packageName,
category: leak.category,
categoryDescription: leak.getCategoryDescription(),
file: file.path.relative,
layer: leak.layer,
line: leak.line,
message: leak.getMessage(),
suggestion: leak.getSuggestion(),
severity: VIOLATION_SEVERITY_MAP.FRAMEWORK_LEAK,
})
}
}
return violations
}
private detectEntityExposures(sourceFiles: SourceFile[]): EntityExposureViolation[] {
const violations: EntityExposureViolation[] = []
for (const file of sourceFiles) {
const exposures = this.entityExposureDetector.detectExposures(
file.content,
file.path.relative,
file.layer,
)
for (const exposure of exposures) {
violations.push({
rule: RULES.ENTITY_EXPOSURE,
entityName: exposure.entityName,
returnType: exposure.returnType,
file: file.path.relative,
layer: exposure.layer,
line: exposure.line,
methodName: exposure.methodName,
message: exposure.getMessage(),
suggestion: exposure.getSuggestion(),
severity: VIOLATION_SEVERITY_MAP.ENTITY_EXPOSURE,
})
}
}
return violations
}
private detectDependencyDirections(sourceFiles: SourceFile[]): DependencyDirectionViolation[] {
const violations: DependencyDirectionViolation[] = []
for (const file of sourceFiles) {
const directionViolations = this.dependencyDirectionDetector.detectViolations(
file.content,
file.path.relative,
file.layer,
)
for (const violation of directionViolations) {
violations.push({
rule: RULES.DEPENDENCY_DIRECTION,
fromLayer: violation.fromLayer,
toLayer: violation.toLayer,
importPath: violation.importPath,
file: file.path.relative,
line: violation.line,
message: violation.getMessage(),
suggestion: violation.getSuggestion(),
severity: VIOLATION_SEVERITY_MAP.DEPENDENCY_DIRECTION,
})
}
}
return violations
}
private detectRepositoryPatternViolations(
sourceFiles: SourceFile[],
): RepositoryPatternViolation[] {
const violations: RepositoryPatternViolation[] = []
for (const file of sourceFiles) {
const patternViolations = this.repositoryPatternDetector.detectViolations(
file.content,
file.path.relative,
file.layer,
)
for (const violation of patternViolations) {
violations.push({
rule: RULES.REPOSITORY_PATTERN,
violationType: violation.violationType as
| typeof REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE
| typeof REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE
| typeof REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE
| typeof REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
file: file.path.relative,
layer: violation.layer,
line: violation.line,
details: violation.details,
message: violation.getMessage(),
suggestion: violation.getSuggestion(),
severity: VIOLATION_SEVERITY_MAP.REPOSITORY_PATTERN,
})
}
}
return violations
}
private detectAggregateBoundaryViolations(
sourceFiles: SourceFile[],
): AggregateBoundaryViolation[] {
const violations: AggregateBoundaryViolation[] = []
for (const file of sourceFiles) {
const boundaryViolations = this.aggregateBoundaryDetector.detectViolations(
file.content,
file.path.relative,
file.layer,
)
for (const violation of boundaryViolations) {
violations.push({
rule: RULES.AGGREGATE_BOUNDARY,
fromAggregate: violation.fromAggregate,
toAggregate: violation.toAggregate,
entityName: violation.entityName,
importPath: violation.importPath,
file: file.path.relative,
line: violation.line,
message: violation.getMessage(),
suggestion: violation.getSuggestion(),
severity: VIOLATION_SEVERITY_MAP.AGGREGATE_BOUNDARY,
})
}
}
return violations
}
private sortBySeverity<T extends { severity: SeverityLevel }>(violations: T[]): T[] {
return violations.sort((a, b) => {
return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
})
}
}

View File

@@ -0,0 +1,66 @@
import { IFileScanner } from "../../../domain/services/IFileScanner"
import { SourceFile } from "../../../domain/entities/SourceFile"
import { ProjectPath } from "../../../domain/value-objects/ProjectPath"
import { REGEX_PATTERNS } from "../../../shared/constants"
export interface FileCollectionRequest {
rootDir: string
include?: string[]
exclude?: string[]
}
export interface FileCollectionResult {
sourceFiles: SourceFile[]
}
/**
* Pipeline step responsible for file collection and basic parsing
*/
export class FileCollectionStep {
constructor(private readonly fileScanner: IFileScanner) {}
public async execute(request: FileCollectionRequest): Promise<FileCollectionResult> {
const filePaths = await this.fileScanner.scan({
rootDir: request.rootDir,
include: request.include,
exclude: request.exclude,
})
const sourceFiles: SourceFile[] = []
for (const filePath of filePaths) {
const content = await this.fileScanner.readFile(filePath)
const projectPath = ProjectPath.create(filePath, request.rootDir)
const imports = this.extractImports(content)
const exports = this.extractExports(content)
const sourceFile = new SourceFile(projectPath, content, imports, exports)
sourceFiles.push(sourceFile)
}
return { sourceFiles }
}
private extractImports(content: string): string[] {
const imports: string[] = []
let match
while ((match = REGEX_PATTERNS.IMPORT_STATEMENT.exec(content)) !== null) {
imports.push(match[1])
}
return imports
}
private extractExports(content: string): string[] {
const exports: string[] = []
let match
while ((match = REGEX_PATTERNS.EXPORT_STATEMENT.exec(content)) !== null) {
exports.push(match[1])
}
return exports
}
}

View File

@@ -0,0 +1,51 @@
import { ICodeParser } from "../../../domain/services/ICodeParser"
import { SourceFile } from "../../../domain/entities/SourceFile"
import { DependencyGraph } from "../../../domain/entities/DependencyGraph"
export interface ParsingRequest {
sourceFiles: SourceFile[]
rootDir: string
}
export interface ParsingResult {
dependencyGraph: DependencyGraph
totalFunctions: number
}
/**
* Pipeline step responsible for AST parsing and dependency graph construction
*/
export class ParsingStep {
constructor(private readonly codeParser: ICodeParser) {}
public execute(request: ParsingRequest): ParsingResult {
const dependencyGraph = new DependencyGraph()
let totalFunctions = 0
for (const sourceFile of request.sourceFiles) {
dependencyGraph.addFile(sourceFile)
if (sourceFile.path.isTypeScript()) {
const tree = this.codeParser.parseTypeScript(sourceFile.content)
const functions = this.codeParser.extractFunctions(tree)
totalFunctions += functions.length
}
for (const imp of sourceFile.imports) {
dependencyGraph.addDependency(
sourceFile.path.relative,
this.resolveImportPath(imp, sourceFile.path.relative, request.rootDir),
)
}
}
return { dependencyGraph, totalFunctions }
}
private resolveImportPath(importPath: string, _currentFile: string, _rootDir: string): string {
if (importPath.startsWith(".")) {
return importPath
}
return importPath
}
}

View File

@@ -0,0 +1,81 @@
import { SourceFile } from "../../../domain/entities/SourceFile"
import { DependencyGraph } from "../../../domain/entities/DependencyGraph"
import type {
AggregateBoundaryViolation,
AnalyzeProjectResponse,
ArchitectureViolation,
CircularDependencyViolation,
DependencyDirectionViolation,
EntityExposureViolation,
FrameworkLeakViolation,
HardcodeViolation,
NamingConventionViolation,
ProjectMetrics,
RepositoryPatternViolation,
} from "../AnalyzeProject"
export interface AggregationRequest {
sourceFiles: SourceFile[]
dependencyGraph: DependencyGraph
totalFunctions: number
violations: ArchitectureViolation[]
hardcodeViolations: HardcodeViolation[]
circularDependencyViolations: CircularDependencyViolation[]
namingViolations: NamingConventionViolation[]
frameworkLeakViolations: FrameworkLeakViolation[]
entityExposureViolations: EntityExposureViolation[]
dependencyDirectionViolations: DependencyDirectionViolation[]
repositoryPatternViolations: RepositoryPatternViolation[]
aggregateBoundaryViolations: AggregateBoundaryViolation[]
}
/**
* Pipeline step responsible for building final response DTO
*/
export class ResultAggregator {
public execute(request: AggregationRequest): AnalyzeProjectResponse {
const metrics = this.calculateMetrics(
request.sourceFiles,
request.totalFunctions,
request.dependencyGraph,
)
return {
files: request.sourceFiles,
dependencyGraph: request.dependencyGraph,
violations: request.violations,
hardcodeViolations: request.hardcodeViolations,
circularDependencyViolations: request.circularDependencyViolations,
namingViolations: request.namingViolations,
frameworkLeakViolations: request.frameworkLeakViolations,
entityExposureViolations: request.entityExposureViolations,
dependencyDirectionViolations: request.dependencyDirectionViolations,
repositoryPatternViolations: request.repositoryPatternViolations,
aggregateBoundaryViolations: request.aggregateBoundaryViolations,
metrics,
}
}
private calculateMetrics(
sourceFiles: SourceFile[],
totalFunctions: number,
_dependencyGraph: DependencyGraph,
): ProjectMetrics {
const layerDistribution: Record<string, number> = {}
let totalImports = 0
for (const file of sourceFiles) {
if (file.layer) {
layerDistribution[file.layer] = (layerDistribution[file.layer] || 0) + 1
}
totalImports += file.imports.length
}
return {
totalFiles: sourceFiles.length,
totalFunctions,
totalImports,
layerDistribution,
}
}
}

View File

@@ -150,4 +150,30 @@ export const CLI_HELP_TEXT = {
FIX_REPOSITORY:
" Repository pattern → Create IUserRepository in domain, implement in infra\n\n",
FOOTER: "Each violation includes a 💡 Suggestion with specific fix instructions.\n",
AI_AGENT_HEADER: "AI AGENT INSTRUCTIONS:\n",
AI_AGENT_INTRO:
" When an AI coding assistant (Claude, Copilot, Cursor, etc.) uses Guardian:\n\n",
AI_AGENT_STEP1: " STEP 1: Run initial scan\n",
AI_AGENT_STEP1_CMD: " $ guardian check ./src --only-critical --limit 5\n\n",
AI_AGENT_STEP2: " STEP 2: For each violation in output:\n",
AI_AGENT_STEP2_DETAIL:
" - Read the file at reported location (file:line:column)\n" +
" - Apply the 💡 Suggestion provided\n" +
" - The suggestion contains exact fix instructions\n\n",
AI_AGENT_STEP3: " STEP 3: After fixing, verify:\n",
AI_AGENT_STEP3_CMD: " $ guardian check ./src --only-critical\n\n",
AI_AGENT_STEP4: " STEP 4: Expand scope progressively:\n",
AI_AGENT_STEP4_CMDS:
" $ guardian check ./src --min-severity high # Fix HIGH issues\n" +
" $ guardian check ./src --min-severity medium # Fix MEDIUM issues\n" +
" $ guardian check ./src # Full scan\n\n",
AI_AGENT_OUTPUT: " OUTPUT FORMAT (parse this):\n",
AI_AGENT_OUTPUT_DETAIL:
" <index>. <file>:<line>:<column>\n" +
" Severity: <emoji> <LEVEL>\n" +
" Type: <violation-type>\n" +
" Value: <problematic-value>\n" +
" Context: <code-snippet>\n" +
" 💡 Suggestion: <exact-fix-instruction>\n\n",
AI_AGENT_PRIORITY: " PRIORITY ORDER: CRITICAL → HIGH → MEDIUM → LOW\n\n",
} as const

View File

@@ -0,0 +1,190 @@
import { SEVERITY_LEVELS, type SeverityLevel } from "../../shared/constants"
import type {
AggregateBoundaryViolation,
ArchitectureViolation,
CircularDependencyViolation,
DependencyDirectionViolation,
EntityExposureViolation,
FrameworkLeakViolation,
HardcodeViolation,
NamingConventionViolation,
RepositoryPatternViolation,
} from "../../application/use-cases/AnalyzeProject"
import { SEVERITY_DISPLAY_LABELS, SEVERITY_SECTION_HEADERS } from "../constants"
import { ViolationGrouper } from "../groupers/ViolationGrouper"
const SEVERITY_LABELS: Record<SeverityLevel, string> = {
[SEVERITY_LEVELS.CRITICAL]: SEVERITY_DISPLAY_LABELS.CRITICAL,
[SEVERITY_LEVELS.HIGH]: SEVERITY_DISPLAY_LABELS.HIGH,
[SEVERITY_LEVELS.MEDIUM]: SEVERITY_DISPLAY_LABELS.MEDIUM,
[SEVERITY_LEVELS.LOW]: SEVERITY_DISPLAY_LABELS.LOW,
}
const SEVERITY_HEADER: Record<SeverityLevel, string> = {
[SEVERITY_LEVELS.CRITICAL]: SEVERITY_SECTION_HEADERS.CRITICAL,
[SEVERITY_LEVELS.HIGH]: SEVERITY_SECTION_HEADERS.HIGH,
[SEVERITY_LEVELS.MEDIUM]: SEVERITY_SECTION_HEADERS.MEDIUM,
[SEVERITY_LEVELS.LOW]: SEVERITY_SECTION_HEADERS.LOW,
}
export class OutputFormatter {
private readonly grouper = new ViolationGrouper()
displayGroupedViolations<T extends { severity: SeverityLevel }>(
violations: T[],
displayFn: (v: T, index: number) => void,
limit?: number,
): void {
const grouped = this.grouper.groupBySeverity(violations)
const severities: SeverityLevel[] = [
SEVERITY_LEVELS.CRITICAL,
SEVERITY_LEVELS.HIGH,
SEVERITY_LEVELS.MEDIUM,
SEVERITY_LEVELS.LOW,
]
let totalDisplayed = 0
const totalAvailable = violations.length
for (const severity of severities) {
const items = grouped.get(severity)
if (items && items.length > 0) {
console.warn(SEVERITY_HEADER[severity])
console.warn(`Found ${String(items.length)} issue(s)\n`)
const itemsToDisplay =
limit !== undefined ? items.slice(0, limit - totalDisplayed) : items
itemsToDisplay.forEach((item, index) => {
displayFn(item, totalDisplayed + index)
})
totalDisplayed += itemsToDisplay.length
if (limit !== undefined && totalDisplayed >= limit) {
break
}
}
}
if (limit !== undefined && totalAvailable > limit) {
console.warn(
`\n⚠ Showing first ${String(limit)} of ${String(totalAvailable)} issues (use --limit to adjust)\n`,
)
}
}
formatArchitectureViolation(v: ArchitectureViolation, index: number): void {
console.log(`${String(index + 1)}. ${v.file}`)
console.log(` Severity: ${SEVERITY_LABELS[v.severity]}`)
console.log(` Rule: ${v.rule}`)
console.log(` ${v.message}`)
console.log("")
}
formatCircularDependency(cd: CircularDependencyViolation, index: number): void {
console.log(`${String(index + 1)}. ${cd.message}`)
console.log(` Severity: ${SEVERITY_LABELS[cd.severity]}`)
console.log(" Cycle path:")
cd.cycle.forEach((file, i) => {
console.log(` ${String(i + 1)}. ${file}`)
})
console.log(` ${String(cd.cycle.length + 1)}. ${cd.cycle[0]} (back to start)`)
console.log("")
}
formatNamingViolation(nc: NamingConventionViolation, index: number): void {
console.log(`${String(index + 1)}. ${nc.file}`)
console.log(` Severity: ${SEVERITY_LABELS[nc.severity]}`)
console.log(` File: ${nc.fileName}`)
console.log(` Layer: ${nc.layer}`)
console.log(` Type: ${nc.type}`)
console.log(` Message: ${nc.message}`)
if (nc.suggestion) {
console.log(` 💡 Suggestion: ${nc.suggestion}`)
}
console.log("")
}
formatFrameworkLeak(fl: FrameworkLeakViolation, index: number): void {
console.log(`${String(index + 1)}. ${fl.file}`)
console.log(` Severity: ${SEVERITY_LABELS[fl.severity]}`)
console.log(` Package: ${fl.packageName}`)
console.log(` Category: ${fl.categoryDescription}`)
console.log(` Layer: ${fl.layer}`)
console.log(` Rule: ${fl.rule}`)
console.log(` ${fl.message}`)
console.log(` 💡 Suggestion: ${fl.suggestion}`)
console.log("")
}
formatEntityExposure(ee: EntityExposureViolation, index: number): void {
const location = ee.line ? `${ee.file}:${String(ee.line)}` : ee.file
console.log(`${String(index + 1)}. ${location}`)
console.log(` Severity: ${SEVERITY_LABELS[ee.severity]}`)
console.log(` Entity: ${ee.entityName}`)
console.log(` Return Type: ${ee.returnType}`)
if (ee.methodName) {
console.log(` Method: ${ee.methodName}`)
}
console.log(` Layer: ${ee.layer}`)
console.log(` Rule: ${ee.rule}`)
console.log(` ${ee.message}`)
console.log(" 💡 Suggestion:")
ee.suggestion.split("\n").forEach((line) => {
if (line.trim()) {
console.log(` ${line}`)
}
})
console.log("")
}
formatDependencyDirection(dd: DependencyDirectionViolation, index: number): void {
console.log(`${String(index + 1)}. ${dd.file}`)
console.log(` Severity: ${SEVERITY_LABELS[dd.severity]}`)
console.log(` From Layer: ${dd.fromLayer}`)
console.log(` To Layer: ${dd.toLayer}`)
console.log(` Import: ${dd.importPath}`)
console.log(` ${dd.message}`)
console.log(` 💡 Suggestion: ${dd.suggestion}`)
console.log("")
}
formatRepositoryPattern(rp: RepositoryPatternViolation, index: number): void {
console.log(`${String(index + 1)}. ${rp.file}`)
console.log(` Severity: ${SEVERITY_LABELS[rp.severity]}`)
console.log(` Layer: ${rp.layer}`)
console.log(` Type: ${rp.violationType}`)
console.log(` Details: ${rp.details}`)
console.log(` ${rp.message}`)
console.log(` 💡 Suggestion: ${rp.suggestion}`)
console.log("")
}
formatAggregateBoundary(ab: AggregateBoundaryViolation, index: number): void {
const location = ab.line ? `${ab.file}:${String(ab.line)}` : ab.file
console.log(`${String(index + 1)}. ${location}`)
console.log(` Severity: ${SEVERITY_LABELS[ab.severity]}`)
console.log(` From Aggregate: ${ab.fromAggregate}`)
console.log(` To Aggregate: ${ab.toAggregate}`)
console.log(` Entity: ${ab.entityName}`)
console.log(` Import: ${ab.importPath}`)
console.log(` ${ab.message}`)
console.log(" 💡 Suggestion:")
ab.suggestion.split("\n").forEach((line) => {
if (line.trim()) {
console.log(` ${line}`)
}
})
console.log("")
}
formatHardcodeViolation(hc: HardcodeViolation, index: number): void {
console.log(`${String(index + 1)}. ${hc.file}:${String(hc.line)}:${String(hc.column)}`)
console.log(` Severity: ${SEVERITY_LABELS[hc.severity]}`)
console.log(` Type: ${hc.type}`)
console.log(` Value: ${JSON.stringify(hc.value)}`)
console.log(` Context: ${hc.context.trim()}`)
console.log(` 💡 Suggested: ${hc.suggestion.constantName}`)
console.log(` 📁 Location: ${hc.suggestion.location}`)
console.log("")
}
}

View File

@@ -0,0 +1,59 @@
import { CLI_LABELS, CLI_MESSAGES } from "../constants"
interface ProjectMetrics {
totalFiles: number
totalFunctions: number
totalImports: number
layerDistribution: Record<string, number>
}
export class StatisticsFormatter {
displayMetrics(metrics: ProjectMetrics): void {
console.log(CLI_MESSAGES.METRICS_HEADER)
console.log(` ${CLI_LABELS.FILES_ANALYZED} ${String(metrics.totalFiles)}`)
console.log(` ${CLI_LABELS.TOTAL_FUNCTIONS} ${String(metrics.totalFunctions)}`)
console.log(` ${CLI_LABELS.TOTAL_IMPORTS} ${String(metrics.totalImports)}`)
if (Object.keys(metrics.layerDistribution).length > 0) {
console.log(CLI_MESSAGES.LAYER_DISTRIBUTION_HEADER)
for (const [layer, count] of Object.entries(metrics.layerDistribution)) {
console.log(` ${layer}: ${String(count)} ${CLI_LABELS.FILES}`)
}
}
}
displaySummary(totalIssues: number, verbose: boolean): void {
if (totalIssues === 0) {
console.log(CLI_MESSAGES.NO_ISSUES)
process.exit(0)
} else {
console.log(
`${CLI_MESSAGES.ISSUES_TOTAL} ${String(totalIssues)} ${CLI_LABELS.ISSUES_TOTAL}`,
)
console.log(CLI_MESSAGES.TIP)
if (verbose) {
console.log(CLI_MESSAGES.HELP_FOOTER)
}
process.exit(1)
}
}
displaySeverityFilterMessage(onlyCritical: boolean, minSeverity?: string): void {
if (onlyCritical) {
console.log("\n🔴 Filtering: Showing only CRITICAL severity issues\n")
} else if (minSeverity) {
console.log(
`\n⚠ Filtering: Showing ${minSeverity.toUpperCase()} severity and above\n`,
)
}
}
displayError(message: string): void {
console.error(`\n❌ ${CLI_MESSAGES.ERROR_PREFIX}`)
console.error(message)
console.error("")
process.exit(1)
}
}

View File

@@ -0,0 +1,29 @@
import { SEVERITY_ORDER, type SeverityLevel } from "../../shared/constants"
export class ViolationGrouper {
groupBySeverity<T extends { severity: SeverityLevel }>(
violations: T[],
): Map<SeverityLevel, T[]> {
const grouped = new Map<SeverityLevel, T[]>()
for (const violation of violations) {
const existing = grouped.get(violation.severity) ?? []
existing.push(violation)
grouped.set(violation.severity, existing)
}
return grouped
}
filterBySeverity<T extends { severity: SeverityLevel }>(
violations: T[],
minSeverity?: SeverityLevel,
): T[] {
if (!minSeverity) {
return violations
}
const minSeverityOrder = SEVERITY_ORDER[minSeverity]
return violations.filter((v) => SEVERITY_ORDER[v.severity] <= minSeverityOrder)
}
}

View File

@@ -11,92 +11,11 @@ import {
CLI_MESSAGES,
CLI_OPTIONS,
DEFAULT_EXCLUDES,
SEVERITY_DISPLAY_LABELS,
SEVERITY_SECTION_HEADERS,
} from "./constants"
import { SEVERITY_LEVELS, SEVERITY_ORDER, type SeverityLevel } from "../shared/constants"
const SEVERITY_LABELS: Record<SeverityLevel, string> = {
[SEVERITY_LEVELS.CRITICAL]: SEVERITY_DISPLAY_LABELS.CRITICAL,
[SEVERITY_LEVELS.HIGH]: SEVERITY_DISPLAY_LABELS.HIGH,
[SEVERITY_LEVELS.MEDIUM]: SEVERITY_DISPLAY_LABELS.MEDIUM,
[SEVERITY_LEVELS.LOW]: SEVERITY_DISPLAY_LABELS.LOW,
}
const SEVERITY_HEADER: Record<SeverityLevel, string> = {
[SEVERITY_LEVELS.CRITICAL]: SEVERITY_SECTION_HEADERS.CRITICAL,
[SEVERITY_LEVELS.HIGH]: SEVERITY_SECTION_HEADERS.HIGH,
[SEVERITY_LEVELS.MEDIUM]: SEVERITY_SECTION_HEADERS.MEDIUM,
[SEVERITY_LEVELS.LOW]: SEVERITY_SECTION_HEADERS.LOW,
}
function groupBySeverity<T extends { severity: SeverityLevel }>(
violations: T[],
): Map<SeverityLevel, T[]> {
const grouped = new Map<SeverityLevel, T[]>()
for (const violation of violations) {
const existing = grouped.get(violation.severity) ?? []
existing.push(violation)
grouped.set(violation.severity, existing)
}
return grouped
}
function filterBySeverity<T extends { severity: SeverityLevel }>(
violations: T[],
minSeverity?: SeverityLevel,
): T[] {
if (!minSeverity) {
return violations
}
const minSeverityOrder = SEVERITY_ORDER[minSeverity]
return violations.filter((v) => SEVERITY_ORDER[v.severity] <= minSeverityOrder)
}
function displayGroupedViolations<T extends { severity: SeverityLevel }>(
violations: T[],
displayFn: (v: T, index: number) => void,
limit?: number,
): void {
const grouped = groupBySeverity(violations)
const severities: SeverityLevel[] = [
SEVERITY_LEVELS.CRITICAL,
SEVERITY_LEVELS.HIGH,
SEVERITY_LEVELS.MEDIUM,
SEVERITY_LEVELS.LOW,
]
let totalDisplayed = 0
const totalAvailable = violations.length
for (const severity of severities) {
const items = grouped.get(severity)
if (items && items.length > 0) {
console.warn(SEVERITY_HEADER[severity])
console.warn(`Found ${String(items.length)} issue(s)\n`)
const itemsToDisplay =
limit !== undefined ? items.slice(0, limit - totalDisplayed) : items
itemsToDisplay.forEach((item, index) => {
displayFn(item, totalDisplayed + index)
})
totalDisplayed += itemsToDisplay.length
if (limit !== undefined && totalDisplayed >= limit) {
break
}
}
}
if (limit !== undefined && totalAvailable > limit) {
console.warn(
`\n⚠ Showing first ${String(limit)} of ${String(totalAvailable)} issues (use --limit to adjust)\n`,
)
}
}
import { SEVERITY_LEVELS, type SeverityLevel } from "../shared/constants"
import { ViolationGrouper } from "./groupers/ViolationGrouper"
import { OutputFormatter } from "./formatters/OutputFormatter"
import { StatisticsFormatter } from "./formatters/StatisticsFormatter"
const program = new Command()
@@ -122,7 +41,20 @@ program
CLI_HELP_TEXT.FIX_ENTITY +
CLI_HELP_TEXT.FIX_DEPENDENCY +
CLI_HELP_TEXT.FIX_REPOSITORY +
CLI_HELP_TEXT.FOOTER,
CLI_HELP_TEXT.FOOTER +
CLI_HELP_TEXT.AI_AGENT_HEADER +
CLI_HELP_TEXT.AI_AGENT_INTRO +
CLI_HELP_TEXT.AI_AGENT_STEP1 +
CLI_HELP_TEXT.AI_AGENT_STEP1_CMD +
CLI_HELP_TEXT.AI_AGENT_STEP2 +
CLI_HELP_TEXT.AI_AGENT_STEP2_DETAIL +
CLI_HELP_TEXT.AI_AGENT_STEP3 +
CLI_HELP_TEXT.AI_AGENT_STEP3_CMD +
CLI_HELP_TEXT.AI_AGENT_STEP4 +
CLI_HELP_TEXT.AI_AGENT_STEP4_CMDS +
CLI_HELP_TEXT.AI_AGENT_OUTPUT +
CLI_HELP_TEXT.AI_AGENT_OUTPUT_DETAIL +
CLI_HELP_TEXT.AI_AGENT_PRIORITY,
)
program
@@ -137,6 +69,10 @@ program
.option(CLI_OPTIONS.ONLY_CRITICAL, CLI_DESCRIPTIONS.ONLY_CRITICAL_OPTION, false)
.option(CLI_OPTIONS.LIMIT, CLI_DESCRIPTIONS.LIMIT_OPTION)
.action(async (path: string, options) => {
const grouper = new ViolationGrouper()
const outputFormatter = new OutputFormatter()
const statsFormatter = new StatisticsFormatter()
try {
console.log(CLI_MESSAGES.ANALYZING)
@@ -169,270 +105,159 @@ program
: undefined
if (minSeverity) {
violations = filterBySeverity(violations, minSeverity)
hardcodeViolations = filterBySeverity(hardcodeViolations, minSeverity)
circularDependencyViolations = filterBySeverity(
violations = grouper.filterBySeverity(violations, minSeverity)
hardcodeViolations = grouper.filterBySeverity(hardcodeViolations, minSeverity)
circularDependencyViolations = grouper.filterBySeverity(
circularDependencyViolations,
minSeverity,
)
namingViolations = filterBySeverity(namingViolations, minSeverity)
frameworkLeakViolations = filterBySeverity(frameworkLeakViolations, minSeverity)
entityExposureViolations = filterBySeverity(entityExposureViolations, minSeverity)
dependencyDirectionViolations = filterBySeverity(
namingViolations = grouper.filterBySeverity(namingViolations, minSeverity)
frameworkLeakViolations = grouper.filterBySeverity(
frameworkLeakViolations,
minSeverity,
)
entityExposureViolations = grouper.filterBySeverity(
entityExposureViolations,
minSeverity,
)
dependencyDirectionViolations = grouper.filterBySeverity(
dependencyDirectionViolations,
minSeverity,
)
repositoryPatternViolations = filterBySeverity(
repositoryPatternViolations = grouper.filterBySeverity(
repositoryPatternViolations,
minSeverity,
)
aggregateBoundaryViolations = filterBySeverity(
aggregateBoundaryViolations = grouper.filterBySeverity(
aggregateBoundaryViolations,
minSeverity,
)
if (options.onlyCritical) {
console.log("\n🔴 Filtering: Showing only CRITICAL severity issues\n")
} else {
console.log(
`\n⚠ Filtering: Showing ${minSeverity.toUpperCase()} severity and above\n`,
)
}
statsFormatter.displaySeverityFilterMessage(
options.onlyCritical,
options.minSeverity,
)
}
// Display metrics
console.log(CLI_MESSAGES.METRICS_HEADER)
console.log(` ${CLI_LABELS.FILES_ANALYZED} ${String(metrics.totalFiles)}`)
console.log(` ${CLI_LABELS.TOTAL_FUNCTIONS} ${String(metrics.totalFunctions)}`)
console.log(` ${CLI_LABELS.TOTAL_IMPORTS} ${String(metrics.totalImports)}`)
statsFormatter.displayMetrics(metrics)
if (Object.keys(metrics.layerDistribution).length > 0) {
console.log(CLI_MESSAGES.LAYER_DISTRIBUTION_HEADER)
for (const [layer, count] of Object.entries(metrics.layerDistribution)) {
console.log(` ${layer}: ${String(count)} ${CLI_LABELS.FILES}`)
}
}
// Architecture violations
if (options.architecture && violations.length > 0) {
console.log(
`\n${CLI_MESSAGES.VIOLATIONS_HEADER} ${String(violations.length)} ${CLI_LABELS.ARCHITECTURE_VIOLATIONS}`,
)
displayGroupedViolations(
outputFormatter.displayGroupedViolations(
violations,
(v, index) => {
console.log(`${String(index + 1)}. ${v.file}`)
console.log(` Severity: ${SEVERITY_LABELS[v.severity]}`)
console.log(` Rule: ${v.rule}`)
console.log(` ${v.message}`)
console.log("")
(v, i) => {
outputFormatter.formatArchitectureViolation(v, i)
},
limit,
)
}
// Circular dependency violations
if (options.architecture && circularDependencyViolations.length > 0) {
console.log(
`\n${CLI_MESSAGES.CIRCULAR_DEPS_HEADER} ${String(circularDependencyViolations.length)} ${CLI_LABELS.CIRCULAR_DEPENDENCIES}`,
)
displayGroupedViolations(
outputFormatter.displayGroupedViolations(
circularDependencyViolations,
(cd, index) => {
console.log(`${String(index + 1)}. ${cd.message}`)
console.log(` Severity: ${SEVERITY_LABELS[cd.severity]}`)
console.log(" Cycle path:")
cd.cycle.forEach((file, i) => {
console.log(` ${String(i + 1)}. ${file}`)
})
console.log(
` ${String(cd.cycle.length + 1)}. ${cd.cycle[0]} (back to start)`,
)
console.log("")
(cd, i) => {
outputFormatter.formatCircularDependency(cd, i)
},
limit,
)
}
// Naming convention violations
if (options.architecture && namingViolations.length > 0) {
console.log(
`\n${CLI_MESSAGES.NAMING_VIOLATIONS_HEADER} ${String(namingViolations.length)} ${CLI_LABELS.NAMING_VIOLATIONS}`,
)
displayGroupedViolations(
outputFormatter.displayGroupedViolations(
namingViolations,
(nc, index) => {
console.log(`${String(index + 1)}. ${nc.file}`)
console.log(` Severity: ${SEVERITY_LABELS[nc.severity]}`)
console.log(` File: ${nc.fileName}`)
console.log(` Layer: ${nc.layer}`)
console.log(` Type: ${nc.type}`)
console.log(` Message: ${nc.message}`)
if (nc.suggestion) {
console.log(` 💡 Suggestion: ${nc.suggestion}`)
}
console.log("")
(nc, i) => {
outputFormatter.formatNamingViolation(nc, i)
},
limit,
)
}
// Framework leak violations
if (options.architecture && frameworkLeakViolations.length > 0) {
console.log(
`\n🏗 Found ${String(frameworkLeakViolations.length)} framework leak(s)`,
)
displayGroupedViolations(
outputFormatter.displayGroupedViolations(
frameworkLeakViolations,
(fl, index) => {
console.log(`${String(index + 1)}. ${fl.file}`)
console.log(` Severity: ${SEVERITY_LABELS[fl.severity]}`)
console.log(` Package: ${fl.packageName}`)
console.log(` Category: ${fl.categoryDescription}`)
console.log(` Layer: ${fl.layer}`)
console.log(` Rule: ${fl.rule}`)
console.log(` ${fl.message}`)
console.log(` 💡 Suggestion: ${fl.suggestion}`)
console.log("")
(fl, i) => {
outputFormatter.formatFrameworkLeak(fl, i)
},
limit,
)
}
// Entity exposure violations
if (options.architecture && entityExposureViolations.length > 0) {
console.log(
`\n🎭 Found ${String(entityExposureViolations.length)} entity exposure(s)`,
)
displayGroupedViolations(
outputFormatter.displayGroupedViolations(
entityExposureViolations,
(ee, index) => {
const location = ee.line ? `${ee.file}:${String(ee.line)}` : ee.file
console.log(`${String(index + 1)}. ${location}`)
console.log(` Severity: ${SEVERITY_LABELS[ee.severity]}`)
console.log(` Entity: ${ee.entityName}`)
console.log(` Return Type: ${ee.returnType}`)
if (ee.methodName) {
console.log(` Method: ${ee.methodName}`)
}
console.log(` Layer: ${ee.layer}`)
console.log(` Rule: ${ee.rule}`)
console.log(` ${ee.message}`)
console.log(" 💡 Suggestion:")
ee.suggestion.split("\n").forEach((line) => {
if (line.trim()) {
console.log(` ${line}`)
}
})
console.log("")
(ee, i) => {
outputFormatter.formatEntityExposure(ee, i)
},
limit,
)
}
// Dependency direction violations
if (options.architecture && dependencyDirectionViolations.length > 0) {
console.log(
`\n⚠ Found ${String(dependencyDirectionViolations.length)} dependency direction violation(s)`,
)
displayGroupedViolations(
outputFormatter.displayGroupedViolations(
dependencyDirectionViolations,
(dd, index) => {
console.log(`${String(index + 1)}. ${dd.file}`)
console.log(` Severity: ${SEVERITY_LABELS[dd.severity]}`)
console.log(` From Layer: ${dd.fromLayer}`)
console.log(` To Layer: ${dd.toLayer}`)
console.log(` Import: ${dd.importPath}`)
console.log(` ${dd.message}`)
console.log(` 💡 Suggestion: ${dd.suggestion}`)
console.log("")
(dd, i) => {
outputFormatter.formatDependencyDirection(dd, i)
},
limit,
)
}
// Repository pattern violations
if (options.architecture && repositoryPatternViolations.length > 0) {
console.log(
`\n📦 Found ${String(repositoryPatternViolations.length)} repository pattern violation(s)`,
)
displayGroupedViolations(
outputFormatter.displayGroupedViolations(
repositoryPatternViolations,
(rp, index) => {
console.log(`${String(index + 1)}. ${rp.file}`)
console.log(` Severity: ${SEVERITY_LABELS[rp.severity]}`)
console.log(` Layer: ${rp.layer}`)
console.log(` Type: ${rp.violationType}`)
console.log(` Details: ${rp.details}`)
console.log(` ${rp.message}`)
console.log(` 💡 Suggestion: ${rp.suggestion}`)
console.log("")
(rp, i) => {
outputFormatter.formatRepositoryPattern(rp, i)
},
limit,
)
}
// Aggregate boundary violations
if (options.architecture && aggregateBoundaryViolations.length > 0) {
console.log(
`\n🔒 Found ${String(aggregateBoundaryViolations.length)} aggregate boundary violation(s)`,
)
displayGroupedViolations(
outputFormatter.displayGroupedViolations(
aggregateBoundaryViolations,
(ab, index) => {
const location = ab.line ? `${ab.file}:${String(ab.line)}` : ab.file
console.log(`${String(index + 1)}. ${location}`)
console.log(` Severity: ${SEVERITY_LABELS[ab.severity]}`)
console.log(` From Aggregate: ${ab.fromAggregate}`)
console.log(` To Aggregate: ${ab.toAggregate}`)
console.log(` Entity: ${ab.entityName}`)
console.log(` Import: ${ab.importPath}`)
console.log(` ${ab.message}`)
console.log(" 💡 Suggestion:")
ab.suggestion.split("\n").forEach((line) => {
if (line.trim()) {
console.log(` ${line}`)
}
})
console.log("")
(ab, i) => {
outputFormatter.formatAggregateBoundary(ab, i)
},
limit,
)
}
// Hardcode violations
if (options.hardcode && hardcodeViolations.length > 0) {
console.log(
`\n${CLI_MESSAGES.HARDCODE_VIOLATIONS_HEADER} ${String(hardcodeViolations.length)} ${CLI_LABELS.HARDCODE_VIOLATIONS}`,
)
displayGroupedViolations(
outputFormatter.displayGroupedViolations(
hardcodeViolations,
(hc, index) => {
console.log(
`${String(index + 1)}. ${hc.file}:${String(hc.line)}:${String(hc.column)}`,
)
console.log(` Severity: ${SEVERITY_LABELS[hc.severity]}`)
console.log(` Type: ${hc.type}`)
console.log(` Value: ${JSON.stringify(hc.value)}`)
console.log(` Context: ${hc.context.trim()}`)
console.log(` 💡 Suggested: ${hc.suggestion.constantName}`)
console.log(` 📁 Location: ${hc.suggestion.location}`)
console.log("")
(hc, i) => {
outputFormatter.formatHardcodeViolation(hc, i)
},
limit,
)
}
// Summary
const totalIssues =
violations.length +
hardcodeViolations.length +
@@ -444,26 +269,9 @@ program
repositoryPatternViolations.length +
aggregateBoundaryViolations.length
if (totalIssues === 0) {
console.log(CLI_MESSAGES.NO_ISSUES)
process.exit(0)
} else {
console.log(
`${CLI_MESSAGES.ISSUES_TOTAL} ${String(totalIssues)} ${CLI_LABELS.ISSUES_TOTAL}`,
)
console.log(CLI_MESSAGES.TIP)
if (options.verbose) {
console.log(CLI_MESSAGES.HELP_FOOTER)
}
process.exit(1)
}
statsFormatter.displaySummary(totalIssues, options.verbose)
} catch (error) {
console.error(`\n❌ ${CLI_MESSAGES.ERROR_PREFIX}`)
console.error(error instanceof Error ? error.message : String(error))
console.error("")
process.exit(1)
statsFormatter.displayError(error instanceof Error ? error.message : String(error))
}
})

View File

@@ -26,6 +26,19 @@ export class HardcodeDetector implements IHardcodeDetector {
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
]
/**
* Detects all hardcoded values (both numbers and strings) in the given code
*
@@ -43,14 +56,15 @@ export class HardcodeDetector implements IHardcodeDetector {
}
/**
* Check if a file is a constants definition file
* 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)\.ts$/i,
/\/(constants|config|settings|defaults|tokens)\.ts$/i,
/\/di\/tokens\.(ts|js)$/i,
]
return constantsPatterns.some((pattern) => pattern.test(filePath))
}
@@ -341,6 +355,18 @@ export class HardcodeDetector implements IHardcodeDetector {
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
}
@@ -388,4 +414,46 @@ export class HardcodeDetector implements IHardcodeDetector {
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)
}
}

View File

@@ -0,0 +1,308 @@
import { describe, it, expect } from "vitest"
import { ProjectPath } from "../../../src/domain/value-objects/ProjectPath"
describe("ProjectPath", () => {
describe("create", () => {
it("should create a ProjectPath with absolute and relative paths", () => {
const absolutePath = "/Users/dev/project/src/domain/User.ts"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.absolute).toBe(absolutePath)
expect(projectPath.relative).toBe("src/domain/User.ts")
})
it("should handle paths with same directory", () => {
const absolutePath = "/Users/dev/project/User.ts"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.absolute).toBe(absolutePath)
expect(projectPath.relative).toBe("User.ts")
})
it("should handle nested directory structures", () => {
const absolutePath = "/Users/dev/project/src/domain/entities/user/User.ts"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.absolute).toBe(absolutePath)
expect(projectPath.relative).toBe("src/domain/entities/user/User.ts")
})
it("should handle Windows-style paths", () => {
const absolutePath = "C:\\Users\\dev\\project\\src\\domain\\User.ts"
const projectRoot = "C:\\Users\\dev\\project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.absolute).toBe(absolutePath)
})
})
describe("absolute getter", () => {
it("should return the absolute path", () => {
const absolutePath = "/Users/dev/project/src/domain/User.ts"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.absolute).toBe(absolutePath)
})
})
describe("relative getter", () => {
it("should return the relative path", () => {
const absolutePath = "/Users/dev/project/src/domain/User.ts"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.relative).toBe("src/domain/User.ts")
})
})
describe("extension getter", () => {
it("should return .ts for TypeScript files", () => {
const absolutePath = "/Users/dev/project/src/domain/User.ts"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.extension).toBe(".ts")
})
it("should return .tsx for TypeScript JSX files", () => {
const absolutePath = "/Users/dev/project/src/components/Button.tsx"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.extension).toBe(".tsx")
})
it("should return .js for JavaScript files", () => {
const absolutePath = "/Users/dev/project/src/utils/helper.js"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.extension).toBe(".js")
})
it("should return .jsx for JavaScript JSX files", () => {
const absolutePath = "/Users/dev/project/src/components/Button.jsx"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.extension).toBe(".jsx")
})
it("should return empty string for files without extension", () => {
const absolutePath = "/Users/dev/project/README"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.extension).toBe("")
})
})
describe("filename getter", () => {
it("should return the filename with extension", () => {
const absolutePath = "/Users/dev/project/src/domain/User.ts"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.filename).toBe("User.ts")
})
it("should handle filenames with multiple dots", () => {
const absolutePath = "/Users/dev/project/src/domain/User.test.ts"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.filename).toBe("User.test.ts")
})
it("should handle filenames without extension", () => {
const absolutePath = "/Users/dev/project/README"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.filename).toBe("README")
})
})
describe("directory getter", () => {
it("should return the directory path relative to project root", () => {
const absolutePath = "/Users/dev/project/src/domain/entities/User.ts"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.directory).toBe("src/domain/entities")
})
it("should return dot for files in project root", () => {
const absolutePath = "/Users/dev/project/README.md"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.directory).toBe(".")
})
it("should handle single-level directories", () => {
const absolutePath = "/Users/dev/project/src/User.ts"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.directory).toBe("src")
})
})
describe("isTypeScript", () => {
it("should return true for .ts files", () => {
const absolutePath = "/Users/dev/project/src/domain/User.ts"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.isTypeScript()).toBe(true)
})
it("should return true for .tsx files", () => {
const absolutePath = "/Users/dev/project/src/components/Button.tsx"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.isTypeScript()).toBe(true)
})
it("should return false for .js files", () => {
const absolutePath = "/Users/dev/project/src/utils/helper.js"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.isTypeScript()).toBe(false)
})
it("should return false for .jsx files", () => {
const absolutePath = "/Users/dev/project/src/components/Button.jsx"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.isTypeScript()).toBe(false)
})
it("should return false for other file types", () => {
const absolutePath = "/Users/dev/project/README.md"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.isTypeScript()).toBe(false)
})
})
describe("isJavaScript", () => {
it("should return true for .js files", () => {
const absolutePath = "/Users/dev/project/src/utils/helper.js"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.isJavaScript()).toBe(true)
})
it("should return true for .jsx files", () => {
const absolutePath = "/Users/dev/project/src/components/Button.jsx"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.isJavaScript()).toBe(true)
})
it("should return false for .ts files", () => {
const absolutePath = "/Users/dev/project/src/domain/User.ts"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.isJavaScript()).toBe(false)
})
it("should return false for .tsx files", () => {
const absolutePath = "/Users/dev/project/src/components/Button.tsx"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.isJavaScript()).toBe(false)
})
it("should return false for other file types", () => {
const absolutePath = "/Users/dev/project/README.md"
const projectRoot = "/Users/dev/project"
const projectPath = ProjectPath.create(absolutePath, projectRoot)
expect(projectPath.isJavaScript()).toBe(false)
})
})
describe("equals", () => {
it("should return true for identical paths", () => {
const absolutePath = "/Users/dev/project/src/domain/User.ts"
const projectRoot = "/Users/dev/project"
const path1 = ProjectPath.create(absolutePath, projectRoot)
const path2 = ProjectPath.create(absolutePath, projectRoot)
expect(path1.equals(path2)).toBe(true)
})
it("should return false for different absolute paths", () => {
const projectRoot = "/Users/dev/project"
const path1 = ProjectPath.create("/Users/dev/project/src/domain/User.ts", projectRoot)
const path2 = ProjectPath.create("/Users/dev/project/src/domain/Order.ts", projectRoot)
expect(path1.equals(path2)).toBe(false)
})
it("should return false for different relative paths", () => {
const path1 = ProjectPath.create(
"/Users/dev/project1/src/User.ts",
"/Users/dev/project1",
)
const path2 = ProjectPath.create(
"/Users/dev/project2/src/User.ts",
"/Users/dev/project2",
)
expect(path1.equals(path2)).toBe(false)
})
it("should return false when comparing with undefined", () => {
const absolutePath = "/Users/dev/project/src/domain/User.ts"
const projectRoot = "/Users/dev/project"
const path1 = ProjectPath.create(absolutePath, projectRoot)
expect(path1.equals(undefined)).toBe(false)
})
})
})

View File

@@ -0,0 +1,521 @@
import { describe, it, expect } from "vitest"
import { RepositoryViolation } from "../../../src/domain/value-objects/RepositoryViolation"
import { REPOSITORY_VIOLATION_TYPES } from "../../../src/shared/constants/rules"
describe("RepositoryViolation", () => {
describe("create", () => {
it("should create a repository violation for ORM type in interface", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Repository uses Prisma type",
"Prisma.UserWhereInput",
)
expect(violation.violationType).toBe(REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE)
expect(violation.filePath).toBe("src/domain/repositories/IUserRepository.ts")
expect(violation.layer).toBe("domain")
expect(violation.line).toBe(15)
expect(violation.details).toBe("Repository uses Prisma type")
expect(violation.ormType).toBe("Prisma.UserWhereInput")
})
it("should create a repository violation for concrete repository in use case", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
"src/application/use-cases/CreateUser.ts",
"application",
10,
"Use case depends on concrete repository",
undefined,
"UserRepository",
)
expect(violation.violationType).toBe(
REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
)
expect(violation.repositoryName).toBe("UserRepository")
})
it("should create a repository violation for new repository in use case", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE,
"src/application/use-cases/CreateUser.ts",
"application",
12,
"Use case creates repository with new",
undefined,
"UserRepository",
)
expect(violation.violationType).toBe(
REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE,
)
expect(violation.repositoryName).toBe("UserRepository")
})
it("should create a repository violation for non-domain method name", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
"src/domain/repositories/IUserRepository.ts",
"domain",
8,
"Method uses technical name. Consider: findById()",
undefined,
undefined,
"findOne",
)
expect(violation.violationType).toBe(REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME)
expect(violation.methodName).toBe("findOne")
})
it("should handle optional line parameter", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
undefined,
"Repository uses Prisma type",
)
expect(violation.line).toBeUndefined()
})
})
describe("getters", () => {
it("should return violation type", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
)
expect(violation.violationType).toBe(REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE)
})
it("should return file path", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
)
expect(violation.filePath).toBe("src/domain/repositories/IUserRepository.ts")
})
it("should return layer", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
)
expect(violation.layer).toBe("domain")
})
it("should return line number", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
)
expect(violation.line).toBe(15)
})
it("should return details", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Repository uses Prisma type",
)
expect(violation.details).toBe("Repository uses Prisma type")
})
it("should return ORM type", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
"Prisma.UserWhereInput",
)
expect(violation.ormType).toBe("Prisma.UserWhereInput")
})
it("should return repository name", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
"src/application/use-cases/CreateUser.ts",
"application",
10,
"Test",
undefined,
"UserRepository",
)
expect(violation.repositoryName).toBe("UserRepository")
})
it("should return method name", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
"src/domain/repositories/IUserRepository.ts",
"domain",
8,
"Test",
undefined,
undefined,
"findOne",
)
expect(violation.methodName).toBe("findOne")
})
})
describe("getMessage", () => {
it("should return message for ORM type in interface", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
"Prisma.UserWhereInput",
)
const message = violation.getMessage()
expect(message).toContain("ORM-specific type")
expect(message).toContain("Prisma.UserWhereInput")
})
it("should return message for concrete repository in use case", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
"src/application/use-cases/CreateUser.ts",
"application",
10,
"Test",
undefined,
"UserRepository",
)
const message = violation.getMessage()
expect(message).toContain("depends on concrete repository")
expect(message).toContain("UserRepository")
})
it("should return message for new repository in use case", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE,
"src/application/use-cases/CreateUser.ts",
"application",
12,
"Test",
undefined,
"UserRepository",
)
const message = violation.getMessage()
expect(message).toContain("creates repository with 'new")
expect(message).toContain("UserRepository")
})
it("should return message for non-domain method name", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
"src/domain/repositories/IUserRepository.ts",
"domain",
8,
"Test",
undefined,
undefined,
"findOne",
)
const message = violation.getMessage()
expect(message).toContain("uses technical name")
expect(message).toContain("findOne")
})
it("should handle unknown ORM type gracefully", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
)
const message = violation.getMessage()
expect(message).toContain("unknown")
})
})
describe("getSuggestion", () => {
it("should return suggestion for ORM type in interface", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
"Prisma.UserWhereInput",
)
const suggestion = violation.getSuggestion()
expect(suggestion).toContain("Remove ORM-specific types")
expect(suggestion).toContain("Use domain types")
})
it("should return suggestion for concrete repository in use case", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
"src/application/use-cases/CreateUser.ts",
"application",
10,
"Test",
undefined,
"UserRepository",
)
const suggestion = violation.getSuggestion()
expect(suggestion).toContain("Depend on repository interface")
expect(suggestion).toContain("IUserRepository")
})
it("should return suggestion for new repository in use case", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE,
"src/application/use-cases/CreateUser.ts",
"application",
12,
"Test",
undefined,
"UserRepository",
)
const suggestion = violation.getSuggestion()
expect(suggestion).toContain("Remove 'new Repository()'")
expect(suggestion).toContain("dependency injection")
})
it("should return suggestion for non-domain method name with smart suggestion", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
"src/domain/repositories/IUserRepository.ts",
"domain",
8,
"Method uses technical name. Consider: findById()",
undefined,
undefined,
"findOne",
)
const suggestion = violation.getSuggestion()
expect(suggestion).toContain("findById()")
})
it("should return fallback suggestion for known technical method", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
"src/domain/repositories/IUserRepository.ts",
"domain",
8,
"Method uses technical name",
undefined,
undefined,
"insert",
)
const suggestion = violation.getSuggestion()
expect(suggestion).toContain("save or create")
})
it("should return default suggestion for unknown method", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
"src/domain/repositories/IUserRepository.ts",
"domain",
8,
"Method uses technical name",
undefined,
undefined,
"unknownMethod",
)
const suggestion = violation.getSuggestion()
expect(suggestion).toBeDefined()
expect(suggestion.length).toBeGreaterThan(0)
})
})
describe("getExampleFix", () => {
it("should return example fix for ORM type in interface", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
)
const example = violation.getExampleFix()
expect(example).toContain("BAD")
expect(example).toContain("GOOD")
expect(example).toContain("IUserRepository")
})
it("should return example fix for concrete repository in use case", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
"src/application/use-cases/CreateUser.ts",
"application",
10,
"Test",
)
const example = violation.getExampleFix()
expect(example).toContain("BAD")
expect(example).toContain("GOOD")
expect(example).toContain("CreateUser")
})
it("should return example fix for new repository in use case", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE,
"src/application/use-cases/CreateUser.ts",
"application",
12,
"Test",
)
const example = violation.getExampleFix()
expect(example).toContain("BAD")
expect(example).toContain("GOOD")
expect(example).toContain("new UserRepository")
})
it("should return example fix for non-domain method name", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
"src/domain/repositories/IUserRepository.ts",
"domain",
8,
"Test",
)
const example = violation.getExampleFix()
expect(example).toContain("BAD")
expect(example).toContain("GOOD")
expect(example).toContain("findOne")
})
})
describe("equals", () => {
it("should return true for violations with identical properties", () => {
const violation1 = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
"Prisma.UserWhereInput",
)
const violation2 = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
"Prisma.UserWhereInput",
)
expect(violation1.equals(violation2)).toBe(true)
})
it("should return false for violations with different types", () => {
const violation1 = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
)
const violation2 = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
)
expect(violation1.equals(violation2)).toBe(false)
})
it("should return false for violations with different file paths", () => {
const violation1 = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
)
const violation2 = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IOrderRepository.ts",
"domain",
15,
"Test",
)
expect(violation1.equals(violation2)).toBe(false)
})
it("should return false when comparing with undefined", () => {
const violation = RepositoryViolation.create(
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
"src/domain/repositories/IUserRepository.ts",
"domain",
15,
"Test",
)
expect(violation.equals(undefined)).toBe(false)
})
})
})

View File

@@ -0,0 +1,329 @@
import { describe, it, expect } from "vitest"
import { SourceFile } from "../../../src/domain/entities/SourceFile"
import { ProjectPath } from "../../../src/domain/value-objects/ProjectPath"
import { LAYERS } from "../../../src/shared/constants/rules"
describe("SourceFile", () => {
describe("constructor", () => {
it("should create a SourceFile instance with all properties", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const content = "class User {}"
const imports = ["./BaseEntity"]
const exports = ["User"]
const id = "test-id"
const sourceFile = new SourceFile(path, content, imports, exports, id)
expect(sourceFile.path).toBe(path)
expect(sourceFile.content).toBe(content)
expect(sourceFile.imports).toEqual(imports)
expect(sourceFile.exports).toEqual(exports)
expect(sourceFile.id).toBe(id)
})
it("should create a SourceFile with empty imports and exports by default", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const content = "class User {}"
const sourceFile = new SourceFile(path, content)
expect(sourceFile.imports).toEqual([])
expect(sourceFile.exports).toEqual([])
})
it("should generate an id if not provided", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const content = "class User {}"
const sourceFile = new SourceFile(path, content)
expect(sourceFile.id).toBeDefined()
expect(typeof sourceFile.id).toBe("string")
expect(sourceFile.id.length).toBeGreaterThan(0)
})
})
describe("layer detection", () => {
it("should detect domain layer from path", () => {
const path = ProjectPath.create("/project/src/domain/entities/User.ts", "/project")
const sourceFile = new SourceFile(path, "")
expect(sourceFile.layer).toBe(LAYERS.DOMAIN)
})
it("should detect application layer from path", () => {
const path = ProjectPath.create(
"/project/src/application/use-cases/CreateUser.ts",
"/project",
)
const sourceFile = new SourceFile(path, "")
expect(sourceFile.layer).toBe(LAYERS.APPLICATION)
})
it("should detect infrastructure layer from path", () => {
const path = ProjectPath.create(
"/project/src/infrastructure/database/UserRepository.ts",
"/project",
)
const sourceFile = new SourceFile(path, "")
expect(sourceFile.layer).toBe(LAYERS.INFRASTRUCTURE)
})
it("should detect shared layer from path", () => {
const path = ProjectPath.create("/project/src/shared/utils/helpers.ts", "/project")
const sourceFile = new SourceFile(path, "")
expect(sourceFile.layer).toBe(LAYERS.SHARED)
})
it("should return undefined for unknown layer", () => {
const path = ProjectPath.create("/project/src/unknown/Test.ts", "/project")
const sourceFile = new SourceFile(path, "")
expect(sourceFile.layer).toBeUndefined()
})
it("should handle uppercase layer names in path", () => {
const path = ProjectPath.create("/project/src/DOMAIN/User.ts", "/project")
const sourceFile = new SourceFile(path, "")
expect(sourceFile.layer).toBe(LAYERS.DOMAIN)
})
it("should handle mixed case layer names in path", () => {
const path = ProjectPath.create("/project/src/Application/UseCase.ts", "/project")
const sourceFile = new SourceFile(path, "")
expect(sourceFile.layer).toBe(LAYERS.APPLICATION)
})
})
describe("path getter", () => {
it("should return the project path", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const sourceFile = new SourceFile(path, "")
expect(sourceFile.path).toBe(path)
})
})
describe("content getter", () => {
it("should return the file content", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const content = "class User { constructor(public name: string) {} }"
const sourceFile = new SourceFile(path, content)
expect(sourceFile.content).toBe(content)
})
})
describe("imports getter", () => {
it("should return a copy of imports array", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const imports = ["./BaseEntity", "./ValueObject"]
const sourceFile = new SourceFile(path, "", imports)
const returnedImports = sourceFile.imports
expect(returnedImports).toEqual(imports)
expect(returnedImports).not.toBe(imports)
})
it("should not allow mutations of internal imports array", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const imports = ["./BaseEntity"]
const sourceFile = new SourceFile(path, "", imports)
const returnedImports = sourceFile.imports
returnedImports.push("./NewImport")
expect(sourceFile.imports).toEqual(["./BaseEntity"])
})
})
describe("exports getter", () => {
it("should return a copy of exports array", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const exports = ["User", "UserProps"]
const sourceFile = new SourceFile(path, "", [], exports)
const returnedExports = sourceFile.exports
expect(returnedExports).toEqual(exports)
expect(returnedExports).not.toBe(exports)
})
it("should not allow mutations of internal exports array", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const exports = ["User"]
const sourceFile = new SourceFile(path, "", [], exports)
const returnedExports = sourceFile.exports
returnedExports.push("NewExport")
expect(sourceFile.exports).toEqual(["User"])
})
})
describe("addImport", () => {
it("should add a new import to the list", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const sourceFile = new SourceFile(path, "")
sourceFile.addImport("./BaseEntity")
expect(sourceFile.imports).toEqual(["./BaseEntity"])
})
it("should not add duplicate imports", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const sourceFile = new SourceFile(path, "", ["./BaseEntity"])
sourceFile.addImport("./BaseEntity")
expect(sourceFile.imports).toEqual(["./BaseEntity"])
})
it("should update updatedAt timestamp when adding new import", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const sourceFile = new SourceFile(path, "")
const originalUpdatedAt = sourceFile.updatedAt
setTimeout(() => {
sourceFile.addImport("./BaseEntity")
expect(sourceFile.updatedAt.getTime()).toBeGreaterThanOrEqual(
originalUpdatedAt.getTime(),
)
}, 10)
})
it("should not update timestamp when adding duplicate import", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const sourceFile = new SourceFile(path, "", ["./BaseEntity"])
const originalUpdatedAt = sourceFile.updatedAt
setTimeout(() => {
sourceFile.addImport("./BaseEntity")
expect(sourceFile.updatedAt).toBe(originalUpdatedAt)
}, 10)
})
it("should add multiple different imports", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const sourceFile = new SourceFile(path, "")
sourceFile.addImport("./BaseEntity")
sourceFile.addImport("./ValueObject")
sourceFile.addImport("./DomainEvent")
expect(sourceFile.imports).toEqual(["./BaseEntity", "./ValueObject", "./DomainEvent"])
})
})
describe("addExport", () => {
it("should add a new export to the list", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const sourceFile = new SourceFile(path, "")
sourceFile.addExport("User")
expect(sourceFile.exports).toEqual(["User"])
})
it("should not add duplicate exports", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const sourceFile = new SourceFile(path, "", [], ["User"])
sourceFile.addExport("User")
expect(sourceFile.exports).toEqual(["User"])
})
it("should update updatedAt timestamp when adding new export", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const sourceFile = new SourceFile(path, "")
const originalUpdatedAt = sourceFile.updatedAt
setTimeout(() => {
sourceFile.addExport("User")
expect(sourceFile.updatedAt.getTime()).toBeGreaterThanOrEqual(
originalUpdatedAt.getTime(),
)
}, 10)
})
it("should not update timestamp when adding duplicate export", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const sourceFile = new SourceFile(path, "", [], ["User"])
const originalUpdatedAt = sourceFile.updatedAt
setTimeout(() => {
sourceFile.addExport("User")
expect(sourceFile.updatedAt).toBe(originalUpdatedAt)
}, 10)
})
it("should add multiple different exports", () => {
const path = ProjectPath.create("/project/src/domain/User.ts", "/project")
const sourceFile = new SourceFile(path, "")
sourceFile.addExport("User")
sourceFile.addExport("UserProps")
sourceFile.addExport("UserFactory")
expect(sourceFile.exports).toEqual(["User", "UserProps", "UserFactory"])
})
})
describe("importsFrom", () => {
it("should return true if imports contain the specified layer", () => {
const path = ProjectPath.create("/project/src/application/User.ts", "/project")
const imports = ["../../domain/entities/User", "../use-cases/CreateUser"]
const sourceFile = new SourceFile(path, "", imports)
expect(sourceFile.importsFrom("domain")).toBe(true)
})
it("should return false if imports do not contain the specified layer", () => {
const path = ProjectPath.create("/project/src/application/User.ts", "/project")
const imports = ["../use-cases/CreateUser", "../dtos/UserDto"]
const sourceFile = new SourceFile(path, "", imports)
expect(sourceFile.importsFrom("domain")).toBe(false)
})
it("should be case-insensitive", () => {
const path = ProjectPath.create("/project/src/application/User.ts", "/project")
const imports = ["../../DOMAIN/entities/User"]
const sourceFile = new SourceFile(path, "", imports)
expect(sourceFile.importsFrom("domain")).toBe(true)
})
it("should return false for empty imports", () => {
const path = ProjectPath.create("/project/src/application/User.ts", "/project")
const sourceFile = new SourceFile(path, "")
expect(sourceFile.importsFrom("domain")).toBe(false)
})
it("should handle partial matches in import paths", () => {
const path = ProjectPath.create("/project/src/application/User.ts", "/project")
const imports = ["../../infrastructure/database/UserRepository"]
const sourceFile = new SourceFile(path, "", imports)
expect(sourceFile.importsFrom("infrastructure")).toBe(true)
expect(sourceFile.importsFrom("domain")).toBe(false)
})
})
})

View File

@@ -0,0 +1,199 @@
import { describe, it, expect } from "vitest"
import { ValueObject } from "../../../src/domain/value-objects/ValueObject"
interface TestProps {
readonly value: string
readonly count: number
}
class TestValueObject extends ValueObject<TestProps> {
constructor(value: string, count: number) {
super({ value, count })
}
public get value(): string {
return this.props.value
}
public get count(): number {
return this.props.count
}
}
interface ComplexProps {
readonly name: string
readonly items: string[]
readonly metadata: { key: string; value: number }
}
class ComplexValueObject extends ValueObject<ComplexProps> {
constructor(name: string, items: string[], metadata: { key: string; value: number }) {
super({ name, items, metadata })
}
public get name(): string {
return this.props.name
}
public get items(): string[] {
return this.props.items
}
public get metadata(): { key: string; value: number } {
return this.props.metadata
}
}
describe("ValueObject", () => {
describe("constructor", () => {
it("should create a value object with provided properties", () => {
const vo = new TestValueObject("test", 42)
expect(vo.value).toBe("test")
expect(vo.count).toBe(42)
})
it("should freeze the properties object", () => {
const vo = new TestValueObject("test", 42)
expect(Object.isFrozen(vo["props"])).toBe(true)
})
it("should prevent modification of properties", () => {
const vo = new TestValueObject("test", 42)
expect(() => {
;(vo["props"] as any).value = "modified"
}).toThrow()
})
it("should handle complex nested properties", () => {
const vo = new ComplexValueObject("test", ["item1", "item2"], {
key: "key1",
value: 100,
})
expect(vo.name).toBe("test")
expect(vo.items).toEqual(["item1", "item2"])
expect(vo.metadata).toEqual({ key: "key1", value: 100 })
})
})
describe("equals", () => {
it("should return true for value objects with identical properties", () => {
const vo1 = new TestValueObject("test", 42)
const vo2 = new TestValueObject("test", 42)
expect(vo1.equals(vo2)).toBe(true)
})
it("should return false for value objects with different values", () => {
const vo1 = new TestValueObject("test1", 42)
const vo2 = new TestValueObject("test2", 42)
expect(vo1.equals(vo2)).toBe(false)
})
it("should return false for value objects with different counts", () => {
const vo1 = new TestValueObject("test", 42)
const vo2 = new TestValueObject("test", 43)
expect(vo1.equals(vo2)).toBe(false)
})
it("should return false when comparing with undefined", () => {
const vo1 = new TestValueObject("test", 42)
expect(vo1.equals(undefined)).toBe(false)
})
it("should return false when comparing with null", () => {
const vo1 = new TestValueObject("test", 42)
expect(vo1.equals(null as any)).toBe(false)
})
it("should handle complex nested property comparisons", () => {
const vo1 = new ComplexValueObject("test", ["item1", "item2"], {
key: "key1",
value: 100,
})
const vo2 = new ComplexValueObject("test", ["item1", "item2"], {
key: "key1",
value: 100,
})
expect(vo1.equals(vo2)).toBe(true)
})
it("should detect differences in nested arrays", () => {
const vo1 = new ComplexValueObject("test", ["item1", "item2"], {
key: "key1",
value: 100,
})
const vo2 = new ComplexValueObject("test", ["item1", "item3"], {
key: "key1",
value: 100,
})
expect(vo1.equals(vo2)).toBe(false)
})
it("should detect differences in nested objects", () => {
const vo1 = new ComplexValueObject("test", ["item1", "item2"], {
key: "key1",
value: 100,
})
const vo2 = new ComplexValueObject("test", ["item1", "item2"], {
key: "key2",
value: 100,
})
expect(vo1.equals(vo2)).toBe(false)
})
it("should return true for same instance", () => {
const vo1 = new TestValueObject("test", 42)
expect(vo1.equals(vo1)).toBe(true)
})
it("should handle empty string values", () => {
const vo1 = new TestValueObject("", 0)
const vo2 = new TestValueObject("", 0)
expect(vo1.equals(vo2)).toBe(true)
})
it("should distinguish between zero and undefined in comparisons", () => {
const vo1 = new TestValueObject("test", 0)
const vo2 = new TestValueObject("test", 0)
expect(vo1.equals(vo2)).toBe(true)
})
})
describe("immutability", () => {
it("should freeze props object after creation", () => {
const vo = new TestValueObject("original", 42)
expect(Object.isFrozen(vo["props"])).toBe(true)
})
it("should not allow adding new properties", () => {
const vo = new TestValueObject("test", 42)
expect(() => {
;(vo["props"] as any).newProp = "new"
}).toThrow()
})
it("should not allow deleting properties", () => {
const vo = new TestValueObject("test", 42)
expect(() => {
delete (vo["props"] as any).value
}).toThrow()
})
})
})

View File

@@ -468,4 +468,102 @@ const b = 2`
expect(result[0].context).toContain("5000")
})
})
describe("TypeScript type contexts (false positive reduction)", () => {
it("should NOT detect strings in union types", () => {
const code = `type Status = 'active' | 'inactive' | 'pending'`
const result = detector.detectMagicStrings(code, "test.ts")
expect(result).toHaveLength(0)
})
it("should NOT detect strings in interface property types", () => {
const code = `interface Config { mode: 'development' | 'production' }`
const result = detector.detectMagicStrings(code, "test.ts")
expect(result).toHaveLength(0)
})
it("should NOT detect strings in type aliases", () => {
const code = `type Theme = 'light' | 'dark'`
const result = detector.detectMagicStrings(code, "test.ts")
expect(result).toHaveLength(0)
})
it("should NOT detect strings in type assertions", () => {
const code = `const mode = getMode() as 'read' | 'write'`
const result = detector.detectMagicStrings(code, "test.ts")
expect(result).toHaveLength(0)
})
it("should NOT detect strings in Symbol() calls", () => {
const code = `const TOKEN = Symbol('MY_TOKEN')`
const result = detector.detectMagicStrings(code, "test.ts")
expect(result).toHaveLength(0)
})
it("should NOT detect strings in multiple Symbol() calls", () => {
const code = `
export const LOGGER = Symbol('LOGGER')
export const DATABASE = Symbol('DATABASE')
export const CACHE = Symbol('CACHE')
`
const result = detector.detectMagicStrings(code, "test.ts")
expect(result).toHaveLength(0)
})
it("should NOT detect strings in import() calls", () => {
const code = `const module = import('../../path/to/module.js')`
const result = detector.detectMagicStrings(code, "test.ts")
expect(result).toHaveLength(0)
})
it("should NOT detect strings in typeof checks", () => {
const code = `if (typeof x === 'string') { }`
const result = detector.detectMagicStrings(code, "test.ts")
expect(result).toHaveLength(0)
})
it("should NOT detect strings in reverse typeof checks", () => {
const code = `if ('number' === typeof count) { }`
const result = detector.detectMagicStrings(code, "test.ts")
expect(result).toHaveLength(0)
})
it("should skip tokens.ts files completely", () => {
const code = `
export const LOGGER = Symbol('LOGGER')
export const DATABASE = Symbol('DATABASE')
const url = "http://localhost:8080"
`
const result = detector.detectAll(code, "src/di/tokens.ts")
expect(result).toHaveLength(0)
})
it("should skip tokens.js files completely", () => {
const code = `const TOKEN = Symbol('TOKEN')`
const result = detector.detectAll(code, "src/di/tokens.js")
expect(result).toHaveLength(0)
})
it("should detect real magic strings even with type contexts nearby", () => {
const code = `
type Mode = 'read' | 'write'
const apiKey = "secret-key-12345"
`
const result = detector.detectMagicStrings(code, "test.ts")
expect(result.length).toBeGreaterThan(0)
expect(result.some((r) => r.value === "secret-key-12345")).toBe(true)
})
})
})