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/),
|
||||
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
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -2,7 +2,20 @@
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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",
|
||||
"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.",
|
||||
"keywords": [
|
||||
"puaros",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IDependencyDirectionDetector } from "./domain/services/IDependencyDirec
|
||||
import { IRepositoryPatternDetector } from "./domain/services/RepositoryPatternDetectorService"
|
||||
import { IAggregateBoundaryDetector } from "./domain/services/IAggregateBoundaryDetector"
|
||||
import { ISecretDetector } from "./domain/services/ISecretDetector"
|
||||
import { IAnemicModelDetector } from "./domain/services/IAnemicModelDetector"
|
||||
import { FileScanner } from "./infrastructure/scanners/FileScanner"
|
||||
import { CodeParser } from "./infrastructure/parsers/CodeParser"
|
||||
import { HardcodeDetector } from "./infrastructure/analyzers/HardcodeDetector"
|
||||
@@ -23,6 +24,7 @@ import { DependencyDirectionDetector } from "./infrastructure/analyzers/Dependen
|
||||
import { RepositoryPatternDetector } from "./infrastructure/analyzers/RepositoryPatternDetector"
|
||||
import { AggregateBoundaryDetector } from "./infrastructure/analyzers/AggregateBoundaryDetector"
|
||||
import { SecretDetector } from "./infrastructure/analyzers/SecretDetector"
|
||||
import { AnemicModelDetector } from "./infrastructure/analyzers/AnemicModelDetector"
|
||||
import { ERROR_MESSAGES } from "./shared/constants"
|
||||
|
||||
/**
|
||||
@@ -82,6 +84,7 @@ export async function analyzeProject(
|
||||
const repositoryPatternDetector: IRepositoryPatternDetector = new RepositoryPatternDetector()
|
||||
const aggregateBoundaryDetector: IAggregateBoundaryDetector = new AggregateBoundaryDetector()
|
||||
const secretDetector: ISecretDetector = new SecretDetector()
|
||||
const anemicModelDetector: IAnemicModelDetector = new AnemicModelDetector()
|
||||
const useCase = new AnalyzeProject(
|
||||
fileScanner,
|
||||
codeParser,
|
||||
@@ -93,6 +96,7 @@ export async function analyzeProject(
|
||||
repositoryPatternDetector,
|
||||
aggregateBoundaryDetector,
|
||||
secretDetector,
|
||||
anemicModelDetector,
|
||||
)
|
||||
|
||||
const result = await useCase.execute(options)
|
||||
@@ -116,5 +120,6 @@ export type {
|
||||
DependencyDirectionViolation,
|
||||
RepositoryPatternViolation,
|
||||
AggregateBoundaryViolation,
|
||||
AnemicModelViolation,
|
||||
ProjectMetrics,
|
||||
} from "./application/use-cases/AnalyzeProject"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { IDependencyDirectionDetector } from "../../domain/services/IDependencyD
|
||||
import { IRepositoryPatternDetector } from "../../domain/services/RepositoryPatternDetectorService"
|
||||
import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector"
|
||||
import { ISecretDetector } from "../../domain/services/ISecretDetector"
|
||||
import { IAnemicModelDetector } from "../../domain/services/IAnemicModelDetector"
|
||||
import { SourceFile } from "../../domain/entities/SourceFile"
|
||||
import { DependencyGraph } from "../../domain/entities/DependencyGraph"
|
||||
import { CollectFiles } from "./pipeline/CollectFiles"
|
||||
@@ -44,6 +45,7 @@ export interface AnalyzeProjectResponse {
|
||||
repositoryPatternViolations: RepositoryPatternViolation[]
|
||||
aggregateBoundaryViolations: AggregateBoundaryViolation[]
|
||||
secretViolations: SecretViolation[]
|
||||
anemicModelViolations: AnemicModelViolation[]
|
||||
metrics: ProjectMetrics
|
||||
}
|
||||
|
||||
@@ -176,6 +178,21 @@ export interface SecretViolation {
|
||||
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 {
|
||||
totalFiles: number
|
||||
totalFunctions: number
|
||||
@@ -207,6 +224,7 @@ export class AnalyzeProject extends UseCase<
|
||||
repositoryPatternDetector: IRepositoryPatternDetector,
|
||||
aggregateBoundaryDetector: IAggregateBoundaryDetector,
|
||||
secretDetector: ISecretDetector,
|
||||
anemicModelDetector: IAnemicModelDetector,
|
||||
) {
|
||||
super()
|
||||
this.fileCollectionStep = new CollectFiles(fileScanner)
|
||||
@@ -220,6 +238,7 @@ export class AnalyzeProject extends UseCase<
|
||||
repositoryPatternDetector,
|
||||
aggregateBoundaryDetector,
|
||||
secretDetector,
|
||||
anemicModelDetector,
|
||||
)
|
||||
this.resultAggregator = new AggregateResults()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DependencyGraph } from "../../../domain/entities/DependencyGraph"
|
||||
import type {
|
||||
AggregateBoundaryViolation,
|
||||
AnalyzeProjectResponse,
|
||||
AnemicModelViolation,
|
||||
ArchitectureViolation,
|
||||
CircularDependencyViolation,
|
||||
DependencyDirectionViolation,
|
||||
@@ -29,6 +30,7 @@ export interface AggregationRequest {
|
||||
repositoryPatternViolations: RepositoryPatternViolation[]
|
||||
aggregateBoundaryViolations: AggregateBoundaryViolation[]
|
||||
secretViolations: SecretViolation[]
|
||||
anemicModelViolations: AnemicModelViolation[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +57,7 @@ export class AggregateResults {
|
||||
repositoryPatternViolations: request.repositoryPatternViolations,
|
||||
aggregateBoundaryViolations: request.aggregateBoundaryViolations,
|
||||
secretViolations: request.secretViolations,
|
||||
anemicModelViolations: request.anemicModelViolations,
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IDependencyDirectionDetector } from "../../../domain/services/IDependen
|
||||
import { IRepositoryPatternDetector } from "../../../domain/services/RepositoryPatternDetectorService"
|
||||
import { IAggregateBoundaryDetector } from "../../../domain/services/IAggregateBoundaryDetector"
|
||||
import { ISecretDetector } from "../../../domain/services/ISecretDetector"
|
||||
import { IAnemicModelDetector } from "../../../domain/services/IAnemicModelDetector"
|
||||
import { SourceFile } from "../../../domain/entities/SourceFile"
|
||||
import { DependencyGraph } from "../../../domain/entities/DependencyGraph"
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from "../../../shared/constants"
|
||||
import type {
|
||||
AggregateBoundaryViolation,
|
||||
AnemicModelViolation,
|
||||
ArchitectureViolation,
|
||||
CircularDependencyViolation,
|
||||
DependencyDirectionViolation,
|
||||
@@ -45,6 +47,7 @@ export interface DetectionResult {
|
||||
repositoryPatternViolations: RepositoryPatternViolation[]
|
||||
aggregateBoundaryViolations: AggregateBoundaryViolation[]
|
||||
secretViolations: SecretViolation[]
|
||||
anemicModelViolations: AnemicModelViolation[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +63,7 @@ export class ExecuteDetection {
|
||||
private readonly repositoryPatternDetector: IRepositoryPatternDetector,
|
||||
private readonly aggregateBoundaryDetector: IAggregateBoundaryDetector,
|
||||
private readonly secretDetector: ISecretDetector,
|
||||
private readonly anemicModelDetector: IAnemicModelDetector,
|
||||
) {}
|
||||
|
||||
public async execute(request: DetectionRequest): Promise<DetectionResult> {
|
||||
@@ -90,6 +94,9 @@ export class ExecuteDetection {
|
||||
this.detectAggregateBoundaryViolations(request.sourceFiles),
|
||||
),
|
||||
secretViolations: this.sortBySeverity(secretViolations),
|
||||
anemicModelViolations: this.sortBySeverity(
|
||||
this.detectAnemicModels(request.sourceFiles),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,6 +405,37 @@ export class ExecuteDetection {
|
||||
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[] {
|
||||
return violations.sort((a, b) => {
|
||||
return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SEVERITY_LEVELS, type SeverityLevel } from "../../shared/constants"
|
||||
import type {
|
||||
AggregateBoundaryViolation,
|
||||
AnemicModelViolation,
|
||||
ArchitectureViolation,
|
||||
CircularDependencyViolation,
|
||||
DependencyDirectionViolation,
|
||||
@@ -204,4 +205,31 @@ export class OutputFormatter {
|
||||
console.log(` 📁 Location: ${hc.suggestion.location}`)
|
||||
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,
|
||||
aggregateBoundaryViolations,
|
||||
secretViolations,
|
||||
anemicModelViolations,
|
||||
} = result
|
||||
|
||||
const minSeverity: SeverityLevel | undefined = options.onlyCritical
|
||||
@@ -134,6 +135,7 @@ program
|
||||
minSeverity,
|
||||
)
|
||||
secretViolations = grouper.filterBySeverity(secretViolations, minSeverity)
|
||||
anemicModelViolations = grouper.filterBySeverity(anemicModelViolations, minSeverity)
|
||||
|
||||
statsFormatter.displaySeverityFilterMessage(
|
||||
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) {
|
||||
console.log(
|
||||
`\n${CLI_MESSAGES.HARDCODE_VIOLATIONS_HEADER} ${String(hardcodeViolations.length)} ${CLI_LABELS.HARDCODE_VIOLATIONS}`,
|
||||
@@ -283,7 +298,8 @@ program
|
||||
dependencyDirectionViolations.length +
|
||||
repositoryPatternViolations.length +
|
||||
aggregateBoundaryViolations.length +
|
||||
secretViolations.length
|
||||
secretViolations.length +
|
||||
anemicModelViolations.length
|
||||
|
||||
statsFormatter.displaySummary(totalIssues, options.verbose)
|
||||
} catch (error) {
|
||||
|
||||
@@ -69,3 +69,14 @@ export const SECRET_VIOLATION_MESSAGES = {
|
||||
ROTATE_IF_EXPOSED: "4. If secret was committed, rotate it immediately",
|
||||
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",
|
||||
} 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
|
||||
*/
|
||||
@@ -93,6 +112,7 @@ export const VIOLATION_SEVERITY_MAP = {
|
||||
DEPENDENCY_DIRECTION: SEVERITY_LEVELS.HIGH,
|
||||
FRAMEWORK_LEAK: SEVERITY_LEVELS.HIGH,
|
||||
ENTITY_EXPOSURE: SEVERITY_LEVELS.HIGH,
|
||||
ANEMIC_MODEL: SEVERITY_LEVELS.MEDIUM,
|
||||
NAMING_CONVENTION: SEVERITY_LEVELS.MEDIUM,
|
||||
ARCHITECTURE: SEVERITY_LEVELS.MEDIUM,
|
||||
HARDCODE: SEVERITY_LEVELS.LOW,
|
||||
|
||||
@@ -12,6 +12,7 @@ export const RULES = {
|
||||
REPOSITORY_PATTERN: "repository-pattern",
|
||||
AGGREGATE_BOUNDARY: "aggregate-boundary",
|
||||
SECRET_EXPOSURE: "secret-exposure",
|
||||
ANEMIC_MODEL: "anemic-model",
|
||||
} 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.repositoryPatternViolations)).toBe(true)
|
||||
expect(Array.isArray(result.aggregateBoundaryViolations)).toBe(true)
|
||||
expect(Array.isArray(result.anemicModelViolations)).toBe(true)
|
||||
})
|
||||
|
||||
it("should respect exclude patterns", async () => {
|
||||
@@ -65,7 +66,8 @@ describe("AnalyzeProject E2E", () => {
|
||||
result.entityExposureViolations.length +
|
||||
result.dependencyDirectionViolations.length +
|
||||
result.repositoryPatternViolations.length +
|
||||
result.aggregateBoundaryViolations.length
|
||||
result.aggregateBoundaryViolations.length +
|
||||
result.anemicModelViolations.length
|
||||
|
||||
expect(totalViolations).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -82,6 +84,7 @@ describe("AnalyzeProject E2E", () => {
|
||||
expect(result.entityExposureViolations.length).toBe(0)
|
||||
expect(result.dependencyDirectionViolations.length).toBe(0)
|
||||
expect(result.circularDependencyViolations.length).toBe(0)
|
||||
expect(result.anemicModelViolations.length).toBe(0)
|
||||
})
|
||||
|
||||
it("should have no dependency direction violations", async () => {
|
||||
|
||||
Reference in New Issue
Block a user