mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd445995d | ||
|
|
c75738ba51 | ||
|
|
83b5dccee4 | ||
|
|
5a648e2c29 |
@@ -5,6 +5,155 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.7.1] - 2025-11-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛 **Aggregate Boundary Detection for relative paths:**
|
||||||
|
- Fixed regex pattern to support paths starting with `domain/` (without leading `/`)
|
||||||
|
- Now correctly detects violations in projects scanned from parent directories
|
||||||
|
|
||||||
|
- 🐛 **Reduced false positives in Repository Pattern detection:**
|
||||||
|
- Removed `findAll`, `exists`, `count` from ORM technical methods blacklist
|
||||||
|
- These are now correctly recognized as valid domain method names
|
||||||
|
- Added `exists`, `count`, `countBy[A-Z]` to domain method patterns
|
||||||
|
|
||||||
|
- 🐛 **Non-aggregate folder exclusions:**
|
||||||
|
- Added exclusions for standard DDD folders: `constants`, `shared`, `factories`, `ports`, `interfaces`
|
||||||
|
- Prevents false positives when domain layer has shared utilities
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️ **Extracted magic strings to constants:**
|
||||||
|
- DDD folder names (`entities`, `aggregates`, `value-objects`, etc.) moved to `DDD_FOLDER_NAMES`
|
||||||
|
- Repository method suggestions moved to `REPOSITORY_METHOD_SUGGESTIONS`
|
||||||
|
- Fallback suggestions moved to `REPOSITORY_FALLBACK_SUGGESTIONS`
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 📁 **Aggregate boundary test examples:**
|
||||||
|
- Added `examples/aggregate-boundary/domain/` with Order, User, Product aggregates
|
||||||
|
- Demonstrates cross-aggregate entity reference violations
|
||||||
|
|
||||||
|
## [0.7.0] - 2025-11-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
**🔒 Aggregate Boundary Validation**
|
||||||
|
|
||||||
|
New DDD feature to enforce aggregate boundaries and prevent tight coupling between aggregates.
|
||||||
|
|
||||||
|
- ✅ **Aggregate Boundary Detector:**
|
||||||
|
- Detects direct entity references across aggregate boundaries
|
||||||
|
- Validates that aggregates reference each other only by ID or Value Objects
|
||||||
|
- Supports multiple folder structure patterns:
|
||||||
|
- `domain/aggregates/order/Order.ts`
|
||||||
|
- `domain/order/Order.ts`
|
||||||
|
- `domain/entities/order/Order.ts`
|
||||||
|
|
||||||
|
- ✅ **Smart Import Analysis:**
|
||||||
|
- Parses ES6 imports and CommonJS require statements
|
||||||
|
- Identifies entity imports from other aggregates
|
||||||
|
- Allows imports from value-objects, events, services, specifications folders
|
||||||
|
|
||||||
|
- ✅ **Actionable Suggestions:**
|
||||||
|
- Reference by ID instead of entity
|
||||||
|
- Use Value Objects to store needed data from other aggregates
|
||||||
|
- Maintain aggregate independence
|
||||||
|
|
||||||
|
- ✅ **CLI Integration:**
|
||||||
|
- `--architecture` flag includes aggregate boundary checks
|
||||||
|
- CRITICAL severity for violations
|
||||||
|
- Detailed violation messages with file:line references
|
||||||
|
|
||||||
|
- ✅ **Test Coverage:**
|
||||||
|
- 41 new tests for aggregate boundary detection
|
||||||
|
- 333 total tests passing (100% pass rate)
|
||||||
|
- Examples in `examples/aggregate-boundary/`
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- New `AggregateBoundaryDetector` in infrastructure layer
|
||||||
|
- New `AggregateBoundaryViolation` value object in domain layer
|
||||||
|
- New `IAggregateBoundaryDetector` interface for dependency inversion
|
||||||
|
- Integrated into `AnalyzeProject` use case
|
||||||
|
|
||||||
|
## [0.6.4] - 2025-11-24
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
**🎯 Smart Context-Aware Suggestions for Repository Method Names**
|
||||||
|
|
||||||
|
Guardian now provides intelligent, context-specific suggestions when it detects non-domain method names in repositories.
|
||||||
|
|
||||||
|
- ✅ **Intelligent method name analysis:**
|
||||||
|
- `queryUsers()` → Suggests: `search`, `findBy[Property]`
|
||||||
|
- `selectById()` → Suggests: `findBy[Property]`, `get[Entity]`
|
||||||
|
- `insertUser()` → Suggests: `create`, `add[Entity]`, `store[Entity]`
|
||||||
|
- `updateRecord()` → Suggests: `update`, `modify[Entity]`
|
||||||
|
- `upsertUser()` → Suggests: `save`, `store[Entity]`
|
||||||
|
- `removeUser()` → Suggests: `delete`, `removeBy[Property]`
|
||||||
|
- `fetchUserData()` → Suggests: `findBy[Property]`, `get[Entity]`
|
||||||
|
- And more technical patterns detected automatically!
|
||||||
|
|
||||||
|
- 🎯 **Impact:**
|
||||||
|
- Developers get actionable, relevant suggestions instead of generic examples
|
||||||
|
- Faster refactoring with specific naming alternatives
|
||||||
|
- Better learning experience for developers new to DDD
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- ✅ **Expanded domain method patterns support:**
|
||||||
|
- `find*()` methods - e.g., `findNodes()`, `findNodeById()`, `findSimilar()`
|
||||||
|
- `saveAll()` - batch save operations
|
||||||
|
- `deleteBy*()` methods - e.g., `deleteByPath()`, `deleteById()`
|
||||||
|
- `deleteAll()` - clear all entities
|
||||||
|
- `add*()` methods - e.g., `addRelationship()`, `addItem()`
|
||||||
|
- `initializeCollection()` - collection initialization
|
||||||
|
|
||||||
|
- 🐛 **Removed `findAll` from technical methods blacklist:**
|
||||||
|
- `findAll()` is now correctly recognized as a standard domain method
|
||||||
|
- Reduced false positives for repositories using this common pattern
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Added `suggestDomainMethodName()` method in `RepositoryPatternDetector.ts` with keyword-based suggestion mapping
|
||||||
|
- Updated `getNonDomainMethodSuggestion()` in `RepositoryViolation.ts` to extract and use smart suggestions
|
||||||
|
- Refactored suggestion logic to reduce cyclomatic complexity (22 → 9)
|
||||||
|
- Enhanced `domainMethodPatterns` with 9 additional patterns
|
||||||
|
- All 333 tests passing
|
||||||
|
|
||||||
|
## [0.6.3] - 2025-11-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
**🐛 Repository Pattern Detection - Reduced False Positives**
|
||||||
|
|
||||||
|
Fixed overly strict repository method name validation that was flagging valid DDD patterns as violations.
|
||||||
|
|
||||||
|
- ✅ **Added support for common DDD repository patterns:**
|
||||||
|
- `has*()` methods - e.g., `hasProject()`, `hasPermission()`
|
||||||
|
- `is*()` methods - e.g., `isCached()`, `isActive()`
|
||||||
|
- `exists*()` methods - e.g., `existsById()`, `existsByEmail()`
|
||||||
|
- `clear*()` methods - e.g., `clearCache()`, `clearAll()`
|
||||||
|
- `store*()` methods - e.g., `storeMetadata()`, `storeFile()`
|
||||||
|
- Lifecycle methods: `initialize()`, `close()`, `connect()`, `disconnect()`
|
||||||
|
|
||||||
|
- 🎯 **Impact:**
|
||||||
|
- Reduced false positives in real-world DDD projects
|
||||||
|
- Better alignment with Domain-Driven Design best practices
|
||||||
|
- More practical for cache repositories, connection management, and business queries
|
||||||
|
|
||||||
|
- 📚 **Why these patterns are valid:**
|
||||||
|
- Martin Fowler's Repository Pattern allows domain-specific query methods
|
||||||
|
- DDD recommends using ubiquitous language in method names
|
||||||
|
- Lifecycle methods are standard for resource management in repositories
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Updated `domainMethodPatterns` in `RepositoryPatternDetector.ts` with 11 additional valid patterns
|
||||||
|
- All existing functionality remains unchanged
|
||||||
|
|
||||||
## [0.6.2] - 2025-11-24
|
## [0.6.2] - 2025-11-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ Code quality guardian for vibe coders and enterprise teams - because AI writes f
|
|||||||
- Prevents "new Repository()" anti-pattern
|
- Prevents "new Repository()" anti-pattern
|
||||||
- 📚 *Based on: Martin Fowler's Repository Pattern, DDD (Evans 2003)* → [Why?](./docs/WHY.md#repository-pattern)
|
- 📚 *Based on: Martin Fowler's Repository Pattern, DDD (Evans 2003)* → [Why?](./docs/WHY.md#repository-pattern)
|
||||||
|
|
||||||
|
🔒 **Aggregate Boundary Validation** ✨ NEW
|
||||||
|
- Detects direct entity references across DDD aggregates
|
||||||
|
- Enforces reference-by-ID or Value Object pattern
|
||||||
|
- Prevents tight coupling between aggregates
|
||||||
|
- Supports multiple folder structures (domain/aggregates/*, domain/*, domain/entities/*)
|
||||||
|
- Filters allowed imports (value-objects, events, repositories, services)
|
||||||
|
- Critical severity for maintaining aggregate independence
|
||||||
|
- 📚 *Based on: Domain-Driven Design (Evans 2003), Implementing DDD (Vernon 2013)* → [Why?](./docs/WHY.md#aggregate-boundaries)
|
||||||
|
|
||||||
🏗️ **Clean Architecture Enforcement**
|
🏗️ **Clean Architecture Enforcement**
|
||||||
- Built with DDD principles
|
- Built with DDD principles
|
||||||
- Layered architecture (Domain, Application, Infrastructure)
|
- Layered architecture (Domain, Application, Infrastructure)
|
||||||
|
|||||||
@@ -256,11 +256,10 @@ Internal refactoring to eliminate hardcoded values and improve maintainability:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Future Roadmap
|
## Version 0.7.0 - Aggregate Boundary Validation 🔒 ✅ RELEASED
|
||||||
|
|
||||||
### Version 0.6.0 - Aggregate Boundary Validation 🔒
|
**Released:** 2025-11-24
|
||||||
**Target:** Q1 2026
|
**Priority:** CRITICAL
|
||||||
**Priority:** MEDIUM
|
|
||||||
|
|
||||||
Validate aggregate boundaries in DDD:
|
Validate aggregate boundaries in DDD:
|
||||||
|
|
||||||
@@ -286,12 +285,19 @@ class Order {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Planned Features:**
|
**Implemented Features:**
|
||||||
- Detect entity references across aggregates
|
- ✅ Detect entity references across aggregates
|
||||||
- Allow only ID or Value Object references
|
- ✅ Allow only ID or Value Object references from other aggregates
|
||||||
- Detect circular dependencies between aggregates
|
- ✅ Filter allowed imports (value-objects, events, repositories, services)
|
||||||
- Validate aggregate root access patterns
|
- ✅ Support for multiple aggregate folder structures (domain/aggregates/name, domain/name, domain/entities/name)
|
||||||
- Support for aggregate folder structure
|
- ✅ 41 comprehensive tests with 100% pass rate
|
||||||
|
- ✅ Examples of good and bad patterns
|
||||||
|
- ✅ CLI output with 🔒 icon and detailed violation info
|
||||||
|
- ✅ Critical severity level for aggregate boundary violations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Roadmap
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* ❌ BAD EXAMPLE: Direct Entity Reference Across Aggregates
|
||||||
|
*
|
||||||
|
* Violation: Order aggregate directly imports and uses User entity from User aggregate
|
||||||
|
*
|
||||||
|
* Problems:
|
||||||
|
* 1. Creates tight coupling between aggregates
|
||||||
|
* 2. Changes to User entity affect Order aggregate
|
||||||
|
* 3. Violates aggregate boundary principles in DDD
|
||||||
|
* 4. Makes aggregates not independently modifiable
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { User } from "../user/User"
|
||||||
|
import { Product } from "../product/Product"
|
||||||
|
|
||||||
|
export class Order {
|
||||||
|
private id: string
|
||||||
|
private user: User
|
||||||
|
private product: Product
|
||||||
|
private quantity: number
|
||||||
|
|
||||||
|
constructor(id: string, user: User, product: Product, quantity: number) {
|
||||||
|
this.id = id
|
||||||
|
this.user = user
|
||||||
|
this.product = product
|
||||||
|
this.quantity = quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
getUserEmail(): string {
|
||||||
|
return this.user.email
|
||||||
|
}
|
||||||
|
|
||||||
|
getProductPrice(): number {
|
||||||
|
return this.product.price
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateTotal(): number {
|
||||||
|
return this.product.price * this.quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { User } from "../user/User"
|
||||||
|
import { Product } from "../product/Product"
|
||||||
|
|
||||||
|
export class Order {
|
||||||
|
private id: string
|
||||||
|
private user: User
|
||||||
|
private product: Product
|
||||||
|
private quantity: number
|
||||||
|
|
||||||
|
constructor(id: string, user: User, product: Product, quantity: number) {
|
||||||
|
this.id = id
|
||||||
|
this.user = user
|
||||||
|
this.product = product
|
||||||
|
this.quantity = quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export class Product {
|
||||||
|
public price: number
|
||||||
|
|
||||||
|
constructor(price: number) {
|
||||||
|
this.price = price
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export class User {
|
||||||
|
public email: string
|
||||||
|
|
||||||
|
constructor(email: string) {
|
||||||
|
this.email = email
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* ✅ GOOD EXAMPLE: Reference by ID
|
||||||
|
*
|
||||||
|
* Best Practice: Order aggregate references other aggregates only by their IDs
|
||||||
|
*
|
||||||
|
* Benefits:
|
||||||
|
* 1. Loose coupling between aggregates
|
||||||
|
* 2. Each aggregate can be modified independently
|
||||||
|
* 3. Follows DDD aggregate boundary principles
|
||||||
|
* 4. Clear separation of concerns
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { UserId } from "../user/value-objects/UserId"
|
||||||
|
import { ProductId } from "../product/value-objects/ProductId"
|
||||||
|
|
||||||
|
export class Order {
|
||||||
|
private id: string
|
||||||
|
private userId: UserId
|
||||||
|
private productId: ProductId
|
||||||
|
private quantity: number
|
||||||
|
|
||||||
|
constructor(id: string, userId: UserId, productId: ProductId, quantity: number) {
|
||||||
|
this.id = id
|
||||||
|
this.userId = userId
|
||||||
|
this.productId = productId
|
||||||
|
this.quantity = quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
getUserId(): UserId {
|
||||||
|
return this.userId
|
||||||
|
}
|
||||||
|
|
||||||
|
getProductId(): ProductId {
|
||||||
|
return this.productId
|
||||||
|
}
|
||||||
|
|
||||||
|
getQuantity(): number {
|
||||||
|
return this.quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* ✅ GOOD EXAMPLE: Using Value Objects for Needed Data
|
||||||
|
*
|
||||||
|
* Best Practice: When Order needs specific data from other aggregates,
|
||||||
|
* use Value Objects to store that data (denormalization)
|
||||||
|
*
|
||||||
|
* Benefits:
|
||||||
|
* 1. Order aggregate has all data it needs
|
||||||
|
* 2. No runtime dependency on other aggregates
|
||||||
|
* 3. Better performance (no joins needed)
|
||||||
|
* 4. Clear contract through Value Objects
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { UserId } from "../user/value-objects/UserId"
|
||||||
|
import { ProductId } from "../product/value-objects/ProductId"
|
||||||
|
|
||||||
|
export class CustomerInfo {
|
||||||
|
constructor(
|
||||||
|
readonly customerId: UserId,
|
||||||
|
readonly customerName: string,
|
||||||
|
readonly customerEmail: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProductInfo {
|
||||||
|
constructor(
|
||||||
|
readonly productId: ProductId,
|
||||||
|
readonly productName: string,
|
||||||
|
readonly productPrice: number,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Order {
|
||||||
|
private id: string
|
||||||
|
private customer: CustomerInfo
|
||||||
|
private product: ProductInfo
|
||||||
|
private quantity: number
|
||||||
|
|
||||||
|
constructor(id: string, customer: CustomerInfo, product: ProductInfo, quantity: number) {
|
||||||
|
this.id = id
|
||||||
|
this.customer = customer
|
||||||
|
this.product = product
|
||||||
|
this.quantity = quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
getCustomerEmail(): string {
|
||||||
|
return this.customer.customerEmail
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateTotal(): number {
|
||||||
|
return this.product.productPrice * this.quantity
|
||||||
|
}
|
||||||
|
|
||||||
|
getCustomerInfo(): CustomerInfo {
|
||||||
|
return this.customer
|
||||||
|
}
|
||||||
|
|
||||||
|
getProductInfo(): ProductInfo {
|
||||||
|
return this.product
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@samiyev/guardian",
|
"name": "@samiyev/guardian",
|
||||||
"version": "0.6.2",
|
"version": "0.7.1",
|
||||||
"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.",
|
"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": [
|
"keywords": [
|
||||||
"puaros",
|
"puaros",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { IFrameworkLeakDetector } from "./domain/services/IFrameworkLeakDetector
|
|||||||
import { IEntityExposureDetector } from "./domain/services/IEntityExposureDetector"
|
import { IEntityExposureDetector } from "./domain/services/IEntityExposureDetector"
|
||||||
import { IDependencyDirectionDetector } from "./domain/services/IDependencyDirectionDetector"
|
import { IDependencyDirectionDetector } from "./domain/services/IDependencyDirectionDetector"
|
||||||
import { IRepositoryPatternDetector } from "./domain/services/RepositoryPatternDetectorService"
|
import { IRepositoryPatternDetector } from "./domain/services/RepositoryPatternDetectorService"
|
||||||
|
import { IAggregateBoundaryDetector } from "./domain/services/IAggregateBoundaryDetector"
|
||||||
import { FileScanner } from "./infrastructure/scanners/FileScanner"
|
import { FileScanner } from "./infrastructure/scanners/FileScanner"
|
||||||
import { CodeParser } from "./infrastructure/parsers/CodeParser"
|
import { CodeParser } from "./infrastructure/parsers/CodeParser"
|
||||||
import { HardcodeDetector } from "./infrastructure/analyzers/HardcodeDetector"
|
import { HardcodeDetector } from "./infrastructure/analyzers/HardcodeDetector"
|
||||||
@@ -19,6 +20,7 @@ import { FrameworkLeakDetector } from "./infrastructure/analyzers/FrameworkLeakD
|
|||||||
import { EntityExposureDetector } from "./infrastructure/analyzers/EntityExposureDetector"
|
import { EntityExposureDetector } from "./infrastructure/analyzers/EntityExposureDetector"
|
||||||
import { DependencyDirectionDetector } from "./infrastructure/analyzers/DependencyDirectionDetector"
|
import { DependencyDirectionDetector } from "./infrastructure/analyzers/DependencyDirectionDetector"
|
||||||
import { RepositoryPatternDetector } from "./infrastructure/analyzers/RepositoryPatternDetector"
|
import { RepositoryPatternDetector } from "./infrastructure/analyzers/RepositoryPatternDetector"
|
||||||
|
import { AggregateBoundaryDetector } from "./infrastructure/analyzers/AggregateBoundaryDetector"
|
||||||
import { ERROR_MESSAGES } from "./shared/constants"
|
import { ERROR_MESSAGES } from "./shared/constants"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +78,7 @@ export async function analyzeProject(
|
|||||||
const dependencyDirectionDetector: IDependencyDirectionDetector =
|
const dependencyDirectionDetector: IDependencyDirectionDetector =
|
||||||
new DependencyDirectionDetector()
|
new DependencyDirectionDetector()
|
||||||
const repositoryPatternDetector: IRepositoryPatternDetector = new RepositoryPatternDetector()
|
const repositoryPatternDetector: IRepositoryPatternDetector = new RepositoryPatternDetector()
|
||||||
|
const aggregateBoundaryDetector: IAggregateBoundaryDetector = new AggregateBoundaryDetector()
|
||||||
const useCase = new AnalyzeProject(
|
const useCase = new AnalyzeProject(
|
||||||
fileScanner,
|
fileScanner,
|
||||||
codeParser,
|
codeParser,
|
||||||
@@ -85,6 +88,7 @@ export async function analyzeProject(
|
|||||||
entityExposureDetector,
|
entityExposureDetector,
|
||||||
dependencyDirectionDetector,
|
dependencyDirectionDetector,
|
||||||
repositoryPatternDetector,
|
repositoryPatternDetector,
|
||||||
|
aggregateBoundaryDetector,
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = await useCase.execute(options)
|
const result = await useCase.execute(options)
|
||||||
@@ -107,5 +111,6 @@ export type {
|
|||||||
EntityExposureViolation,
|
EntityExposureViolation,
|
||||||
DependencyDirectionViolation,
|
DependencyDirectionViolation,
|
||||||
RepositoryPatternViolation,
|
RepositoryPatternViolation,
|
||||||
|
AggregateBoundaryViolation,
|
||||||
ProjectMetrics,
|
ProjectMetrics,
|
||||||
} from "./application/use-cases/AnalyzeProject"
|
} from "./application/use-cases/AnalyzeProject"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { IFrameworkLeakDetector } from "../../domain/services/IFrameworkLeakDete
|
|||||||
import { IEntityExposureDetector } from "../../domain/services/IEntityExposureDetector"
|
import { IEntityExposureDetector } from "../../domain/services/IEntityExposureDetector"
|
||||||
import { IDependencyDirectionDetector } from "../../domain/services/IDependencyDirectionDetector"
|
import { IDependencyDirectionDetector } from "../../domain/services/IDependencyDirectionDetector"
|
||||||
import { IRepositoryPatternDetector } from "../../domain/services/RepositoryPatternDetectorService"
|
import { IRepositoryPatternDetector } from "../../domain/services/RepositoryPatternDetectorService"
|
||||||
|
import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector"
|
||||||
import { SourceFile } from "../../domain/entities/SourceFile"
|
import { SourceFile } from "../../domain/entities/SourceFile"
|
||||||
import { DependencyGraph } from "../../domain/entities/DependencyGraph"
|
import { DependencyGraph } from "../../domain/entities/DependencyGraph"
|
||||||
import { ProjectPath } from "../../domain/value-objects/ProjectPath"
|
import { ProjectPath } from "../../domain/value-objects/ProjectPath"
|
||||||
@@ -41,6 +42,7 @@ export interface AnalyzeProjectResponse {
|
|||||||
entityExposureViolations: EntityExposureViolation[]
|
entityExposureViolations: EntityExposureViolation[]
|
||||||
dependencyDirectionViolations: DependencyDirectionViolation[]
|
dependencyDirectionViolations: DependencyDirectionViolation[]
|
||||||
repositoryPatternViolations: RepositoryPatternViolation[]
|
repositoryPatternViolations: RepositoryPatternViolation[]
|
||||||
|
aggregateBoundaryViolations: AggregateBoundaryViolation[]
|
||||||
metrics: ProjectMetrics
|
metrics: ProjectMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +151,19 @@ export interface RepositoryPatternViolation {
|
|||||||
severity: SeverityLevel
|
severity: SeverityLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AggregateBoundaryViolation {
|
||||||
|
rule: typeof RULES.AGGREGATE_BOUNDARY
|
||||||
|
fromAggregate: string
|
||||||
|
toAggregate: string
|
||||||
|
entityName: string
|
||||||
|
importPath: string
|
||||||
|
file: string
|
||||||
|
line?: number
|
||||||
|
message: string
|
||||||
|
suggestion: string
|
||||||
|
severity: SeverityLevel
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectMetrics {
|
export interface ProjectMetrics {
|
||||||
totalFiles: number
|
totalFiles: number
|
||||||
totalFunctions: number
|
totalFunctions: number
|
||||||
@@ -172,6 +187,7 @@ export class AnalyzeProject extends UseCase<
|
|||||||
private readonly entityExposureDetector: IEntityExposureDetector,
|
private readonly entityExposureDetector: IEntityExposureDetector,
|
||||||
private readonly dependencyDirectionDetector: IDependencyDirectionDetector,
|
private readonly dependencyDirectionDetector: IDependencyDirectionDetector,
|
||||||
private readonly repositoryPatternDetector: IRepositoryPatternDetector,
|
private readonly repositoryPatternDetector: IRepositoryPatternDetector,
|
||||||
|
private readonly aggregateBoundaryDetector: IAggregateBoundaryDetector,
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
@@ -234,6 +250,9 @@ export class AnalyzeProject extends UseCase<
|
|||||||
const repositoryPatternViolations = this.sortBySeverity(
|
const repositoryPatternViolations = this.sortBySeverity(
|
||||||
this.detectRepositoryPatternViolations(sourceFiles),
|
this.detectRepositoryPatternViolations(sourceFiles),
|
||||||
)
|
)
|
||||||
|
const aggregateBoundaryViolations = this.sortBySeverity(
|
||||||
|
this.detectAggregateBoundaryViolations(sourceFiles),
|
||||||
|
)
|
||||||
const metrics = this.calculateMetrics(sourceFiles, totalFunctions, dependencyGraph)
|
const metrics = this.calculateMetrics(sourceFiles, totalFunctions, dependencyGraph)
|
||||||
|
|
||||||
return ResponseDto.ok({
|
return ResponseDto.ok({
|
||||||
@@ -247,6 +266,7 @@ export class AnalyzeProject extends UseCase<
|
|||||||
entityExposureViolations,
|
entityExposureViolations,
|
||||||
dependencyDirectionViolations,
|
dependencyDirectionViolations,
|
||||||
repositoryPatternViolations,
|
repositoryPatternViolations,
|
||||||
|
aggregateBoundaryViolations,
|
||||||
metrics,
|
metrics,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -532,6 +552,37 @@ export class AnalyzeProject extends UseCase<
|
|||||||
return violations
|
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(
|
private calculateMetrics(
|
||||||
sourceFiles: SourceFile[],
|
sourceFiles: SourceFile[],
|
||||||
totalFunctions: number,
|
totalFunctions: number,
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ program
|
|||||||
entityExposureViolations,
|
entityExposureViolations,
|
||||||
dependencyDirectionViolations,
|
dependencyDirectionViolations,
|
||||||
repositoryPatternViolations,
|
repositoryPatternViolations,
|
||||||
|
aggregateBoundaryViolations,
|
||||||
} = result
|
} = result
|
||||||
|
|
||||||
const minSeverity: SeverityLevel | undefined = options.onlyCritical
|
const minSeverity: SeverityLevel | undefined = options.onlyCritical
|
||||||
@@ -185,6 +186,10 @@ program
|
|||||||
repositoryPatternViolations,
|
repositoryPatternViolations,
|
||||||
minSeverity,
|
minSeverity,
|
||||||
)
|
)
|
||||||
|
aggregateBoundaryViolations = filterBySeverity(
|
||||||
|
aggregateBoundaryViolations,
|
||||||
|
minSeverity,
|
||||||
|
)
|
||||||
|
|
||||||
if (options.onlyCritical) {
|
if (options.onlyCritical) {
|
||||||
console.log("\n🔴 Filtering: Showing only CRITICAL severity issues\n")
|
console.log("\n🔴 Filtering: Showing only CRITICAL severity issues\n")
|
||||||
@@ -374,6 +379,35 @@ program
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aggregate boundary violations
|
||||||
|
if (options.architecture && aggregateBoundaryViolations.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`\n🔒 Found ${String(aggregateBoundaryViolations.length)} aggregate boundary violation(s)`,
|
||||||
|
)
|
||||||
|
|
||||||
|
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("")
|
||||||
|
},
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Hardcode violations
|
// Hardcode violations
|
||||||
if (options.hardcode && hardcodeViolations.length > 0) {
|
if (options.hardcode && hardcodeViolations.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -407,7 +441,8 @@ program
|
|||||||
frameworkLeakViolations.length +
|
frameworkLeakViolations.length +
|
||||||
entityExposureViolations.length +
|
entityExposureViolations.length +
|
||||||
dependencyDirectionViolations.length +
|
dependencyDirectionViolations.length +
|
||||||
repositoryPatternViolations.length
|
repositoryPatternViolations.length +
|
||||||
|
aggregateBoundaryViolations.length
|
||||||
|
|
||||||
if (totalIssues === 0) {
|
if (totalIssues === 0) {
|
||||||
console.log(CLI_MESSAGES.NO_ISSUES)
|
console.log(CLI_MESSAGES.NO_ISSUES)
|
||||||
|
|||||||
@@ -48,3 +48,15 @@ export const REPOSITORY_PATTERN_MESSAGES = {
|
|||||||
SUGGESTION_DELETE: "remove or delete",
|
SUGGESTION_DELETE: "remove or delete",
|
||||||
SUGGESTION_QUERY: "find or search",
|
SUGGESTION_QUERY: "find or search",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const REPOSITORY_FALLBACK_SUGGESTIONS = {
|
||||||
|
DEFAULT: "findById() or findByEmail()",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AGGREGATE_VIOLATION_MESSAGES = {
|
||||||
|
USE_ID_REFERENCE: "1. Reference other aggregates by ID (UserId, OrderId) instead of entity",
|
||||||
|
USE_VALUE_OBJECT:
|
||||||
|
"2. Use Value Objects to store needed data from other aggregates (CustomerInfo, ProductSummary)",
|
||||||
|
AVOID_DIRECT_REFERENCE: "3. Avoid direct entity references to maintain aggregate independence",
|
||||||
|
MAINTAIN_INDEPENDENCE: "4. Each aggregate should be independently modifiable and deployable",
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { AggregateBoundaryViolation } from "../value-objects/AggregateBoundaryViolation"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for detecting aggregate boundary violations in DDD
|
||||||
|
*
|
||||||
|
* Aggregate boundary violations occur when an entity from one aggregate
|
||||||
|
* directly references an entity from another aggregate. In DDD, aggregates
|
||||||
|
* should reference each other only by ID or Value Objects to maintain
|
||||||
|
* loose coupling and independence.
|
||||||
|
*/
|
||||||
|
export interface IAggregateBoundaryDetector {
|
||||||
|
/**
|
||||||
|
* Detects aggregate boundary violations in the given code
|
||||||
|
*
|
||||||
|
* Analyzes import statements to identify direct entity references
|
||||||
|
* across aggregate boundaries.
|
||||||
|
*
|
||||||
|
* @param code - Source code to analyze
|
||||||
|
* @param filePath - Path to the file being analyzed
|
||||||
|
* @param layer - The architectural layer of the file (should be 'domain')
|
||||||
|
* @returns Array of detected aggregate boundary violations
|
||||||
|
*/
|
||||||
|
detectViolations(
|
||||||
|
code: string,
|
||||||
|
filePath: string,
|
||||||
|
layer: string | undefined,
|
||||||
|
): AggregateBoundaryViolation[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a file path belongs to an aggregate
|
||||||
|
*
|
||||||
|
* @param filePath - The file path to check
|
||||||
|
* @returns The aggregate name if found, undefined otherwise
|
||||||
|
*/
|
||||||
|
extractAggregateFromPath(filePath: string): string | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an import path references an entity from another aggregate
|
||||||
|
*
|
||||||
|
* @param importPath - The import path to analyze
|
||||||
|
* @param currentAggregate - The aggregate of the current file
|
||||||
|
* @returns True if the import crosses aggregate boundaries inappropriately
|
||||||
|
*/
|
||||||
|
isAggregateBoundaryViolation(importPath: string, currentAggregate: string): boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { ValueObject } from "./ValueObject"
|
||||||
|
import { AGGREGATE_VIOLATION_MESSAGES } from "../constants/Messages"
|
||||||
|
|
||||||
|
interface AggregateBoundaryViolationProps {
|
||||||
|
readonly fromAggregate: string
|
||||||
|
readonly toAggregate: string
|
||||||
|
readonly entityName: string
|
||||||
|
readonly importPath: string
|
||||||
|
readonly filePath: string
|
||||||
|
readonly line?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an aggregate boundary violation in the codebase
|
||||||
|
*
|
||||||
|
* Aggregate boundary violations occur when an entity from one aggregate
|
||||||
|
* directly references an entity from another aggregate, violating DDD principles:
|
||||||
|
* - Aggregates should reference each other only by ID or Value Objects
|
||||||
|
* - Direct entity references create tight coupling between aggregates
|
||||||
|
* - Changes to one aggregate should not require changes to another
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Bad: Direct entity reference across aggregates
|
||||||
|
* const violation = AggregateBoundaryViolation.create(
|
||||||
|
* 'order',
|
||||||
|
* 'user',
|
||||||
|
* 'User',
|
||||||
|
* '../user/User',
|
||||||
|
* 'src/domain/aggregates/order/Order.ts',
|
||||||
|
* 5
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* console.log(violation.getMessage())
|
||||||
|
* // "Order aggregate should not directly reference User entity from User aggregate"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class AggregateBoundaryViolation extends ValueObject<AggregateBoundaryViolationProps> {
|
||||||
|
private constructor(props: AggregateBoundaryViolationProps) {
|
||||||
|
super(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static create(
|
||||||
|
fromAggregate: string,
|
||||||
|
toAggregate: string,
|
||||||
|
entityName: string,
|
||||||
|
importPath: string,
|
||||||
|
filePath: string,
|
||||||
|
line?: number,
|
||||||
|
): AggregateBoundaryViolation {
|
||||||
|
return new AggregateBoundaryViolation({
|
||||||
|
fromAggregate,
|
||||||
|
toAggregate,
|
||||||
|
entityName,
|
||||||
|
importPath,
|
||||||
|
filePath,
|
||||||
|
line,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public get fromAggregate(): string {
|
||||||
|
return this.props.fromAggregate
|
||||||
|
}
|
||||||
|
|
||||||
|
public get toAggregate(): string {
|
||||||
|
return this.props.toAggregate
|
||||||
|
}
|
||||||
|
|
||||||
|
public get entityName(): string {
|
||||||
|
return this.props.entityName
|
||||||
|
}
|
||||||
|
|
||||||
|
public get importPath(): string {
|
||||||
|
return this.props.importPath
|
||||||
|
}
|
||||||
|
|
||||||
|
public get filePath(): string {
|
||||||
|
return this.props.filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
public get line(): number | undefined {
|
||||||
|
return this.props.line
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMessage(): string {
|
||||||
|
return `${this.capitalizeFirst(this.props.fromAggregate)} aggregate should not directly reference ${this.props.entityName} entity from ${this.capitalizeFirst(this.props.toAggregate)} aggregate`
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSuggestion(): string {
|
||||||
|
const suggestions: string[] = [
|
||||||
|
AGGREGATE_VIOLATION_MESSAGES.USE_ID_REFERENCE,
|
||||||
|
AGGREGATE_VIOLATION_MESSAGES.USE_VALUE_OBJECT,
|
||||||
|
AGGREGATE_VIOLATION_MESSAGES.AVOID_DIRECT_REFERENCE,
|
||||||
|
AGGREGATE_VIOLATION_MESSAGES.MAINTAIN_INDEPENDENCE,
|
||||||
|
]
|
||||||
|
|
||||||
|
return suggestions.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
public getExampleFix(): string {
|
||||||
|
return `
|
||||||
|
// ❌ Bad: Direct entity reference across aggregates
|
||||||
|
// domain/aggregates/order/Order.ts
|
||||||
|
import { User } from '../user/User'
|
||||||
|
|
||||||
|
class Order {
|
||||||
|
constructor(private user: User) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good: Reference by ID
|
||||||
|
// domain/aggregates/order/Order.ts
|
||||||
|
import { UserId } from '../user/value-objects/UserId'
|
||||||
|
|
||||||
|
class Order {
|
||||||
|
constructor(private userId: UserId) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good: Use Value Object for needed data
|
||||||
|
// domain/aggregates/order/value-objects/CustomerInfo.ts
|
||||||
|
class CustomerInfo {
|
||||||
|
constructor(
|
||||||
|
readonly customerId: string,
|
||||||
|
readonly customerName: string,
|
||||||
|
readonly customerEmail: string
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// domain/aggregates/order/Order.ts
|
||||||
|
class Order {
|
||||||
|
constructor(private customerInfo: CustomerInfo) {}
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private capitalizeFirst(str: string): string {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ValueObject } from "./ValueObject"
|
import { ValueObject } from "./ValueObject"
|
||||||
import { REPOSITORY_VIOLATION_TYPES } from "../../shared/constants/rules"
|
import { REPOSITORY_VIOLATION_TYPES } from "../../shared/constants/rules"
|
||||||
import { REPOSITORY_PATTERN_MESSAGES } from "../constants/Messages"
|
import { REPOSITORY_FALLBACK_SUGGESTIONS, REPOSITORY_PATTERN_MESSAGES } from "../constants/Messages"
|
||||||
|
|
||||||
interface RepositoryViolationProps {
|
interface RepositoryViolationProps {
|
||||||
readonly violationType:
|
readonly violationType:
|
||||||
@@ -177,6 +177,9 @@ export class RepositoryViolation extends ValueObject<RepositoryViolationProps> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getNonDomainMethodSuggestion(): string {
|
private getNonDomainMethodSuggestion(): string {
|
||||||
|
const detailsMatch = /Consider: (.+)$/.exec(this.props.details)
|
||||||
|
const smartSuggestion = detailsMatch ? detailsMatch[1] : null
|
||||||
|
|
||||||
const technicalToDomain = {
|
const technicalToDomain = {
|
||||||
findOne: REPOSITORY_PATTERN_MESSAGES.SUGGESTION_FINDONE,
|
findOne: REPOSITORY_PATTERN_MESSAGES.SUGGESTION_FINDONE,
|
||||||
findMany: REPOSITORY_PATTERN_MESSAGES.SUGGESTION_FINDMANY,
|
findMany: REPOSITORY_PATTERN_MESSAGES.SUGGESTION_FINDMANY,
|
||||||
@@ -186,8 +189,10 @@ export class RepositoryViolation extends ValueObject<RepositoryViolationProps> {
|
|||||||
query: REPOSITORY_PATTERN_MESSAGES.SUGGESTION_QUERY,
|
query: REPOSITORY_PATTERN_MESSAGES.SUGGESTION_QUERY,
|
||||||
}
|
}
|
||||||
|
|
||||||
const suggestion =
|
const fallbackSuggestion =
|
||||||
technicalToDomain[this.props.methodName as keyof typeof technicalToDomain]
|
technicalToDomain[this.props.methodName as keyof typeof technicalToDomain]
|
||||||
|
const finalSuggestion =
|
||||||
|
smartSuggestion || fallbackSuggestion || REPOSITORY_FALLBACK_SUGGESTIONS.DEFAULT
|
||||||
|
|
||||||
return [
|
return [
|
||||||
REPOSITORY_PATTERN_MESSAGES.STEP_RENAME_METHOD,
|
REPOSITORY_PATTERN_MESSAGES.STEP_RENAME_METHOD,
|
||||||
@@ -196,7 +201,7 @@ export class RepositoryViolation extends ValueObject<RepositoryViolationProps> {
|
|||||||
"",
|
"",
|
||||||
REPOSITORY_PATTERN_MESSAGES.EXAMPLE_PREFIX,
|
REPOSITORY_PATTERN_MESSAGES.EXAMPLE_PREFIX,
|
||||||
`❌ Bad: ${this.props.methodName || "findOne"}()`,
|
`❌ Bad: ${this.props.methodName || "findOne"}()`,
|
||||||
`✅ Good: ${suggestion || "findById() or findByEmail()"}`,
|
`✅ Good: ${finalSuggestion}`,
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector"
|
||||||
|
import { AggregateBoundaryViolation } from "../../domain/value-objects/AggregateBoundaryViolation"
|
||||||
|
import { LAYERS } from "../../shared/constants/rules"
|
||||||
|
import { IMPORT_PATTERNS } from "../constants/paths"
|
||||||
|
import { DDD_FOLDER_NAMES } from "../constants/detectorPatterns"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects aggregate boundary violations in Domain-Driven Design
|
||||||
|
*
|
||||||
|
* This detector enforces DDD aggregate rules:
|
||||||
|
* - Aggregates should reference each other only by ID or Value Objects
|
||||||
|
* - Direct entity references across aggregates create tight coupling
|
||||||
|
* - Each aggregate should be independently modifiable
|
||||||
|
*
|
||||||
|
* Folder structure patterns detected:
|
||||||
|
* - domain/aggregates/order/Order.ts
|
||||||
|
* - domain/order/Order.ts (aggregate name from parent folder)
|
||||||
|
* - domain/entities/order/Order.ts
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const detector = new AggregateBoundaryDetector()
|
||||||
|
*
|
||||||
|
* // Detect violations in order aggregate
|
||||||
|
* const code = `
|
||||||
|
* import { User } from '../user/User'
|
||||||
|
* import { UserId } from '../user/value-objects/UserId'
|
||||||
|
* `
|
||||||
|
* const violations = detector.detectViolations(
|
||||||
|
* code,
|
||||||
|
* 'src/domain/aggregates/order/Order.ts',
|
||||||
|
* 'domain'
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* // violations will contain 1 violation for direct User entity import
|
||||||
|
* // but not for UserId (value object is allowed)
|
||||||
|
* console.log(violations.length) // 1
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class AggregateBoundaryDetector implements IAggregateBoundaryDetector {
|
||||||
|
private readonly entityFolderNames = new Set<string>([
|
||||||
|
DDD_FOLDER_NAMES.ENTITIES,
|
||||||
|
DDD_FOLDER_NAMES.AGGREGATES,
|
||||||
|
])
|
||||||
|
private readonly valueObjectFolderNames = new Set<string>([
|
||||||
|
DDD_FOLDER_NAMES.VALUE_OBJECTS,
|
||||||
|
DDD_FOLDER_NAMES.VO,
|
||||||
|
])
|
||||||
|
private readonly allowedFolderNames = new Set<string>([
|
||||||
|
DDD_FOLDER_NAMES.VALUE_OBJECTS,
|
||||||
|
DDD_FOLDER_NAMES.VO,
|
||||||
|
DDD_FOLDER_NAMES.EVENTS,
|
||||||
|
DDD_FOLDER_NAMES.DOMAIN_EVENTS,
|
||||||
|
DDD_FOLDER_NAMES.REPOSITORIES,
|
||||||
|
DDD_FOLDER_NAMES.SERVICES,
|
||||||
|
DDD_FOLDER_NAMES.SPECIFICATIONS,
|
||||||
|
])
|
||||||
|
private readonly nonAggregateFolderNames = new Set<string>([
|
||||||
|
DDD_FOLDER_NAMES.VALUE_OBJECTS,
|
||||||
|
DDD_FOLDER_NAMES.VO,
|
||||||
|
DDD_FOLDER_NAMES.EVENTS,
|
||||||
|
DDD_FOLDER_NAMES.DOMAIN_EVENTS,
|
||||||
|
DDD_FOLDER_NAMES.REPOSITORIES,
|
||||||
|
DDD_FOLDER_NAMES.SERVICES,
|
||||||
|
DDD_FOLDER_NAMES.SPECIFICATIONS,
|
||||||
|
DDD_FOLDER_NAMES.ENTITIES,
|
||||||
|
DDD_FOLDER_NAMES.CONSTANTS,
|
||||||
|
DDD_FOLDER_NAMES.SHARED,
|
||||||
|
DDD_FOLDER_NAMES.FACTORIES,
|
||||||
|
DDD_FOLDER_NAMES.PORTS,
|
||||||
|
DDD_FOLDER_NAMES.INTERFACES,
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects aggregate boundary violations in the given code
|
||||||
|
*
|
||||||
|
* Analyzes import statements to identify direct entity references
|
||||||
|
* across aggregate boundaries in the domain layer.
|
||||||
|
*
|
||||||
|
* @param code - Source code to analyze
|
||||||
|
* @param filePath - Path to the file being analyzed
|
||||||
|
* @param layer - The architectural layer of the file (should be 'domain')
|
||||||
|
* @returns Array of detected aggregate boundary violations
|
||||||
|
*/
|
||||||
|
public detectViolations(
|
||||||
|
code: string,
|
||||||
|
filePath: string,
|
||||||
|
layer: string | undefined,
|
||||||
|
): AggregateBoundaryViolation[] {
|
||||||
|
if (layer !== LAYERS.DOMAIN) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAggregate = this.extractAggregateFromPath(filePath)
|
||||||
|
if (!currentAggregate) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const violations: AggregateBoundaryViolation[] = []
|
||||||
|
const lines = code.split("\n")
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
const lineNumber = i + 1
|
||||||
|
|
||||||
|
const imports = this.extractImports(line)
|
||||||
|
for (const importPath of imports) {
|
||||||
|
if (this.isAggregateBoundaryViolation(importPath, currentAggregate)) {
|
||||||
|
const targetAggregate = this.extractAggregateFromImport(importPath)
|
||||||
|
const entityName = this.extractEntityName(importPath)
|
||||||
|
|
||||||
|
if (targetAggregate && entityName) {
|
||||||
|
violations.push(
|
||||||
|
AggregateBoundaryViolation.create(
|
||||||
|
currentAggregate,
|
||||||
|
targetAggregate,
|
||||||
|
entityName,
|
||||||
|
importPath,
|
||||||
|
filePath,
|
||||||
|
lineNumber,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a file path belongs to an aggregate
|
||||||
|
*
|
||||||
|
* Extracts aggregate name from paths like:
|
||||||
|
* - domain/aggregates/order/Order.ts → 'order'
|
||||||
|
* - domain/order/Order.ts → 'order'
|
||||||
|
* - domain/entities/order/Order.ts → 'order'
|
||||||
|
*
|
||||||
|
* @param filePath - The file path to check
|
||||||
|
* @returns The aggregate name if found, undefined otherwise
|
||||||
|
*/
|
||||||
|
public extractAggregateFromPath(filePath: string): string | undefined {
|
||||||
|
const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/")
|
||||||
|
|
||||||
|
const domainMatch = /(?:^|\/)(domain)\//.exec(normalizedPath)
|
||||||
|
if (!domainMatch) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainEndIndex = domainMatch.index + domainMatch[0].length
|
||||||
|
const pathAfterDomain = normalizedPath.substring(domainEndIndex)
|
||||||
|
const segments = pathAfterDomain.split("/").filter(Boolean)
|
||||||
|
|
||||||
|
if (segments.length < 2) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.entityFolderNames.has(segments[0])) {
|
||||||
|
if (segments.length < 3) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
const aggregate = segments[1]
|
||||||
|
if (this.nonAggregateFolderNames.has(aggregate)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return aggregate
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregate = segments[0]
|
||||||
|
if (this.nonAggregateFolderNames.has(aggregate)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return aggregate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an import path references an entity from another aggregate
|
||||||
|
*
|
||||||
|
* @param importPath - The import path to analyze
|
||||||
|
* @param currentAggregate - The aggregate of the current file
|
||||||
|
* @returns True if the import crosses aggregate boundaries inappropriately
|
||||||
|
*/
|
||||||
|
public isAggregateBoundaryViolation(importPath: string, currentAggregate: string): boolean {
|
||||||
|
const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "").toLowerCase()
|
||||||
|
|
||||||
|
if (!normalizedPath.includes("/")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedPath.startsWith(".") && !normalizedPath.startsWith("/")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetAggregate = this.extractAggregateFromImport(normalizedPath)
|
||||||
|
if (!targetAggregate || targetAggregate === currentAggregate) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isAllowedImport(normalizedPath)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.seemsLikeEntityImport(normalizedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the import path is from an allowed folder (value-objects, events, etc.)
|
||||||
|
*/
|
||||||
|
private isAllowedImport(normalizedPath: string): boolean {
|
||||||
|
for (const folderName of this.allowedFolderNames) {
|
||||||
|
if (normalizedPath.includes(`/${folderName}/`)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the import seems to be an entity (not a value object, event, etc.)
|
||||||
|
*
|
||||||
|
* Note: normalizedPath is already lowercased, so we check if the first character
|
||||||
|
* is a letter (indicating it was likely PascalCase originally)
|
||||||
|
*/
|
||||||
|
private seemsLikeEntityImport(normalizedPath: string): boolean {
|
||||||
|
const pathParts = normalizedPath.split("/")
|
||||||
|
const lastPart = pathParts[pathParts.length - 1]
|
||||||
|
|
||||||
|
if (!lastPart) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = lastPart.replace(/\.(ts|js)$/, "")
|
||||||
|
|
||||||
|
if (filename.length > 0 && /^[a-z][a-z]/.exec(filename)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the aggregate name from an import path
|
||||||
|
*
|
||||||
|
* Handles both absolute and relative paths:
|
||||||
|
* - ../user/User → user
|
||||||
|
* - ../../domain/user/User → user
|
||||||
|
* - ../user/value-objects/UserId → user (but filtered as value object)
|
||||||
|
*/
|
||||||
|
private extractAggregateFromImport(importPath: string): string | undefined {
|
||||||
|
const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "").toLowerCase()
|
||||||
|
|
||||||
|
const segments = normalizedPath.split("/").filter((seg) => seg !== ".." && seg !== ".")
|
||||||
|
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
if (
|
||||||
|
segments[i] === DDD_FOLDER_NAMES.DOMAIN ||
|
||||||
|
segments[i] === DDD_FOLDER_NAMES.AGGREGATES
|
||||||
|
) {
|
||||||
|
if (i + 1 < segments.length) {
|
||||||
|
if (
|
||||||
|
this.entityFolderNames.has(segments[i + 1]) ||
|
||||||
|
segments[i + 1] === DDD_FOLDER_NAMES.AGGREGATES
|
||||||
|
) {
|
||||||
|
if (i + 2 < segments.length) {
|
||||||
|
return segments[i + 2]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return segments[i + 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length >= 2) {
|
||||||
|
const secondLastSegment = segments[segments.length - 2]
|
||||||
|
|
||||||
|
if (
|
||||||
|
!this.entityFolderNames.has(secondLastSegment) &&
|
||||||
|
!this.valueObjectFolderNames.has(secondLastSegment) &&
|
||||||
|
!this.allowedFolderNames.has(secondLastSegment) &&
|
||||||
|
secondLastSegment !== DDD_FOLDER_NAMES.DOMAIN
|
||||||
|
) {
|
||||||
|
return secondLastSegment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length === 1) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the entity name from an import path
|
||||||
|
*/
|
||||||
|
private extractEntityName(importPath: string): string | undefined {
|
||||||
|
const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "")
|
||||||
|
const segments = normalizedPath.split("/")
|
||||||
|
const lastSegment = segments[segments.length - 1]
|
||||||
|
|
||||||
|
if (lastSegment) {
|
||||||
|
return lastSegment.replace(/\.(ts|js)$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts import paths from a line of code
|
||||||
|
*
|
||||||
|
* Handles various import statement formats:
|
||||||
|
* - import { X } from 'path'
|
||||||
|
* - import X from 'path'
|
||||||
|
* - import * as X from 'path'
|
||||||
|
* - const X = require('path')
|
||||||
|
*
|
||||||
|
* @param line - A line of code to analyze
|
||||||
|
* @returns Array of import paths found in the line
|
||||||
|
*/
|
||||||
|
private extractImports(line: string): string[] {
|
||||||
|
const imports: string[] = []
|
||||||
|
|
||||||
|
let match = IMPORT_PATTERNS.ES_IMPORT.exec(line)
|
||||||
|
while (match) {
|
||||||
|
imports.push(match[1])
|
||||||
|
match = IMPORT_PATTERNS.ES_IMPORT.exec(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
match = IMPORT_PATTERNS.REQUIRE.exec(line)
|
||||||
|
while (match) {
|
||||||
|
imports.push(match[1])
|
||||||
|
match = IMPORT_PATTERNS.REQUIRE.exec(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return imports
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { RepositoryViolation } from "../../domain/value-objects/RepositoryViolat
|
|||||||
import { LAYERS, REPOSITORY_VIOLATION_TYPES } from "../../shared/constants/rules"
|
import { LAYERS, REPOSITORY_VIOLATION_TYPES } from "../../shared/constants/rules"
|
||||||
import { ORM_QUERY_METHODS } from "../constants/orm-methods"
|
import { ORM_QUERY_METHODS } from "../constants/orm-methods"
|
||||||
import { REPOSITORY_PATTERN_MESSAGES } from "../../domain/constants/Messages"
|
import { REPOSITORY_PATTERN_MESSAGES } from "../../domain/constants/Messages"
|
||||||
|
import { REPOSITORY_METHOD_SUGGESTIONS } from "../constants/detectorPatterns"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects Repository Pattern violations in the codebase
|
* Detects Repository Pattern violations in the codebase
|
||||||
@@ -68,16 +69,39 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector {
|
|||||||
|
|
||||||
private readonly domainMethodPatterns = [
|
private readonly domainMethodPatterns = [
|
||||||
/^findBy[A-Z]/,
|
/^findBy[A-Z]/,
|
||||||
/^findAll/,
|
/^findAll$/,
|
||||||
|
/^find[A-Z]/,
|
||||||
/^save$/,
|
/^save$/,
|
||||||
|
/^saveAll$/,
|
||||||
/^create$/,
|
/^create$/,
|
||||||
/^update$/,
|
/^update$/,
|
||||||
/^delete$/,
|
/^delete$/,
|
||||||
|
/^deleteBy[A-Z]/,
|
||||||
|
/^deleteAll$/,
|
||||||
/^remove$/,
|
/^remove$/,
|
||||||
|
/^removeBy[A-Z]/,
|
||||||
|
/^removeAll$/,
|
||||||
/^add$/,
|
/^add$/,
|
||||||
|
/^add[A-Z]/,
|
||||||
/^get[A-Z]/,
|
/^get[A-Z]/,
|
||||||
|
/^getAll$/,
|
||||||
/^search/,
|
/^search/,
|
||||||
/^list/,
|
/^list/,
|
||||||
|
/^has[A-Z]/,
|
||||||
|
/^is[A-Z]/,
|
||||||
|
/^exists$/,
|
||||||
|
/^exists[A-Z]/,
|
||||||
|
/^existsBy[A-Z]/,
|
||||||
|
/^clear[A-Z]/,
|
||||||
|
/^clearAll$/,
|
||||||
|
/^store[A-Z]/,
|
||||||
|
/^initialize$/,
|
||||||
|
/^initializeCollection$/,
|
||||||
|
/^close$/,
|
||||||
|
/^connect$/,
|
||||||
|
/^disconnect$/,
|
||||||
|
/^count$/,
|
||||||
|
/^countBy[A-Z]/,
|
||||||
]
|
]
|
||||||
|
|
||||||
private readonly concreteRepositoryPatterns = [
|
private readonly concreteRepositoryPatterns = [
|
||||||
@@ -226,6 +250,73 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector {
|
|||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suggests better domain method names based on the original method name
|
||||||
|
*/
|
||||||
|
private suggestDomainMethodName(methodName: string): string {
|
||||||
|
const lowerName = methodName.toLowerCase()
|
||||||
|
const suggestions: string[] = []
|
||||||
|
|
||||||
|
const suggestionMap: Record<string, string[]> = {
|
||||||
|
query: [
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.SEARCH,
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
|
||||||
|
],
|
||||||
|
select: [
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
|
||||||
|
],
|
||||||
|
insert: [
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.CREATE,
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.ADD_ENTITY,
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.STORE_ENTITY,
|
||||||
|
],
|
||||||
|
update: [
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.UPDATE,
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.MODIFY_ENTITY,
|
||||||
|
],
|
||||||
|
upsert: [
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.SAVE,
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.STORE_ENTITY,
|
||||||
|
],
|
||||||
|
remove: [
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.DELETE,
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.REMOVE_BY_PROPERTY,
|
||||||
|
],
|
||||||
|
fetch: [
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
|
||||||
|
],
|
||||||
|
retrieve: [
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
|
||||||
|
],
|
||||||
|
load: [
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [keyword, keywords] of Object.entries(suggestionMap)) {
|
||||||
|
if (lowerName.includes(keyword)) {
|
||||||
|
suggestions.push(...keywords)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerName.includes("get") && lowerName.includes("all")) {
|
||||||
|
suggestions.push(
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.FIND_ALL,
|
||||||
|
REPOSITORY_METHOD_SUGGESTIONS.LIST_ALL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suggestions.length === 0) {
|
||||||
|
return REPOSITORY_METHOD_SUGGESTIONS.DEFAULT_SUGGESTION
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Consider: ${suggestions.slice(0, 3).join(", ")}`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects non-domain method names in repository interfaces
|
* Detects non-domain method names in repository interfaces
|
||||||
*/
|
*/
|
||||||
@@ -247,13 +338,14 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector {
|
|||||||
const methodName = methodMatch[1]
|
const methodName = methodMatch[1]
|
||||||
|
|
||||||
if (!this.isDomainMethodName(methodName) && !line.trim().startsWith("//")) {
|
if (!this.isDomainMethodName(methodName) && !line.trim().startsWith("//")) {
|
||||||
|
const suggestion = this.suggestDomainMethodName(methodName)
|
||||||
violations.push(
|
violations.push(
|
||||||
RepositoryViolation.create(
|
RepositoryViolation.create(
|
||||||
REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
|
REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
|
||||||
filePath,
|
filePath,
|
||||||
layer || LAYERS.DOMAIN,
|
layer || LAYERS.DOMAIN,
|
||||||
lineNumber,
|
lineNumber,
|
||||||
`Method '${methodName}' uses technical name instead of domain language`,
|
`Method '${methodName}' uses technical name instead of domain language. ${suggestion}`,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
methodName,
|
methodName,
|
||||||
|
|||||||
@@ -64,3 +64,45 @@ export const NAMING_ERROR_MESSAGES = {
|
|||||||
USE_VERB_NOUN: "Use verb + noun in PascalCase (e.g., CreateUser.ts, UpdateProfile.ts)",
|
USE_VERB_NOUN: "Use verb + noun in PascalCase (e.g., CreateUser.ts, UpdateProfile.ts)",
|
||||||
USE_CASE_START_VERB: "Use cases should start with a verb",
|
USE_CASE_START_VERB: "Use cases should start with a verb",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DDD folder names for aggregate boundary detection
|
||||||
|
*/
|
||||||
|
export const DDD_FOLDER_NAMES = {
|
||||||
|
ENTITIES: "entities",
|
||||||
|
AGGREGATES: "aggregates",
|
||||||
|
VALUE_OBJECTS: "value-objects",
|
||||||
|
VO: "vo",
|
||||||
|
EVENTS: "events",
|
||||||
|
DOMAIN_EVENTS: "domain-events",
|
||||||
|
REPOSITORIES: "repositories",
|
||||||
|
SERVICES: "services",
|
||||||
|
SPECIFICATIONS: "specifications",
|
||||||
|
DOMAIN: "domain",
|
||||||
|
CONSTANTS: "constants",
|
||||||
|
SHARED: "shared",
|
||||||
|
FACTORIES: "factories",
|
||||||
|
PORTS: "ports",
|
||||||
|
INTERFACES: "interfaces",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository method suggestions for domain language
|
||||||
|
*/
|
||||||
|
export const REPOSITORY_METHOD_SUGGESTIONS = {
|
||||||
|
SEARCH: "search",
|
||||||
|
FIND_BY_PROPERTY: "findBy[Property]",
|
||||||
|
GET_ENTITY: "get[Entity]",
|
||||||
|
CREATE: "create",
|
||||||
|
ADD_ENTITY: "add[Entity]",
|
||||||
|
STORE_ENTITY: "store[Entity]",
|
||||||
|
UPDATE: "update",
|
||||||
|
MODIFY_ENTITY: "modify[Entity]",
|
||||||
|
SAVE: "save",
|
||||||
|
DELETE: "delete",
|
||||||
|
REMOVE_BY_PROPERTY: "removeBy[Property]",
|
||||||
|
FIND_ALL: "findAll",
|
||||||
|
LIST_ALL: "listAll",
|
||||||
|
DEFAULT_SUGGESTION:
|
||||||
|
"Use domain-specific names like: findBy[Property], save, create, delete, update, add[Entity]",
|
||||||
|
} as const
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export const ORM_QUERY_METHODS = [
|
|||||||
"findOne",
|
"findOne",
|
||||||
"findMany",
|
"findMany",
|
||||||
"findFirst",
|
"findFirst",
|
||||||
"findAll",
|
|
||||||
"findAndCountAll",
|
"findAndCountAll",
|
||||||
"insert",
|
"insert",
|
||||||
"insertMany",
|
"insertMany",
|
||||||
@@ -17,8 +16,6 @@ export const ORM_QUERY_METHODS = [
|
|||||||
"run",
|
"run",
|
||||||
"exec",
|
"exec",
|
||||||
"aggregate",
|
"aggregate",
|
||||||
"count",
|
|
||||||
"exists",
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type OrmQueryMethod = (typeof ORM_QUERY_METHODS)[number]
|
export type OrmQueryMethod = (typeof ORM_QUERY_METHODS)[number]
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export const SEVERITY_ORDER: Record<SeverityLevel, number> = {
|
|||||||
export const VIOLATION_SEVERITY_MAP = {
|
export const VIOLATION_SEVERITY_MAP = {
|
||||||
CIRCULAR_DEPENDENCY: SEVERITY_LEVELS.CRITICAL,
|
CIRCULAR_DEPENDENCY: SEVERITY_LEVELS.CRITICAL,
|
||||||
REPOSITORY_PATTERN: SEVERITY_LEVELS.CRITICAL,
|
REPOSITORY_PATTERN: SEVERITY_LEVELS.CRITICAL,
|
||||||
|
AGGREGATE_BOUNDARY: SEVERITY_LEVELS.CRITICAL,
|
||||||
DEPENDENCY_DIRECTION: SEVERITY_LEVELS.HIGH,
|
DEPENDENCY_DIRECTION: SEVERITY_LEVELS.HIGH,
|
||||||
FRAMEWORK_LEAK: SEVERITY_LEVELS.HIGH,
|
FRAMEWORK_LEAK: SEVERITY_LEVELS.HIGH,
|
||||||
ENTITY_EXPOSURE: SEVERITY_LEVELS.HIGH,
|
ENTITY_EXPOSURE: SEVERITY_LEVELS.HIGH,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const RULES = {
|
|||||||
ENTITY_EXPOSURE: "entity-exposure",
|
ENTITY_EXPOSURE: "entity-exposure",
|
||||||
DEPENDENCY_DIRECTION: "dependency-direction",
|
DEPENDENCY_DIRECTION: "dependency-direction",
|
||||||
REPOSITORY_PATTERN: "repository-pattern",
|
REPOSITORY_PATTERN: "repository-pattern",
|
||||||
|
AGGREGATE_BOUNDARY: "aggregate-boundary",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
538
packages/guardian/tests/AggregateBoundaryDetector.test.ts
Normal file
538
packages/guardian/tests/AggregateBoundaryDetector.test.ts
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import { AggregateBoundaryDetector } from "../src/infrastructure/analyzers/AggregateBoundaryDetector"
|
||||||
|
import { LAYERS } from "../src/shared/constants/rules"
|
||||||
|
|
||||||
|
describe("AggregateBoundaryDetector", () => {
|
||||||
|
const detector = new AggregateBoundaryDetector()
|
||||||
|
|
||||||
|
describe("extractAggregateFromPath", () => {
|
||||||
|
it("should extract aggregate from domain/aggregates/name path", () => {
|
||||||
|
expect(detector.extractAggregateFromPath("src/domain/aggregates/order/Order.ts")).toBe(
|
||||||
|
"order",
|
||||||
|
)
|
||||||
|
expect(detector.extractAggregateFromPath("src/domain/aggregates/user/User.ts")).toBe(
|
||||||
|
"user",
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
detector.extractAggregateFromPath("src/domain/aggregates/product/Product.ts"),
|
||||||
|
).toBe("product")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should extract aggregate from domain/name path", () => {
|
||||||
|
expect(detector.extractAggregateFromPath("src/domain/order/Order.ts")).toBe("order")
|
||||||
|
expect(detector.extractAggregateFromPath("src/domain/user/User.ts")).toBe("user")
|
||||||
|
expect(detector.extractAggregateFromPath("src/domain/cart/ShoppingCart.ts")).toBe(
|
||||||
|
"cart",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should extract aggregate from domain/entities/name path", () => {
|
||||||
|
expect(detector.extractAggregateFromPath("src/domain/entities/order/Order.ts")).toBe(
|
||||||
|
"order",
|
||||||
|
)
|
||||||
|
expect(detector.extractAggregateFromPath("src/domain/entities/user/User.ts")).toBe(
|
||||||
|
"user",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return undefined for non-domain paths", () => {
|
||||||
|
expect(
|
||||||
|
detector.extractAggregateFromPath("src/application/use-cases/CreateUser.ts"),
|
||||||
|
).toBeUndefined()
|
||||||
|
expect(
|
||||||
|
detector.extractAggregateFromPath(
|
||||||
|
"src/infrastructure/repositories/UserRepository.ts",
|
||||||
|
),
|
||||||
|
).toBeUndefined()
|
||||||
|
expect(detector.extractAggregateFromPath("src/shared/types/Result.ts")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return undefined for paths without aggregate structure", () => {
|
||||||
|
expect(detector.extractAggregateFromPath("src/domain/User.ts")).toBeUndefined()
|
||||||
|
expect(detector.extractAggregateFromPath("src/User.ts")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle Windows-style paths", () => {
|
||||||
|
expect(
|
||||||
|
detector.extractAggregateFromPath("src\\domain\\aggregates\\order\\Order.ts"),
|
||||||
|
).toBe("order")
|
||||||
|
expect(detector.extractAggregateFromPath("src\\domain\\user\\User.ts")).toBe("user")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isAggregateBoundaryViolation", () => {
|
||||||
|
it("should detect direct entity import from another aggregate", () => {
|
||||||
|
expect(detector.isAggregateBoundaryViolation("../user/User", "order")).toBe(true)
|
||||||
|
expect(detector.isAggregateBoundaryViolation("../../user/User", "order")).toBe(true)
|
||||||
|
expect(
|
||||||
|
detector.isAggregateBoundaryViolation("../../../domain/user/User", "order"),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect import from same aggregate", () => {
|
||||||
|
expect(detector.isAggregateBoundaryViolation("../order/Order", "order")).toBe(false)
|
||||||
|
expect(detector.isAggregateBoundaryViolation("./OrderItem", "order")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect value object imports", () => {
|
||||||
|
expect(
|
||||||
|
detector.isAggregateBoundaryViolation("../user/value-objects/UserId", "order"),
|
||||||
|
).toBe(false)
|
||||||
|
expect(detector.isAggregateBoundaryViolation("../user/vo/Email", "order")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect event imports", () => {
|
||||||
|
expect(
|
||||||
|
detector.isAggregateBoundaryViolation("../user/events/UserCreatedEvent", "order"),
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
detector.isAggregateBoundaryViolation(
|
||||||
|
"../user/domain-events/UserRegisteredEvent",
|
||||||
|
"order",
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect repository interface imports", () => {
|
||||||
|
expect(
|
||||||
|
detector.isAggregateBoundaryViolation(
|
||||||
|
"../user/repositories/IUserRepository",
|
||||||
|
"order",
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect service imports", () => {
|
||||||
|
expect(
|
||||||
|
detector.isAggregateBoundaryViolation("../user/services/UserService", "order"),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect external package imports", () => {
|
||||||
|
expect(detector.isAggregateBoundaryViolation("express", "order")).toBe(false)
|
||||||
|
expect(detector.isAggregateBoundaryViolation("@nestjs/common", "order")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect imports without path separator", () => {
|
||||||
|
expect(detector.isAggregateBoundaryViolation("User", "order")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("detectViolations", () => {
|
||||||
|
describe("Domain layer aggregate boundary violations", () => {
|
||||||
|
it("should detect direct entity import from another aggregate", () => {
|
||||||
|
const code = `
|
||||||
|
import { User } from '../user/User'
|
||||||
|
|
||||||
|
export class Order {
|
||||||
|
constructor(private user: User) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
expect(violations[0].fromAggregate).toBe("order")
|
||||||
|
expect(violations[0].toAggregate).toBe("user")
|
||||||
|
expect(violations[0].entityName).toBe("User")
|
||||||
|
expect(violations[0].importPath).toBe("../user/User")
|
||||||
|
expect(violations[0].line).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect multiple entity imports from different aggregates", () => {
|
||||||
|
const code = `
|
||||||
|
import { User } from '../user/User'
|
||||||
|
import { Product } from '../product/Product'
|
||||||
|
import { Category } from '../catalog/Category'
|
||||||
|
|
||||||
|
export class Order {
|
||||||
|
constructor(
|
||||||
|
private user: User,
|
||||||
|
private product: Product,
|
||||||
|
private category: Category
|
||||||
|
) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(3)
|
||||||
|
expect(violations[0].entityName).toBe("User")
|
||||||
|
expect(violations[1].entityName).toBe("Product")
|
||||||
|
expect(violations[2].entityName).toBe("Category")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect value object imports", () => {
|
||||||
|
const code = `
|
||||||
|
import { UserId } from '../user/value-objects/UserId'
|
||||||
|
import { ProductId } from '../product/value-objects/ProductId'
|
||||||
|
|
||||||
|
export class Order {
|
||||||
|
constructor(
|
||||||
|
private userId: UserId,
|
||||||
|
private productId: ProductId
|
||||||
|
) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect event imports", () => {
|
||||||
|
const code = `
|
||||||
|
import { UserCreatedEvent } from '../user/events/UserCreatedEvent'
|
||||||
|
import { ProductAddedEvent } from '../product/domain-events/ProductAddedEvent'
|
||||||
|
|
||||||
|
export class Order {
|
||||||
|
handle(event: UserCreatedEvent): void {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect repository interface imports", () => {
|
||||||
|
const code = `
|
||||||
|
import { IUserRepository } from '../user/repositories/IUserRepository'
|
||||||
|
|
||||||
|
export class OrderService {
|
||||||
|
constructor(private userRepo: IUserRepository) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/OrderService.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect imports from same aggregate", () => {
|
||||||
|
const code = `
|
||||||
|
import { OrderItem } from './OrderItem'
|
||||||
|
import { OrderStatus } from './value-objects/OrderStatus'
|
||||||
|
|
||||||
|
export class Order {
|
||||||
|
constructor(
|
||||||
|
private items: OrderItem[],
|
||||||
|
private status: OrderStatus
|
||||||
|
) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Non-domain layers", () => {
|
||||||
|
it("should return empty array for application layer", () => {
|
||||||
|
const code = `
|
||||||
|
import { User } from '../../domain/aggregates/user/User'
|
||||||
|
import { Order } from '../../domain/aggregates/order/Order'
|
||||||
|
|
||||||
|
export class CreateOrder {
|
||||||
|
constructor() {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/CreateOrder.ts",
|
||||||
|
LAYERS.APPLICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty array for infrastructure layer", () => {
|
||||||
|
const code = `
|
||||||
|
import { User } from '../../domain/aggregates/user/User'
|
||||||
|
|
||||||
|
export class UserController {
|
||||||
|
constructor() {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/infrastructure/controllers/UserController.ts",
|
||||||
|
LAYERS.INFRASTRUCTURE,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty array for undefined layer", () => {
|
||||||
|
const code = `import { User } from '../user/User'`
|
||||||
|
const violations = detector.detectViolations(code, "src/utils/helper.ts", undefined)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Import statement formats", () => {
|
||||||
|
it("should detect violations in named imports", () => {
|
||||||
|
const code = `import { User, UserProfile } from '../user/User'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect violations in default imports", () => {
|
||||||
|
const code = `import User from '../user/User'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect violations in namespace imports", () => {
|
||||||
|
const code = `import * as UserAggregate from '../user/User'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect violations in require statements", () => {
|
||||||
|
const code = `const User = require('../user/User')`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Different path structures", () => {
|
||||||
|
it("should detect violations in domain/aggregates/name structure", () => {
|
||||||
|
const code = `import { User } from '../user/User'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
expect(violations[0].fromAggregate).toBe("order")
|
||||||
|
expect(violations[0].toAggregate).toBe("user")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect violations in domain/name structure", () => {
|
||||||
|
const code = `import { User } from '../user/User'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
expect(violations[0].fromAggregate).toBe("order")
|
||||||
|
expect(violations[0].toAggregate).toBe("user")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect violations in domain/entities/name structure", () => {
|
||||||
|
const code = `import { User } from '../../user/User'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
expect(violations[0].fromAggregate).toBe("order")
|
||||||
|
expect(violations[0].toAggregate).toBe("user")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Edge cases", () => {
|
||||||
|
it("should handle empty code", () => {
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
"",
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle code with no imports", () => {
|
||||||
|
const code = `
|
||||||
|
export class Order {
|
||||||
|
constructor(private id: string) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle file without aggregate in path", () => {
|
||||||
|
const code = `import { User } from '../user/User'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle comments in imports", () => {
|
||||||
|
const code = `
|
||||||
|
// This is a comment
|
||||||
|
import { User } from '../user/User' // Bad import
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getMessage", () => {
|
||||||
|
it("should return correct violation message", () => {
|
||||||
|
const code = `import { User } from '../user/User'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations[0].getMessage()).toBe(
|
||||||
|
"Order aggregate should not directly reference User entity from User aggregate",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should capitalize aggregate names in message", () => {
|
||||||
|
const code = `import { Product } from '../product/Product'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/cart/ShoppingCart.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations[0].getMessage()).toContain("Cart aggregate")
|
||||||
|
expect(violations[0].getMessage()).toContain("Product aggregate")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getSuggestion", () => {
|
||||||
|
it("should return suggestions for fixing aggregate boundary violations", () => {
|
||||||
|
const code = `import { User } from '../user/User'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
const suggestion = violations[0].getSuggestion()
|
||||||
|
expect(suggestion).toContain("Reference other aggregates by ID")
|
||||||
|
expect(suggestion).toContain("Use Value Objects")
|
||||||
|
expect(suggestion).toContain("Avoid direct entity references")
|
||||||
|
expect(suggestion).toContain("independently modifiable")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getExampleFix", () => {
|
||||||
|
it("should return example fix for aggregate boundary violation", () => {
|
||||||
|
const code = `import { User } from '../user/User'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
const example = violations[0].getExampleFix()
|
||||||
|
expect(example).toContain("// ❌ Bad")
|
||||||
|
expect(example).toContain("// ✅ Good")
|
||||||
|
expect(example).toContain("UserId")
|
||||||
|
expect(example).toContain("CustomerInfo")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Complex scenarios", () => {
|
||||||
|
it("should detect mixed valid and invalid imports", () => {
|
||||||
|
const code = `
|
||||||
|
import { User } from '../user/User' // VIOLATION
|
||||||
|
import { UserId } from '../user/value-objects/UserId' // OK
|
||||||
|
import { Product } from '../product/Product' // VIOLATION
|
||||||
|
import { ProductId } from '../product/value-objects/ProductId' // OK
|
||||||
|
import { OrderItem } from './OrderItem' // OK - same aggregate
|
||||||
|
|
||||||
|
export class Order {
|
||||||
|
constructor(
|
||||||
|
private user: User,
|
||||||
|
private userId: UserId,
|
||||||
|
private product: Product,
|
||||||
|
private productId: ProductId,
|
||||||
|
private items: OrderItem[]
|
||||||
|
) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(2)
|
||||||
|
expect(violations[0].entityName).toBe("User")
|
||||||
|
expect(violations[1].entityName).toBe("Product")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle deeply nested import paths", () => {
|
||||||
|
const code = `import { User } from '../../../domain/aggregates/user/entities/User'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
expect(violations[0].entityName).toBe("User")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect violations with .ts extension in import", () => {
|
||||||
|
const code = `import { User } from '../user/User.ts'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/order/Order.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
expect(violations[0].entityName).toBe("User")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user