mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat: add anemic model detection and refactor hardcoded values (v0.9.0)
This commit is contained in:
@@ -5,6 +5,52 @@ 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.9.0] - 2025-11-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 🏛️ **Anemic Model Detection** - NEW feature to detect anemic domain models lacking business logic:
|
||||||
|
- Detects entities with only getters/setters (violates DDD principles)
|
||||||
|
- Identifies classes with public setters (breaks encapsulation)
|
||||||
|
- Analyzes method-to-property ratio to find data-heavy, logic-light classes
|
||||||
|
- Provides detailed suggestions: add business methods, move logic from services, encapsulate invariants
|
||||||
|
- New `AnemicModelDetector` infrastructure component
|
||||||
|
- New `AnemicModelViolation` value object with rich example fixes
|
||||||
|
- New `IAnemicModelDetector` domain interface
|
||||||
|
- Integrated into CLI with detailed violation reports
|
||||||
|
- 12 comprehensive tests for anemic model detection
|
||||||
|
|
||||||
|
- 📦 **New shared constants** - Centralized constants for better code maintainability:
|
||||||
|
- `CLASS_KEYWORDS` - TypeScript class and method keywords (constructor, public, private, protected)
|
||||||
|
- `EXAMPLE_CODE_CONSTANTS` - Documentation example code strings (ORDER_STATUS_PENDING, ORDER_STATUS_APPROVED, CANNOT_APPROVE_ERROR)
|
||||||
|
- `ANEMIC_MODEL_MESSAGES` - 8 suggestion messages for fixing anemic models
|
||||||
|
|
||||||
|
- 📚 **Example files** - Added DDD examples demonstrating anemic vs rich domain models:
|
||||||
|
- `examples/bad/domain/entities/anemic-model-only-getters-setters.ts`
|
||||||
|
- `examples/bad/domain/entities/anemic-model-public-setters.ts`
|
||||||
|
- `examples/good-architecture/domain/entities/Customer.ts`
|
||||||
|
- `examples/good-architecture/domain/entities/Order.ts`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️ **Refactored hardcoded values** - Extracted all remaining hardcoded values to centralized constants:
|
||||||
|
- Updated `AnemicModelDetector.ts` to use `CLASS_KEYWORDS` constants
|
||||||
|
- Updated `AnemicModelViolation.ts` to use `EXAMPLE_CODE_CONSTANTS` for example fix strings
|
||||||
|
- Replaced local constants with shared constants from `shared/constants`
|
||||||
|
- Improved code maintainability and consistency
|
||||||
|
|
||||||
|
- 🎯 **Enhanced violation detection pipeline** - Added anemic model detection to `ExecuteDetection.ts`
|
||||||
|
- 📊 **Updated API** - Added anemic model violations to response DTO
|
||||||
|
- 🔧 **CLI improvements** - Added anemic model section to output formatting
|
||||||
|
|
||||||
|
### Quality
|
||||||
|
|
||||||
|
- ✅ **Guardian self-check** - 0 issues (was 5) - 100% clean codebase
|
||||||
|
- ✅ **All tests pass** - 578/578 tests passing (added 12 new tests)
|
||||||
|
- ✅ **Build successful** - TypeScript compilation with no errors
|
||||||
|
- ✅ **Linter clean** - 0 errors, 3 acceptable warnings (complexity, params)
|
||||||
|
- ✅ **Format verified** - All files properly formatted with 4-space indentation
|
||||||
|
|
||||||
## [0.8.1] - 2025-11-25
|
## [0.8.1] - 2025-11-25
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -2,7 +2,20 @@
|
|||||||
|
|
||||||
This document outlines the current features and future plans for @puaros/guardian.
|
This document outlines the current features and future plans for @puaros/guardian.
|
||||||
|
|
||||||
## Current Version: 0.7.5 ✅ RELEASED
|
## Current Version: 0.9.0 ✅ RELEASED
|
||||||
|
|
||||||
|
**Released:** 2025-11-26
|
||||||
|
|
||||||
|
### What's New in 0.9.0
|
||||||
|
|
||||||
|
- 🏛️ **Anemic Model Detection** - NEW feature to detect anemic domain models lacking business logic
|
||||||
|
- ✅ **100% clean codebase** - Guardian now passes its own self-check with 0 issues
|
||||||
|
- 📦 **New shared constants** - Added CLASS_KEYWORDS and EXAMPLE_CODE_CONSTANTS
|
||||||
|
- ✅ **All 578 tests passing** - Added 12 new tests for anemic model detection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Previous Version: 0.8.1 ✅ RELEASED
|
||||||
|
|
||||||
**Released:** 2025-11-25
|
**Released:** 2025-11-25
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* BAD EXAMPLE: Anemic Domain Model
|
||||||
|
*
|
||||||
|
* This Order class only has getters and setters without any business logic.
|
||||||
|
* All business logic is likely scattered in services (procedural approach).
|
||||||
|
*
|
||||||
|
* This violates Domain-Driven Design principles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class Order {
|
||||||
|
private status: string
|
||||||
|
private total: number
|
||||||
|
private items: any[]
|
||||||
|
|
||||||
|
getStatus(): string {
|
||||||
|
return this.status
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(status: string): void {
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
getTotal(): number {
|
||||||
|
return this.total
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotal(total: number): void {
|
||||||
|
this.total = total
|
||||||
|
}
|
||||||
|
|
||||||
|
getItems(): any[] {
|
||||||
|
return this.items
|
||||||
|
}
|
||||||
|
|
||||||
|
setItems(items: any[]): void {
|
||||||
|
this.items = items
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* BAD EXAMPLE: Anemic Domain Model with Public Setters
|
||||||
|
*
|
||||||
|
* This User class has public setters which is an anti-pattern in DDD.
|
||||||
|
* Public setters allow uncontrolled state changes without validation or business rules.
|
||||||
|
*
|
||||||
|
* This violates Domain-Driven Design principles and encapsulation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class User {
|
||||||
|
private email: string
|
||||||
|
private password: string
|
||||||
|
private status: string
|
||||||
|
|
||||||
|
public setEmail(email: string): void {
|
||||||
|
this.email = email
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEmail(): string {
|
||||||
|
return this.email
|
||||||
|
}
|
||||||
|
|
||||||
|
public setPassword(password: string): void {
|
||||||
|
this.password = password
|
||||||
|
}
|
||||||
|
|
||||||
|
public setStatus(status: string): void {
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStatus(): string {
|
||||||
|
return this.status
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* GOOD EXAMPLE: Rich Domain Model with Business Logic
|
||||||
|
*
|
||||||
|
* This Customer class encapsulates business rules and state transitions.
|
||||||
|
* No public setters - all changes go through business methods.
|
||||||
|
*
|
||||||
|
* This follows Domain-Driven Design and encapsulation principles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Address {
|
||||||
|
street: string
|
||||||
|
city: string
|
||||||
|
country: string
|
||||||
|
postalCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomainEvent {
|
||||||
|
type: string
|
||||||
|
data: any
|
||||||
|
}
|
||||||
|
|
||||||
|
class Customer {
|
||||||
|
private readonly id: string
|
||||||
|
private email: string
|
||||||
|
private isActive: boolean
|
||||||
|
private loyaltyPoints: number
|
||||||
|
private address: Address | null
|
||||||
|
private readonly events: DomainEvent[] = []
|
||||||
|
|
||||||
|
constructor(id: string, email: string) {
|
||||||
|
this.id = id
|
||||||
|
this.email = email
|
||||||
|
this.isActive = true
|
||||||
|
this.loyaltyPoints = 0
|
||||||
|
this.address = null
|
||||||
|
}
|
||||||
|
|
||||||
|
public activate(): void {
|
||||||
|
if (this.isActive) {
|
||||||
|
throw new Error("Customer is already active")
|
||||||
|
}
|
||||||
|
this.isActive = true
|
||||||
|
this.events.push({
|
||||||
|
type: "CustomerActivated",
|
||||||
|
data: { customerId: this.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public deactivate(reason: string): void {
|
||||||
|
if (!this.isActive) {
|
||||||
|
throw new Error("Customer is already inactive")
|
||||||
|
}
|
||||||
|
this.isActive = false
|
||||||
|
this.events.push({
|
||||||
|
type: "CustomerDeactivated",
|
||||||
|
data: { customerId: this.id, reason },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public changeEmail(newEmail: string): void {
|
||||||
|
if (!this.isValidEmail(newEmail)) {
|
||||||
|
throw new Error("Invalid email format")
|
||||||
|
}
|
||||||
|
if (this.email === newEmail) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const oldEmail = this.email
|
||||||
|
this.email = newEmail
|
||||||
|
this.events.push({
|
||||||
|
type: "EmailChanged",
|
||||||
|
data: { customerId: this.id, oldEmail, newEmail },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public updateAddress(address: Address): void {
|
||||||
|
if (!this.isValidAddress(address)) {
|
||||||
|
throw new Error("Invalid address")
|
||||||
|
}
|
||||||
|
this.address = address
|
||||||
|
this.events.push({
|
||||||
|
type: "AddressUpdated",
|
||||||
|
data: { customerId: this.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public addLoyaltyPoints(points: number): void {
|
||||||
|
if (points <= 0) {
|
||||||
|
throw new Error("Points must be positive")
|
||||||
|
}
|
||||||
|
if (!this.isActive) {
|
||||||
|
throw new Error("Cannot add points to inactive customer")
|
||||||
|
}
|
||||||
|
this.loyaltyPoints += points
|
||||||
|
this.events.push({
|
||||||
|
type: "LoyaltyPointsAdded",
|
||||||
|
data: { customerId: this.id, points },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public redeemLoyaltyPoints(points: number): void {
|
||||||
|
if (points <= 0) {
|
||||||
|
throw new Error("Points must be positive")
|
||||||
|
}
|
||||||
|
if (this.loyaltyPoints < points) {
|
||||||
|
throw new Error("Insufficient loyalty points")
|
||||||
|
}
|
||||||
|
this.loyaltyPoints -= points
|
||||||
|
this.events.push({
|
||||||
|
type: "LoyaltyPointsRedeemed",
|
||||||
|
data: { customerId: this.id, points },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEmail(): string {
|
||||||
|
return this.email
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLoyaltyPoints(): number {
|
||||||
|
return this.loyaltyPoints
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAddress(): Address | null {
|
||||||
|
return this.address ? { ...this.address } : null
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEvents(): DomainEvent[] {
|
||||||
|
return [...this.events]
|
||||||
|
}
|
||||||
|
|
||||||
|
private isValidEmail(email: string): boolean {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isValidAddress(address: Address): boolean {
|
||||||
|
return !!address.street && !!address.city && !!address.country && !!address.postalCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Customer }
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* GOOD EXAMPLE: Rich Domain Model
|
||||||
|
*
|
||||||
|
* This Order class contains business logic and enforces business rules.
|
||||||
|
* State changes are made through business methods, not setters.
|
||||||
|
*
|
||||||
|
* This follows Domain-Driven Design principles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type OrderStatus = "pending" | "approved" | "rejected" | "shipped"
|
||||||
|
|
||||||
|
interface OrderItem {
|
||||||
|
productId: string
|
||||||
|
quantity: number
|
||||||
|
price: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DomainEvent {
|
||||||
|
type: string
|
||||||
|
data: any
|
||||||
|
}
|
||||||
|
|
||||||
|
class Order {
|
||||||
|
private readonly id: string
|
||||||
|
private status: OrderStatus
|
||||||
|
private items: OrderItem[]
|
||||||
|
private readonly events: DomainEvent[] = []
|
||||||
|
|
||||||
|
constructor(id: string, items: OrderItem[]) {
|
||||||
|
this.id = id
|
||||||
|
this.status = "pending"
|
||||||
|
this.items = items
|
||||||
|
}
|
||||||
|
|
||||||
|
public approve(): void {
|
||||||
|
if (!this.canBeApproved()) {
|
||||||
|
throw new Error("Cannot approve order in current state")
|
||||||
|
}
|
||||||
|
this.status = "approved"
|
||||||
|
this.events.push({
|
||||||
|
type: "OrderApproved",
|
||||||
|
data: { orderId: this.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public reject(reason: string): void {
|
||||||
|
if (!this.canBeRejected()) {
|
||||||
|
throw new Error("Cannot reject order in current state")
|
||||||
|
}
|
||||||
|
this.status = "rejected"
|
||||||
|
this.events.push({
|
||||||
|
type: "OrderRejected",
|
||||||
|
data: { orderId: this.id, reason },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public ship(): void {
|
||||||
|
if (!this.canBeShipped()) {
|
||||||
|
throw new Error("Order must be approved before shipping")
|
||||||
|
}
|
||||||
|
this.status = "shipped"
|
||||||
|
this.events.push({
|
||||||
|
type: "OrderShipped",
|
||||||
|
data: { orderId: this.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public addItem(item: OrderItem): void {
|
||||||
|
if (this.status !== "pending") {
|
||||||
|
throw new Error("Cannot modify approved or shipped order")
|
||||||
|
}
|
||||||
|
this.items.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
public calculateTotal(): number {
|
||||||
|
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStatus(): OrderStatus {
|
||||||
|
return this.status
|
||||||
|
}
|
||||||
|
|
||||||
|
public getItems(): OrderItem[] {
|
||||||
|
return [...this.items]
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEvents(): DomainEvent[] {
|
||||||
|
return [...this.events]
|
||||||
|
}
|
||||||
|
|
||||||
|
private canBeApproved(): boolean {
|
||||||
|
return this.status === "pending" && this.items.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private canBeRejected(): boolean {
|
||||||
|
return this.status === "pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
private canBeShipped(): boolean {
|
||||||
|
return this.status === "approved"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Order }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@samiyev/guardian",
|
"name": "@samiyev/guardian",
|
||||||
"version": "0.8.1",
|
"version": "0.9.0",
|
||||||
"description": "Research-backed code quality guardian for AI-assisted development. Detects hardcodes, secrets, circular deps, framework leaks, entity exposure, and 9 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, secrets, circular deps, framework leaks, entity exposure, and 9 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",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { IDependencyDirectionDetector } from "./domain/services/IDependencyDirec
|
|||||||
import { IRepositoryPatternDetector } from "./domain/services/RepositoryPatternDetectorService"
|
import { IRepositoryPatternDetector } from "./domain/services/RepositoryPatternDetectorService"
|
||||||
import { IAggregateBoundaryDetector } from "./domain/services/IAggregateBoundaryDetector"
|
import { IAggregateBoundaryDetector } from "./domain/services/IAggregateBoundaryDetector"
|
||||||
import { ISecretDetector } from "./domain/services/ISecretDetector"
|
import { ISecretDetector } from "./domain/services/ISecretDetector"
|
||||||
|
import { IAnemicModelDetector } from "./domain/services/IAnemicModelDetector"
|
||||||
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"
|
||||||
@@ -23,6 +24,7 @@ import { DependencyDirectionDetector } from "./infrastructure/analyzers/Dependen
|
|||||||
import { RepositoryPatternDetector } from "./infrastructure/analyzers/RepositoryPatternDetector"
|
import { RepositoryPatternDetector } from "./infrastructure/analyzers/RepositoryPatternDetector"
|
||||||
import { AggregateBoundaryDetector } from "./infrastructure/analyzers/AggregateBoundaryDetector"
|
import { AggregateBoundaryDetector } from "./infrastructure/analyzers/AggregateBoundaryDetector"
|
||||||
import { SecretDetector } from "./infrastructure/analyzers/SecretDetector"
|
import { SecretDetector } from "./infrastructure/analyzers/SecretDetector"
|
||||||
|
import { AnemicModelDetector } from "./infrastructure/analyzers/AnemicModelDetector"
|
||||||
import { ERROR_MESSAGES } from "./shared/constants"
|
import { ERROR_MESSAGES } from "./shared/constants"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,6 +84,7 @@ export async function analyzeProject(
|
|||||||
const repositoryPatternDetector: IRepositoryPatternDetector = new RepositoryPatternDetector()
|
const repositoryPatternDetector: IRepositoryPatternDetector = new RepositoryPatternDetector()
|
||||||
const aggregateBoundaryDetector: IAggregateBoundaryDetector = new AggregateBoundaryDetector()
|
const aggregateBoundaryDetector: IAggregateBoundaryDetector = new AggregateBoundaryDetector()
|
||||||
const secretDetector: ISecretDetector = new SecretDetector()
|
const secretDetector: ISecretDetector = new SecretDetector()
|
||||||
|
const anemicModelDetector: IAnemicModelDetector = new AnemicModelDetector()
|
||||||
const useCase = new AnalyzeProject(
|
const useCase = new AnalyzeProject(
|
||||||
fileScanner,
|
fileScanner,
|
||||||
codeParser,
|
codeParser,
|
||||||
@@ -93,6 +96,7 @@ export async function analyzeProject(
|
|||||||
repositoryPatternDetector,
|
repositoryPatternDetector,
|
||||||
aggregateBoundaryDetector,
|
aggregateBoundaryDetector,
|
||||||
secretDetector,
|
secretDetector,
|
||||||
|
anemicModelDetector,
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = await useCase.execute(options)
|
const result = await useCase.execute(options)
|
||||||
@@ -116,5 +120,6 @@ export type {
|
|||||||
DependencyDirectionViolation,
|
DependencyDirectionViolation,
|
||||||
RepositoryPatternViolation,
|
RepositoryPatternViolation,
|
||||||
AggregateBoundaryViolation,
|
AggregateBoundaryViolation,
|
||||||
|
AnemicModelViolation,
|
||||||
ProjectMetrics,
|
ProjectMetrics,
|
||||||
} from "./application/use-cases/AnalyzeProject"
|
} from "./application/use-cases/AnalyzeProject"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { IDependencyDirectionDetector } from "../../domain/services/IDependencyD
|
|||||||
import { IRepositoryPatternDetector } from "../../domain/services/RepositoryPatternDetectorService"
|
import { IRepositoryPatternDetector } from "../../domain/services/RepositoryPatternDetectorService"
|
||||||
import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector"
|
import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector"
|
||||||
import { ISecretDetector } from "../../domain/services/ISecretDetector"
|
import { ISecretDetector } from "../../domain/services/ISecretDetector"
|
||||||
|
import { IAnemicModelDetector } from "../../domain/services/IAnemicModelDetector"
|
||||||
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 { CollectFiles } from "./pipeline/CollectFiles"
|
import { CollectFiles } from "./pipeline/CollectFiles"
|
||||||
@@ -44,6 +45,7 @@ export interface AnalyzeProjectResponse {
|
|||||||
repositoryPatternViolations: RepositoryPatternViolation[]
|
repositoryPatternViolations: RepositoryPatternViolation[]
|
||||||
aggregateBoundaryViolations: AggregateBoundaryViolation[]
|
aggregateBoundaryViolations: AggregateBoundaryViolation[]
|
||||||
secretViolations: SecretViolation[]
|
secretViolations: SecretViolation[]
|
||||||
|
anemicModelViolations: AnemicModelViolation[]
|
||||||
metrics: ProjectMetrics
|
metrics: ProjectMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +178,21 @@ export interface SecretViolation {
|
|||||||
severity: SeverityLevel
|
severity: SeverityLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AnemicModelViolation {
|
||||||
|
rule: typeof RULES.ANEMIC_MODEL
|
||||||
|
className: string
|
||||||
|
file: string
|
||||||
|
layer: string
|
||||||
|
line?: number
|
||||||
|
methodCount: number
|
||||||
|
propertyCount: number
|
||||||
|
hasOnlyGettersSetters: boolean
|
||||||
|
hasPublicSetters: boolean
|
||||||
|
message: string
|
||||||
|
suggestion: string
|
||||||
|
severity: SeverityLevel
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectMetrics {
|
export interface ProjectMetrics {
|
||||||
totalFiles: number
|
totalFiles: number
|
||||||
totalFunctions: number
|
totalFunctions: number
|
||||||
@@ -207,6 +224,7 @@ export class AnalyzeProject extends UseCase<
|
|||||||
repositoryPatternDetector: IRepositoryPatternDetector,
|
repositoryPatternDetector: IRepositoryPatternDetector,
|
||||||
aggregateBoundaryDetector: IAggregateBoundaryDetector,
|
aggregateBoundaryDetector: IAggregateBoundaryDetector,
|
||||||
secretDetector: ISecretDetector,
|
secretDetector: ISecretDetector,
|
||||||
|
anemicModelDetector: IAnemicModelDetector,
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
this.fileCollectionStep = new CollectFiles(fileScanner)
|
this.fileCollectionStep = new CollectFiles(fileScanner)
|
||||||
@@ -220,6 +238,7 @@ export class AnalyzeProject extends UseCase<
|
|||||||
repositoryPatternDetector,
|
repositoryPatternDetector,
|
||||||
aggregateBoundaryDetector,
|
aggregateBoundaryDetector,
|
||||||
secretDetector,
|
secretDetector,
|
||||||
|
anemicModelDetector,
|
||||||
)
|
)
|
||||||
this.resultAggregator = new AggregateResults()
|
this.resultAggregator = new AggregateResults()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DependencyGraph } from "../../../domain/entities/DependencyGraph"
|
|||||||
import type {
|
import type {
|
||||||
AggregateBoundaryViolation,
|
AggregateBoundaryViolation,
|
||||||
AnalyzeProjectResponse,
|
AnalyzeProjectResponse,
|
||||||
|
AnemicModelViolation,
|
||||||
ArchitectureViolation,
|
ArchitectureViolation,
|
||||||
CircularDependencyViolation,
|
CircularDependencyViolation,
|
||||||
DependencyDirectionViolation,
|
DependencyDirectionViolation,
|
||||||
@@ -29,6 +30,7 @@ export interface AggregationRequest {
|
|||||||
repositoryPatternViolations: RepositoryPatternViolation[]
|
repositoryPatternViolations: RepositoryPatternViolation[]
|
||||||
aggregateBoundaryViolations: AggregateBoundaryViolation[]
|
aggregateBoundaryViolations: AggregateBoundaryViolation[]
|
||||||
secretViolations: SecretViolation[]
|
secretViolations: SecretViolation[]
|
||||||
|
anemicModelViolations: AnemicModelViolation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +57,7 @@ export class AggregateResults {
|
|||||||
repositoryPatternViolations: request.repositoryPatternViolations,
|
repositoryPatternViolations: request.repositoryPatternViolations,
|
||||||
aggregateBoundaryViolations: request.aggregateBoundaryViolations,
|
aggregateBoundaryViolations: request.aggregateBoundaryViolations,
|
||||||
secretViolations: request.secretViolations,
|
secretViolations: request.secretViolations,
|
||||||
|
anemicModelViolations: request.anemicModelViolations,
|
||||||
metrics,
|
metrics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { IDependencyDirectionDetector } from "../../../domain/services/IDependen
|
|||||||
import { IRepositoryPatternDetector } from "../../../domain/services/RepositoryPatternDetectorService"
|
import { IRepositoryPatternDetector } from "../../../domain/services/RepositoryPatternDetectorService"
|
||||||
import { IAggregateBoundaryDetector } from "../../../domain/services/IAggregateBoundaryDetector"
|
import { IAggregateBoundaryDetector } from "../../../domain/services/IAggregateBoundaryDetector"
|
||||||
import { ISecretDetector } from "../../../domain/services/ISecretDetector"
|
import { ISecretDetector } from "../../../domain/services/ISecretDetector"
|
||||||
|
import { IAnemicModelDetector } from "../../../domain/services/IAnemicModelDetector"
|
||||||
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 {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
} from "../../../shared/constants"
|
} from "../../../shared/constants"
|
||||||
import type {
|
import type {
|
||||||
AggregateBoundaryViolation,
|
AggregateBoundaryViolation,
|
||||||
|
AnemicModelViolation,
|
||||||
ArchitectureViolation,
|
ArchitectureViolation,
|
||||||
CircularDependencyViolation,
|
CircularDependencyViolation,
|
||||||
DependencyDirectionViolation,
|
DependencyDirectionViolation,
|
||||||
@@ -45,6 +47,7 @@ export interface DetectionResult {
|
|||||||
repositoryPatternViolations: RepositoryPatternViolation[]
|
repositoryPatternViolations: RepositoryPatternViolation[]
|
||||||
aggregateBoundaryViolations: AggregateBoundaryViolation[]
|
aggregateBoundaryViolations: AggregateBoundaryViolation[]
|
||||||
secretViolations: SecretViolation[]
|
secretViolations: SecretViolation[]
|
||||||
|
anemicModelViolations: AnemicModelViolation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +63,7 @@ export class ExecuteDetection {
|
|||||||
private readonly repositoryPatternDetector: IRepositoryPatternDetector,
|
private readonly repositoryPatternDetector: IRepositoryPatternDetector,
|
||||||
private readonly aggregateBoundaryDetector: IAggregateBoundaryDetector,
|
private readonly aggregateBoundaryDetector: IAggregateBoundaryDetector,
|
||||||
private readonly secretDetector: ISecretDetector,
|
private readonly secretDetector: ISecretDetector,
|
||||||
|
private readonly anemicModelDetector: IAnemicModelDetector,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async execute(request: DetectionRequest): Promise<DetectionResult> {
|
public async execute(request: DetectionRequest): Promise<DetectionResult> {
|
||||||
@@ -90,6 +94,9 @@ export class ExecuteDetection {
|
|||||||
this.detectAggregateBoundaryViolations(request.sourceFiles),
|
this.detectAggregateBoundaryViolations(request.sourceFiles),
|
||||||
),
|
),
|
||||||
secretViolations: this.sortBySeverity(secretViolations),
|
secretViolations: this.sortBySeverity(secretViolations),
|
||||||
|
anemicModelViolations: this.sortBySeverity(
|
||||||
|
this.detectAnemicModels(request.sourceFiles),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,6 +405,37 @@ export class ExecuteDetection {
|
|||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private detectAnemicModels(sourceFiles: SourceFile[]): AnemicModelViolation[] {
|
||||||
|
const violations: AnemicModelViolation[] = []
|
||||||
|
|
||||||
|
for (const file of sourceFiles) {
|
||||||
|
const anemicModels = this.anemicModelDetector.detectAnemicModels(
|
||||||
|
file.content,
|
||||||
|
file.path.relative,
|
||||||
|
file.layer,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const anemicModel of anemicModels) {
|
||||||
|
violations.push({
|
||||||
|
rule: RULES.ANEMIC_MODEL,
|
||||||
|
className: anemicModel.className,
|
||||||
|
file: file.path.relative,
|
||||||
|
layer: anemicModel.layer,
|
||||||
|
line: anemicModel.line,
|
||||||
|
methodCount: anemicModel.methodCount,
|
||||||
|
propertyCount: anemicModel.propertyCount,
|
||||||
|
hasOnlyGettersSetters: anemicModel.hasOnlyGettersSetters,
|
||||||
|
hasPublicSetters: anemicModel.hasPublicSetters,
|
||||||
|
message: anemicModel.getMessage(),
|
||||||
|
suggestion: anemicModel.getSuggestion(),
|
||||||
|
severity: VIOLATION_SEVERITY_MAP.ANEMIC_MODEL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
private sortBySeverity<T extends { severity: SeverityLevel }>(violations: T[]): T[] {
|
private sortBySeverity<T extends { severity: SeverityLevel }>(violations: T[]): T[] {
|
||||||
return violations.sort((a, b) => {
|
return violations.sort((a, b) => {
|
||||||
return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
|
return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SEVERITY_LEVELS, type SeverityLevel } from "../../shared/constants"
|
import { SEVERITY_LEVELS, type SeverityLevel } from "../../shared/constants"
|
||||||
import type {
|
import type {
|
||||||
AggregateBoundaryViolation,
|
AggregateBoundaryViolation,
|
||||||
|
AnemicModelViolation,
|
||||||
ArchitectureViolation,
|
ArchitectureViolation,
|
||||||
CircularDependencyViolation,
|
CircularDependencyViolation,
|
||||||
DependencyDirectionViolation,
|
DependencyDirectionViolation,
|
||||||
@@ -204,4 +205,31 @@ export class OutputFormatter {
|
|||||||
console.log(` 📁 Location: ${hc.suggestion.location}`)
|
console.log(` 📁 Location: ${hc.suggestion.location}`)
|
||||||
console.log("")
|
console.log("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatAnemicModelViolation(am: AnemicModelViolation, index: number): void {
|
||||||
|
const location = am.line ? `${am.file}:${String(am.line)}` : am.file
|
||||||
|
console.log(`${String(index + 1)}. ${location}`)
|
||||||
|
console.log(` Severity: ${SEVERITY_LABELS[am.severity]}`)
|
||||||
|
console.log(` Class: ${am.className}`)
|
||||||
|
console.log(` Layer: ${am.layer}`)
|
||||||
|
console.log(
|
||||||
|
` Methods: ${String(am.methodCount)} | Properties: ${String(am.propertyCount)}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (am.hasPublicSetters) {
|
||||||
|
console.log(" ⚠️ Has public setters (DDD anti-pattern)")
|
||||||
|
}
|
||||||
|
if (am.hasOnlyGettersSetters) {
|
||||||
|
console.log(" ⚠️ Only getters/setters (no business logic)")
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ${am.message}`)
|
||||||
|
console.log(" 💡 Suggestion:")
|
||||||
|
am.suggestion.split("\n").forEach((line) => {
|
||||||
|
if (line.trim()) {
|
||||||
|
console.log(` ${line}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log("")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ program
|
|||||||
repositoryPatternViolations,
|
repositoryPatternViolations,
|
||||||
aggregateBoundaryViolations,
|
aggregateBoundaryViolations,
|
||||||
secretViolations,
|
secretViolations,
|
||||||
|
anemicModelViolations,
|
||||||
} = result
|
} = result
|
||||||
|
|
||||||
const minSeverity: SeverityLevel | undefined = options.onlyCritical
|
const minSeverity: SeverityLevel | undefined = options.onlyCritical
|
||||||
@@ -134,6 +135,7 @@ program
|
|||||||
minSeverity,
|
minSeverity,
|
||||||
)
|
)
|
||||||
secretViolations = grouper.filterBySeverity(secretViolations, minSeverity)
|
secretViolations = grouper.filterBySeverity(secretViolations, minSeverity)
|
||||||
|
anemicModelViolations = grouper.filterBySeverity(anemicModelViolations, minSeverity)
|
||||||
|
|
||||||
statsFormatter.displaySeverityFilterMessage(
|
statsFormatter.displaySeverityFilterMessage(
|
||||||
options.onlyCritical,
|
options.onlyCritical,
|
||||||
@@ -260,6 +262,19 @@ program
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (anemicModelViolations.length > 0) {
|
||||||
|
console.log(
|
||||||
|
`\n🩺 Found ${String(anemicModelViolations.length)} anemic domain model(s)`,
|
||||||
|
)
|
||||||
|
outputFormatter.displayGroupedViolations(
|
||||||
|
anemicModelViolations,
|
||||||
|
(am, i) => {
|
||||||
|
outputFormatter.formatAnemicModelViolation(am, i)
|
||||||
|
},
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (options.hardcode && hardcodeViolations.length > 0) {
|
if (options.hardcode && hardcodeViolations.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
`\n${CLI_MESSAGES.HARDCODE_VIOLATIONS_HEADER} ${String(hardcodeViolations.length)} ${CLI_LABELS.HARDCODE_VIOLATIONS}`,
|
`\n${CLI_MESSAGES.HARDCODE_VIOLATIONS_HEADER} ${String(hardcodeViolations.length)} ${CLI_LABELS.HARDCODE_VIOLATIONS}`,
|
||||||
@@ -283,7 +298,8 @@ program
|
|||||||
dependencyDirectionViolations.length +
|
dependencyDirectionViolations.length +
|
||||||
repositoryPatternViolations.length +
|
repositoryPatternViolations.length +
|
||||||
aggregateBoundaryViolations.length +
|
aggregateBoundaryViolations.length +
|
||||||
secretViolations.length
|
secretViolations.length +
|
||||||
|
anemicModelViolations.length
|
||||||
|
|
||||||
statsFormatter.displaySummary(totalIssues, options.verbose)
|
statsFormatter.displaySummary(totalIssues, options.verbose)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -69,3 +69,14 @@ export const SECRET_VIOLATION_MESSAGES = {
|
|||||||
ROTATE_IF_EXPOSED: "4. If secret was committed, rotate it immediately",
|
ROTATE_IF_EXPOSED: "4. If secret was committed, rotate it immediately",
|
||||||
USE_GITIGNORE: "5. Add secret files to .gitignore (.env, credentials.json, etc.)",
|
USE_GITIGNORE: "5. Add secret files to .gitignore (.env, credentials.json, etc.)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ANEMIC_MODEL_MESSAGES = {
|
||||||
|
REMOVE_PUBLIC_SETTERS: "1. Remove public setters - they allow uncontrolled state changes",
|
||||||
|
USE_METHODS_FOR_CHANGES: "2. Use business methods instead (approve(), cancel(), addItem())",
|
||||||
|
ENCAPSULATE_INVARIANTS: "3. Encapsulate business rules and invariants in methods",
|
||||||
|
ADD_BUSINESS_METHODS: "1. Add business logic methods to the entity",
|
||||||
|
MOVE_LOGIC_FROM_SERVICES:
|
||||||
|
"2. Move business logic from services to domain entities where it belongs",
|
||||||
|
ENCAPSULATE_BUSINESS_RULES: "3. Encapsulate business rules inside entity methods",
|
||||||
|
USE_DOMAIN_EVENTS: "4. Use domain events to communicate state changes",
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { AnemicModelViolation } from "../value-objects/AnemicModelViolation"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for detecting anemic domain model violations in the codebase
|
||||||
|
*
|
||||||
|
* Anemic domain models are entities that contain only getters/setters
|
||||||
|
* without business logic. This anti-pattern violates Domain-Driven Design
|
||||||
|
* principles and leads to procedural code scattered in services.
|
||||||
|
*/
|
||||||
|
export interface IAnemicModelDetector {
|
||||||
|
/**
|
||||||
|
* Detects anemic model violations in the given code
|
||||||
|
*
|
||||||
|
* Analyzes classes in domain/entities to identify:
|
||||||
|
* - Classes with only getters and setters (no business logic)
|
||||||
|
* - Classes with public setters (DDD anti-pattern)
|
||||||
|
* - Classes with low method-to-property ratio
|
||||||
|
*
|
||||||
|
* @param code - Source code to analyze
|
||||||
|
* @param filePath - Path to the file being analyzed
|
||||||
|
* @param layer - The architectural layer of the file (domain, application, infrastructure, shared)
|
||||||
|
* @returns Array of detected anemic model violations
|
||||||
|
*/
|
||||||
|
detectAnemicModels(
|
||||||
|
code: string,
|
||||||
|
filePath: string,
|
||||||
|
layer: string | undefined,
|
||||||
|
): AnemicModelViolation[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { ValueObject } from "./ValueObject"
|
||||||
|
import { ANEMIC_MODEL_MESSAGES } from "../constants/Messages"
|
||||||
|
import { EXAMPLE_CODE_CONSTANTS } from "../../shared/constants"
|
||||||
|
|
||||||
|
interface AnemicModelViolationProps {
|
||||||
|
readonly className: string
|
||||||
|
readonly filePath: string
|
||||||
|
readonly layer: string
|
||||||
|
readonly line?: number
|
||||||
|
readonly methodCount: number
|
||||||
|
readonly propertyCount: number
|
||||||
|
readonly hasOnlyGettersSetters: boolean
|
||||||
|
readonly hasPublicSetters: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an anemic domain model violation in the codebase
|
||||||
|
*
|
||||||
|
* Anemic domain model occurs when entities have only getters/setters
|
||||||
|
* without business logic. This violates Domain-Driven Design principles
|
||||||
|
* and leads to procedural code instead of object-oriented design.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Bad: Anemic model with only getters/setters
|
||||||
|
* const violation = AnemicModelViolation.create(
|
||||||
|
* 'Order',
|
||||||
|
* 'src/domain/entities/Order.ts',
|
||||||
|
* 'domain',
|
||||||
|
* 10,
|
||||||
|
* 4,
|
||||||
|
* 2,
|
||||||
|
* true,
|
||||||
|
* true
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* console.log(violation.getMessage())
|
||||||
|
* // "Class 'Order' is anemic: 4 methods (all getters/setters) for 2 properties"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class AnemicModelViolation extends ValueObject<AnemicModelViolationProps> {
|
||||||
|
private constructor(props: AnemicModelViolationProps) {
|
||||||
|
super(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static create(
|
||||||
|
className: string,
|
||||||
|
filePath: string,
|
||||||
|
layer: string,
|
||||||
|
line: number | undefined,
|
||||||
|
methodCount: number,
|
||||||
|
propertyCount: number,
|
||||||
|
hasOnlyGettersSetters: boolean,
|
||||||
|
hasPublicSetters: boolean,
|
||||||
|
): AnemicModelViolation {
|
||||||
|
return new AnemicModelViolation({
|
||||||
|
className,
|
||||||
|
filePath,
|
||||||
|
layer,
|
||||||
|
line,
|
||||||
|
methodCount,
|
||||||
|
propertyCount,
|
||||||
|
hasOnlyGettersSetters,
|
||||||
|
hasPublicSetters,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public get className(): string {
|
||||||
|
return this.props.className
|
||||||
|
}
|
||||||
|
|
||||||
|
public get filePath(): string {
|
||||||
|
return this.props.filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
public get layer(): string {
|
||||||
|
return this.props.layer
|
||||||
|
}
|
||||||
|
|
||||||
|
public get line(): number | undefined {
|
||||||
|
return this.props.line
|
||||||
|
}
|
||||||
|
|
||||||
|
public get methodCount(): number {
|
||||||
|
return this.props.methodCount
|
||||||
|
}
|
||||||
|
|
||||||
|
public get propertyCount(): number {
|
||||||
|
return this.props.propertyCount
|
||||||
|
}
|
||||||
|
|
||||||
|
public get hasOnlyGettersSetters(): boolean {
|
||||||
|
return this.props.hasOnlyGettersSetters
|
||||||
|
}
|
||||||
|
|
||||||
|
public get hasPublicSetters(): boolean {
|
||||||
|
return this.props.hasPublicSetters
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMessage(): string {
|
||||||
|
if (this.props.hasPublicSetters) {
|
||||||
|
return `Class '${this.props.className}' has public setters (anti-pattern in DDD)`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.hasOnlyGettersSetters) {
|
||||||
|
return `Class '${this.props.className}' is anemic: ${String(this.props.methodCount)} methods (all getters/setters) for ${String(this.props.propertyCount)} properties`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratio = this.props.methodCount / Math.max(this.props.propertyCount, 1)
|
||||||
|
return `Class '${this.props.className}' appears anemic: low method-to-property ratio (${ratio.toFixed(1)}:1)`
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSuggestion(): string {
|
||||||
|
const suggestions: string[] = []
|
||||||
|
|
||||||
|
if (this.props.hasPublicSetters) {
|
||||||
|
suggestions.push(ANEMIC_MODEL_MESSAGES.REMOVE_PUBLIC_SETTERS)
|
||||||
|
suggestions.push(ANEMIC_MODEL_MESSAGES.USE_METHODS_FOR_CHANGES)
|
||||||
|
suggestions.push(ANEMIC_MODEL_MESSAGES.ENCAPSULATE_INVARIANTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.hasOnlyGettersSetters || this.props.methodCount < 2) {
|
||||||
|
suggestions.push(ANEMIC_MODEL_MESSAGES.ADD_BUSINESS_METHODS)
|
||||||
|
suggestions.push(ANEMIC_MODEL_MESSAGES.MOVE_LOGIC_FROM_SERVICES)
|
||||||
|
suggestions.push(ANEMIC_MODEL_MESSAGES.ENCAPSULATE_BUSINESS_RULES)
|
||||||
|
suggestions.push(ANEMIC_MODEL_MESSAGES.USE_DOMAIN_EVENTS)
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
public getExampleFix(): string {
|
||||||
|
if (this.props.hasPublicSetters) {
|
||||||
|
return `
|
||||||
|
// ❌ Bad: Public setters allow uncontrolled state changes
|
||||||
|
class ${this.props.className} {
|
||||||
|
private status: string
|
||||||
|
|
||||||
|
public setStatus(status: string): void {
|
||||||
|
this.status = status // No validation!
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStatus(): string {
|
||||||
|
return this.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good: Business methods with validation
|
||||||
|
class ${this.props.className} {
|
||||||
|
private status: OrderStatus
|
||||||
|
|
||||||
|
public approve(): void {
|
||||||
|
if (!this.canBeApproved()) {
|
||||||
|
throw new CannotApproveOrderError()
|
||||||
|
}
|
||||||
|
this.status = OrderStatus.APPROVED
|
||||||
|
this.events.push(new OrderApprovedEvent(this.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
public reject(reason: string): void {
|
||||||
|
if (!this.canBeRejected()) {
|
||||||
|
throw new CannotRejectOrderError()
|
||||||
|
}
|
||||||
|
this.status = OrderStatus.REJECTED
|
||||||
|
this.rejectionReason = reason
|
||||||
|
this.events.push(new OrderRejectedEvent(this.id, reason))
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStatus(): OrderStatus {
|
||||||
|
return this.status
|
||||||
|
}
|
||||||
|
|
||||||
|
private canBeApproved(): boolean {
|
||||||
|
return this.status === OrderStatus.PENDING && this.hasItems()
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
// ❌ Bad: Anemic model (only getters/setters)
|
||||||
|
class ${this.props.className} {
|
||||||
|
getStatus() { return this.status }
|
||||||
|
setStatus(status: string) { this.status = status }
|
||||||
|
|
||||||
|
getTotal() { return this.total }
|
||||||
|
setTotal(total: number) { this.total = total }
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrderService {
|
||||||
|
approve(order: ${this.props.className}): void {
|
||||||
|
if (order.getStatus() !== '${EXAMPLE_CODE_CONSTANTS.ORDER_STATUS_PENDING}') {
|
||||||
|
throw new Error('${EXAMPLE_CODE_CONSTANTS.CANNOT_APPROVE_ERROR}')
|
||||||
|
}
|
||||||
|
order.setStatus('${EXAMPLE_CODE_CONSTANTS.ORDER_STATUS_APPROVED}')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good: Rich domain model with business logic
|
||||||
|
class ${this.props.className} {
|
||||||
|
private readonly id: OrderId
|
||||||
|
private status: OrderStatus
|
||||||
|
private items: OrderItem[]
|
||||||
|
private events: DomainEvent[] = []
|
||||||
|
|
||||||
|
public approve(): void {
|
||||||
|
if (!this.isPending()) {
|
||||||
|
throw new CannotApproveOrderError()
|
||||||
|
}
|
||||||
|
this.status = OrderStatus.APPROVED
|
||||||
|
this.events.push(new OrderApprovedEvent(this.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
public calculateTotal(): Money {
|
||||||
|
return this.items.reduce(
|
||||||
|
(sum, item) => sum.add(item.getPrice()),
|
||||||
|
Money.zero()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public addItem(item: OrderItem): void {
|
||||||
|
if (this.isApproved()) {
|
||||||
|
throw new CannotModifyApprovedOrderError()
|
||||||
|
}
|
||||||
|
this.items.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStatus(): OrderStatus {
|
||||||
|
return this.status
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPending(): boolean {
|
||||||
|
return this.status === OrderStatus.PENDING
|
||||||
|
}
|
||||||
|
|
||||||
|
private isApproved(): boolean {
|
||||||
|
return this.status === OrderStatus.APPROVED
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { IAnemicModelDetector } from "../../domain/services/IAnemicModelDetector"
|
||||||
|
import { AnemicModelViolation } from "../../domain/value-objects/AnemicModelViolation"
|
||||||
|
import { CLASS_KEYWORDS } from "../../shared/constants"
|
||||||
|
import { LAYERS } from "../../shared/constants/rules"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects anemic domain model violations
|
||||||
|
*
|
||||||
|
* This detector identifies entities that lack business logic and contain
|
||||||
|
* only getters/setters. Anemic models violate Domain-Driven Design principles.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const detector = new AnemicModelDetector()
|
||||||
|
*
|
||||||
|
* // Detect anemic models in entity file
|
||||||
|
* const code = `
|
||||||
|
* class Order {
|
||||||
|
* getStatus() { return this.status }
|
||||||
|
* setStatus(status: string) { this.status = status }
|
||||||
|
* getTotal() { return this.total }
|
||||||
|
* setTotal(total: number) { this.total = total }
|
||||||
|
* }
|
||||||
|
* `
|
||||||
|
* const violations = detector.detectAnemicModels(
|
||||||
|
* code,
|
||||||
|
* 'src/domain/entities/Order.ts',
|
||||||
|
* 'domain'
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* // violations will contain anemic model violation
|
||||||
|
* console.log(violations.length) // 1
|
||||||
|
* console.log(violations[0].className) // 'Order'
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class AnemicModelDetector implements IAnemicModelDetector {
|
||||||
|
private readonly entityPatterns = [/\/entities\//, /\/aggregates\//]
|
||||||
|
private readonly excludePatterns = [
|
||||||
|
/\.test\.ts$/,
|
||||||
|
/\.spec\.ts$/,
|
||||||
|
/Dto\.ts$/,
|
||||||
|
/Request\.ts$/,
|
||||||
|
/Response\.ts$/,
|
||||||
|
/Mapper\.ts$/,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects anemic model violations in the given code
|
||||||
|
*/
|
||||||
|
public detectAnemicModels(
|
||||||
|
code: string,
|
||||||
|
filePath: string,
|
||||||
|
layer: string | undefined,
|
||||||
|
): AnemicModelViolation[] {
|
||||||
|
if (!this.shouldAnalyze(filePath, layer)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const violations: AnemicModelViolation[] = []
|
||||||
|
const classes = this.extractClasses(code)
|
||||||
|
|
||||||
|
for (const classInfo of classes) {
|
||||||
|
const violation = this.analyzeClass(classInfo, filePath, layer || LAYERS.DOMAIN)
|
||||||
|
if (violation) {
|
||||||
|
violations.push(violation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if file should be analyzed
|
||||||
|
*/
|
||||||
|
private shouldAnalyze(filePath: string, layer: string | undefined): boolean {
|
||||||
|
if (layer !== LAYERS.DOMAIN) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.excludePatterns.some((pattern) => pattern.test(filePath))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.entityPatterns.some((pattern) => pattern.test(filePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts class information from code
|
||||||
|
*/
|
||||||
|
private extractClasses(code: string): ClassInfo[] {
|
||||||
|
const classes: ClassInfo[] = []
|
||||||
|
const lines = code.split("\n")
|
||||||
|
let currentClass: { name: string; startLine: number; startIndex: number } | null = null
|
||||||
|
let braceCount = 0
|
||||||
|
let classBody = ""
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
|
||||||
|
if (!currentClass) {
|
||||||
|
const classRegex = /^\s*(?:export\s+)?(?:abstract\s+)?class\s+(\w+)/
|
||||||
|
const classMatch = classRegex.exec(line)
|
||||||
|
if (classMatch) {
|
||||||
|
currentClass = {
|
||||||
|
name: classMatch[1],
|
||||||
|
startLine: i + 1,
|
||||||
|
startIndex: lines.slice(0, i).join("\n").length,
|
||||||
|
}
|
||||||
|
braceCount = 0
|
||||||
|
classBody = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentClass) {
|
||||||
|
for (const char of line) {
|
||||||
|
if (char === "{") {
|
||||||
|
braceCount++
|
||||||
|
} else if (char === "}") {
|
||||||
|
braceCount--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (braceCount > 0) {
|
||||||
|
classBody = `${classBody}${line}\n`
|
||||||
|
} else if (braceCount === 0 && classBody.length > 0) {
|
||||||
|
const properties = this.extractProperties(classBody)
|
||||||
|
const methods = this.extractMethods(classBody)
|
||||||
|
|
||||||
|
classes.push({
|
||||||
|
className: currentClass.name,
|
||||||
|
lineNumber: currentClass.startLine,
|
||||||
|
properties,
|
||||||
|
methods,
|
||||||
|
})
|
||||||
|
|
||||||
|
currentClass = null
|
||||||
|
classBody = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts properties from class body
|
||||||
|
*/
|
||||||
|
private extractProperties(classBody: string): PropertyInfo[] {
|
||||||
|
const properties: PropertyInfo[] = []
|
||||||
|
const propertyRegex = /(?:private|protected|public|readonly)*\s*(\w+)(?:\?)?:\s*\w+/g
|
||||||
|
|
||||||
|
let match
|
||||||
|
while ((match = propertyRegex.exec(classBody)) !== null) {
|
||||||
|
const propertyName = match[1]
|
||||||
|
|
||||||
|
if (!this.isMethodSignature(match[0])) {
|
||||||
|
properties.push({ name: propertyName })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return properties
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts methods from class body
|
||||||
|
*/
|
||||||
|
private extractMethods(classBody: string): MethodInfo[] {
|
||||||
|
const methods: MethodInfo[] = []
|
||||||
|
const methodRegex =
|
||||||
|
/(public|private|protected)?\s*(get|set)?\s+(\w+)\s*\([^)]*\)(?:\s*:\s*\w+)?/g
|
||||||
|
|
||||||
|
let match
|
||||||
|
while ((match = methodRegex.exec(classBody)) !== null) {
|
||||||
|
const visibility = match[1] || CLASS_KEYWORDS.PUBLIC
|
||||||
|
const accessor = match[2]
|
||||||
|
const methodName = match[3]
|
||||||
|
|
||||||
|
if (methodName === CLASS_KEYWORDS.CONSTRUCTOR) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const isGetter = accessor === "get" || this.isGetterMethod(methodName)
|
||||||
|
const isSetter = accessor === "set" || this.isSetterMethod(methodName, classBody)
|
||||||
|
const isPublic = visibility === CLASS_KEYWORDS.PUBLIC || !visibility
|
||||||
|
|
||||||
|
methods.push({
|
||||||
|
name: methodName,
|
||||||
|
isGetter,
|
||||||
|
isSetter,
|
||||||
|
isPublic,
|
||||||
|
isBusinessLogic: !isGetter && !isSetter,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return methods
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyzes class for anemic model violations
|
||||||
|
*/
|
||||||
|
private analyzeClass(
|
||||||
|
classInfo: ClassInfo,
|
||||||
|
filePath: string,
|
||||||
|
layer: string,
|
||||||
|
): AnemicModelViolation | null {
|
||||||
|
const { className, lineNumber, properties, methods } = classInfo
|
||||||
|
|
||||||
|
if (properties.length === 0 && methods.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessMethods = methods.filter((m) => m.isBusinessLogic)
|
||||||
|
const hasOnlyGettersSetters = businessMethods.length === 0 && methods.length > 0
|
||||||
|
const hasPublicSetters = methods.some((m) => m.isSetter && m.isPublic)
|
||||||
|
|
||||||
|
const methodCount = methods.length
|
||||||
|
const propertyCount = properties.length
|
||||||
|
|
||||||
|
if (hasPublicSetters) {
|
||||||
|
return AnemicModelViolation.create(
|
||||||
|
className,
|
||||||
|
filePath,
|
||||||
|
layer,
|
||||||
|
lineNumber,
|
||||||
|
methodCount,
|
||||||
|
propertyCount,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOnlyGettersSetters && methodCount >= 2 && propertyCount > 0) {
|
||||||
|
return AnemicModelViolation.create(
|
||||||
|
className,
|
||||||
|
filePath,
|
||||||
|
layer,
|
||||||
|
lineNumber,
|
||||||
|
methodCount,
|
||||||
|
propertyCount,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodToPropertyRatio = methodCount / Math.max(propertyCount, 1)
|
||||||
|
if (
|
||||||
|
propertyCount > 0 &&
|
||||||
|
businessMethods.length < 2 &&
|
||||||
|
methodToPropertyRatio < 1.0 &&
|
||||||
|
methodCount > 0
|
||||||
|
) {
|
||||||
|
return AnemicModelViolation.create(
|
||||||
|
className,
|
||||||
|
filePath,
|
||||||
|
layer,
|
||||||
|
lineNumber,
|
||||||
|
methodCount,
|
||||||
|
propertyCount,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if method name is a getter pattern
|
||||||
|
*/
|
||||||
|
private isGetterMethod(methodName: string): boolean {
|
||||||
|
return (
|
||||||
|
methodName.startsWith("get") ||
|
||||||
|
methodName.startsWith("is") ||
|
||||||
|
methodName.startsWith("has")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if method is a setter pattern
|
||||||
|
*/
|
||||||
|
private isSetterMethod(methodName: string, _classBody: string): boolean {
|
||||||
|
return methodName.startsWith("set")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if property declaration is actually a method signature
|
||||||
|
*/
|
||||||
|
private isMethodSignature(propertyDeclaration: string): boolean {
|
||||||
|
return propertyDeclaration.includes("(") && propertyDeclaration.includes(")")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets line number for a position in code
|
||||||
|
*/
|
||||||
|
private getLineNumber(code: string, position: number): number {
|
||||||
|
const lines = code.substring(0, position).split("\n")
|
||||||
|
return lines.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClassInfo {
|
||||||
|
className: string
|
||||||
|
lineNumber: number
|
||||||
|
properties: PropertyInfo[]
|
||||||
|
methods: MethodInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PropertyInfo {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MethodInfo {
|
||||||
|
name: string
|
||||||
|
isGetter: boolean
|
||||||
|
isSetter: boolean
|
||||||
|
isPublic: boolean
|
||||||
|
isBusinessLogic: boolean
|
||||||
|
}
|
||||||
@@ -45,6 +45,25 @@ export const TYPE_NAMES = {
|
|||||||
OBJECT: "object",
|
OBJECT: "object",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TypeScript class and method keywords
|
||||||
|
*/
|
||||||
|
export const CLASS_KEYWORDS = {
|
||||||
|
CONSTRUCTOR: "constructor",
|
||||||
|
PUBLIC: "public",
|
||||||
|
PRIVATE: "private",
|
||||||
|
PROTECTED: "protected",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example code constants for documentation
|
||||||
|
*/
|
||||||
|
export const EXAMPLE_CODE_CONSTANTS = {
|
||||||
|
ORDER_STATUS_PENDING: "pending",
|
||||||
|
ORDER_STATUS_APPROVED: "approved",
|
||||||
|
CANNOT_APPROVE_ERROR: "Cannot approve",
|
||||||
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Common regex patterns
|
* Common regex patterns
|
||||||
*/
|
*/
|
||||||
@@ -93,6 +112,7 @@ export const VIOLATION_SEVERITY_MAP = {
|
|||||||
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,
|
||||||
|
ANEMIC_MODEL: SEVERITY_LEVELS.MEDIUM,
|
||||||
NAMING_CONVENTION: SEVERITY_LEVELS.MEDIUM,
|
NAMING_CONVENTION: SEVERITY_LEVELS.MEDIUM,
|
||||||
ARCHITECTURE: SEVERITY_LEVELS.MEDIUM,
|
ARCHITECTURE: SEVERITY_LEVELS.MEDIUM,
|
||||||
HARDCODE: SEVERITY_LEVELS.LOW,
|
HARDCODE: SEVERITY_LEVELS.LOW,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const RULES = {
|
|||||||
REPOSITORY_PATTERN: "repository-pattern",
|
REPOSITORY_PATTERN: "repository-pattern",
|
||||||
AGGREGATE_BOUNDARY: "aggregate-boundary",
|
AGGREGATE_BOUNDARY: "aggregate-boundary",
|
||||||
SECRET_EXPOSURE: "secret-exposure",
|
SECRET_EXPOSURE: "secret-exposure",
|
||||||
|
ANEMIC_MODEL: "anemic-model",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
372
packages/guardian/tests/AnemicModelDetector.test.ts
Normal file
372
packages/guardian/tests/AnemicModelDetector.test.ts
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest"
|
||||||
|
import { AnemicModelDetector } from "../src/infrastructure/analyzers/AnemicModelDetector"
|
||||||
|
|
||||||
|
describe("AnemicModelDetector", () => {
|
||||||
|
let detector: AnemicModelDetector
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
detector = new AnemicModelDetector()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("detectAnemicModels", () => {
|
||||||
|
it("should detect class with only getters and setters", () => {
|
||||||
|
const code = `
|
||||||
|
class Order {
|
||||||
|
private status: string
|
||||||
|
private total: number
|
||||||
|
|
||||||
|
getStatus(): string {
|
||||||
|
return this.status
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(status: string): void {
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
|
||||||
|
getTotal(): number {
|
||||||
|
return this.total
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotal(total: number): void {
|
||||||
|
this.total = total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectAnemicModels(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/Order.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
expect(violations[0].className).toBe("Order")
|
||||||
|
expect(violations[0].methodCount).toBeGreaterThan(0)
|
||||||
|
expect(violations[0].propertyCount).toBeGreaterThan(0)
|
||||||
|
expect(violations[0].getMessage()).toContain("Order")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect class with public setters", () => {
|
||||||
|
const code = `
|
||||||
|
class User {
|
||||||
|
private email: string
|
||||||
|
private password: string
|
||||||
|
|
||||||
|
public setEmail(email: string): void {
|
||||||
|
this.email = email
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEmail(): string {
|
||||||
|
return this.email
|
||||||
|
}
|
||||||
|
|
||||||
|
public setPassword(password: string): void {
|
||||||
|
this.password = password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectAnemicModels(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/User.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
expect(violations[0].className).toBe("User")
|
||||||
|
expect(violations[0].hasPublicSetters).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not detect rich domain model with business logic", () => {
|
||||||
|
const code = `
|
||||||
|
class Order {
|
||||||
|
private readonly id: string
|
||||||
|
private status: OrderStatus
|
||||||
|
private items: OrderItem[]
|
||||||
|
|
||||||
|
public approve(): void {
|
||||||
|
if (!this.canBeApproved()) {
|
||||||
|
throw new Error("Cannot approve")
|
||||||
|
}
|
||||||
|
this.status = OrderStatus.APPROVED
|
||||||
|
}
|
||||||
|
|
||||||
|
public reject(reason: string): void {
|
||||||
|
if (!this.canBeRejected()) {
|
||||||
|
throw new Error("Cannot reject")
|
||||||
|
}
|
||||||
|
this.status = OrderStatus.REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
public addItem(item: OrderItem): void {
|
||||||
|
if (this.isApproved()) {
|
||||||
|
throw new Error("Cannot modify approved order")
|
||||||
|
}
|
||||||
|
this.items.push(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
public calculateTotal(): Money {
|
||||||
|
return this.items.reduce((sum, item) => sum.add(item.getPrice()), Money.zero())
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStatus(): OrderStatus {
|
||||||
|
return this.status
|
||||||
|
}
|
||||||
|
|
||||||
|
private canBeApproved(): boolean {
|
||||||
|
return this.status === OrderStatus.PENDING
|
||||||
|
}
|
||||||
|
|
||||||
|
private canBeRejected(): boolean {
|
||||||
|
return this.status === OrderStatus.PENDING
|
||||||
|
}
|
||||||
|
|
||||||
|
private isApproved(): boolean {
|
||||||
|
return this.status === OrderStatus.APPROVED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectAnemicModels(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/Order.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not analyze files outside domain layer", () => {
|
||||||
|
const code = `
|
||||||
|
class OrderDto {
|
||||||
|
getStatus(): string {
|
||||||
|
return this.status
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(status: string): void {
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectAnemicModels(
|
||||||
|
code,
|
||||||
|
"src/application/dtos/OrderDto.ts",
|
||||||
|
"application",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not analyze DTO files", () => {
|
||||||
|
const code = `
|
||||||
|
class UserDto {
|
||||||
|
private email: string
|
||||||
|
|
||||||
|
getEmail(): string {
|
||||||
|
return this.email
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmail(email: string): void {
|
||||||
|
this.email = email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectAnemicModels(
|
||||||
|
code,
|
||||||
|
"src/domain/dtos/UserDto.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not analyze test files", () => {
|
||||||
|
const code = `
|
||||||
|
class Order {
|
||||||
|
getStatus(): string {
|
||||||
|
return this.status
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(status: string): void {
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectAnemicModels(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/Order.test.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect anemic model in entities folder", () => {
|
||||||
|
const code = `
|
||||||
|
class Product {
|
||||||
|
private name: string
|
||||||
|
private price: number
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return this.name
|
||||||
|
}
|
||||||
|
|
||||||
|
setName(name: string): void {
|
||||||
|
this.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
getPrice(): number {
|
||||||
|
return this.price
|
||||||
|
}
|
||||||
|
|
||||||
|
setPrice(price: number): void {
|
||||||
|
this.price = price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectAnemicModels(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/Product.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
expect(violations[0].className).toBe("Product")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect anemic model in aggregates folder", () => {
|
||||||
|
const code = `
|
||||||
|
class Customer {
|
||||||
|
private email: string
|
||||||
|
|
||||||
|
getEmail(): string {
|
||||||
|
return this.email
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmail(email: string): void {
|
||||||
|
this.email = email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectAnemicModels(
|
||||||
|
code,
|
||||||
|
"src/domain/aggregates/customer/Customer.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
expect(violations[0].className).toBe("Customer")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not detect class with good method-to-property ratio", () => {
|
||||||
|
const code = `
|
||||||
|
class Account {
|
||||||
|
private balance: number
|
||||||
|
private isActive: boolean
|
||||||
|
|
||||||
|
public deposit(amount: number): void {
|
||||||
|
if (amount <= 0) throw new Error("Invalid amount")
|
||||||
|
this.balance += amount
|
||||||
|
}
|
||||||
|
|
||||||
|
public withdraw(amount: number): void {
|
||||||
|
if (amount > this.balance) throw new Error("Insufficient funds")
|
||||||
|
this.balance -= amount
|
||||||
|
}
|
||||||
|
|
||||||
|
public activate(): void {
|
||||||
|
this.isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
public deactivate(): void {
|
||||||
|
this.isActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBalance(): number {
|
||||||
|
return this.balance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectAnemicModels(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/Account.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle class with no properties or methods", () => {
|
||||||
|
const code = `
|
||||||
|
class EmptyEntity {
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectAnemicModels(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/EmptyEntity.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect multiple anemic classes in one file", () => {
|
||||||
|
const code = `
|
||||||
|
class Order {
|
||||||
|
getStatus() { return this.status }
|
||||||
|
setStatus(status: string) { this.status = status }
|
||||||
|
}
|
||||||
|
|
||||||
|
class Item {
|
||||||
|
getPrice() { return this.price }
|
||||||
|
setPrice(price: number) { this.price = price }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectAnemicModels(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/Models.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(2)
|
||||||
|
expect(violations[0].className).toBe("Order")
|
||||||
|
expect(violations[1].className).toBe("Item")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should provide correct violation details", () => {
|
||||||
|
const code = `
|
||||||
|
class Payment {
|
||||||
|
private amount: number
|
||||||
|
private currency: string
|
||||||
|
|
||||||
|
getAmount(): number {
|
||||||
|
return this.amount
|
||||||
|
}
|
||||||
|
|
||||||
|
setAmount(amount: number): void {
|
||||||
|
this.amount = amount
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrency(): string {
|
||||||
|
return this.currency
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrency(currency: string): void {
|
||||||
|
this.currency = currency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectAnemicModels(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/Payment.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
const violation = violations[0]
|
||||||
|
expect(violation.className).toBe("Payment")
|
||||||
|
expect(violation.filePath).toBe("src/domain/entities/Payment.ts")
|
||||||
|
expect(violation.layer).toBe("domain")
|
||||||
|
expect(violation.line).toBeGreaterThan(0)
|
||||||
|
expect(violation.getMessage()).toContain("Payment")
|
||||||
|
expect(violation.getSuggestion()).toContain("business")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -27,6 +27,7 @@ describe("AnalyzeProject E2E", () => {
|
|||||||
expect(Array.isArray(result.dependencyDirectionViolations)).toBe(true)
|
expect(Array.isArray(result.dependencyDirectionViolations)).toBe(true)
|
||||||
expect(Array.isArray(result.repositoryPatternViolations)).toBe(true)
|
expect(Array.isArray(result.repositoryPatternViolations)).toBe(true)
|
||||||
expect(Array.isArray(result.aggregateBoundaryViolations)).toBe(true)
|
expect(Array.isArray(result.aggregateBoundaryViolations)).toBe(true)
|
||||||
|
expect(Array.isArray(result.anemicModelViolations)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should respect exclude patterns", async () => {
|
it("should respect exclude patterns", async () => {
|
||||||
@@ -65,7 +66,8 @@ describe("AnalyzeProject E2E", () => {
|
|||||||
result.entityExposureViolations.length +
|
result.entityExposureViolations.length +
|
||||||
result.dependencyDirectionViolations.length +
|
result.dependencyDirectionViolations.length +
|
||||||
result.repositoryPatternViolations.length +
|
result.repositoryPatternViolations.length +
|
||||||
result.aggregateBoundaryViolations.length
|
result.aggregateBoundaryViolations.length +
|
||||||
|
result.anemicModelViolations.length
|
||||||
|
|
||||||
expect(totalViolations).toBeGreaterThan(0)
|
expect(totalViolations).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
@@ -82,6 +84,7 @@ describe("AnalyzeProject E2E", () => {
|
|||||||
expect(result.entityExposureViolations.length).toBe(0)
|
expect(result.entityExposureViolations.length).toBe(0)
|
||||||
expect(result.dependencyDirectionViolations.length).toBe(0)
|
expect(result.dependencyDirectionViolations.length).toBe(0)
|
||||||
expect(result.circularDependencyViolations.length).toBe(0)
|
expect(result.circularDependencyViolations.length).toBe(0)
|
||||||
|
expect(result.anemicModelViolations.length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should have no dependency direction violations", async () => {
|
it("should have no dependency direction violations", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user