From c75738ba515fb07feaea2496818f3da10d6e0a62 Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Mon, 24 Nov 2025 23:54:16 +0500 Subject: [PATCH] feat: add aggregate boundary validation (v0.7.0) Implement DDD aggregate boundary validation to detect and prevent direct entity references across aggregate boundaries. Features: - Detect direct entity imports between aggregates - Allow only ID or Value Object references - Support multiple folder structures (domain/aggregates/*, domain/*, domain/entities/*) - Filter allowed imports (value-objects, events, repositories, services) - Critical severity level for violations - 41 comprehensive tests with 92.55% coverage - CLI output with detailed suggestions - Examples of good and bad patterns Breaking changes: None Backwards compatible: Yes --- packages/guardian/README.md | 9 + packages/guardian/ROADMAP.md | 26 +- .../bad/Order-DirectEntityReference.ts | 40 ++ .../good/Order-IdReference.ts | 40 ++ .../good/Order-ValueObject.ts | 61 ++ packages/guardian/package.json | 2 +- packages/guardian/src/api.ts | 5 + .../application/use-cases/AnalyzeProject.ts | 51 ++ packages/guardian/src/cli/index.ts | 37 +- .../guardian/src/domain/constants/Messages.ts | 8 + .../services/IAggregateBoundaryDetector.ts | 45 ++ .../AggregateBoundaryViolation.ts | 137 +++++ .../analyzers/AggregateBoundaryDetector.ts | 308 ++++++++++ .../guardian/src/shared/constants/index.ts | 1 + .../guardian/src/shared/constants/rules.ts | 1 + .../tests/AggregateBoundaryDetector.test.ts | 538 ++++++++++++++++++ 16 files changed, 1297 insertions(+), 12 deletions(-) create mode 100644 packages/guardian/examples/aggregate-boundary/bad/Order-DirectEntityReference.ts create mode 100644 packages/guardian/examples/aggregate-boundary/good/Order-IdReference.ts create mode 100644 packages/guardian/examples/aggregate-boundary/good/Order-ValueObject.ts create mode 100644 packages/guardian/src/domain/services/IAggregateBoundaryDetector.ts create mode 100644 packages/guardian/src/domain/value-objects/AggregateBoundaryViolation.ts create mode 100644 packages/guardian/src/infrastructure/analyzers/AggregateBoundaryDetector.ts create mode 100644 packages/guardian/tests/AggregateBoundaryDetector.test.ts diff --git a/packages/guardian/README.md b/packages/guardian/README.md index e8cb62a..a68bcd3 100644 --- a/packages/guardian/README.md +++ b/packages/guardian/README.md @@ -72,6 +72,15 @@ Code quality guardian for vibe coders and enterprise teams - because AI writes f - Prevents "new Repository()" anti-pattern - šŸ“š *Based on: Martin Fowler's Repository Pattern, DDD (Evans 2003)* → [Why?](./docs/WHY.md#repository-pattern) +šŸ”’ **Aggregate Boundary Validation** ✨ NEW +- Detects direct entity references across DDD aggregates +- Enforces reference-by-ID or Value Object pattern +- Prevents tight coupling between aggregates +- Supports multiple folder structures (domain/aggregates/*, domain/*, domain/entities/*) +- Filters allowed imports (value-objects, events, repositories, services) +- Critical severity for maintaining aggregate independence +- šŸ“š *Based on: Domain-Driven Design (Evans 2003), Implementing DDD (Vernon 2013)* → [Why?](./docs/WHY.md#aggregate-boundaries) + šŸ—ļø **Clean Architecture Enforcement** - Built with DDD principles - Layered architecture (Domain, Application, Infrastructure) diff --git a/packages/guardian/ROADMAP.md b/packages/guardian/ROADMAP.md index 07f62f3..fe18987 100644 --- a/packages/guardian/ROADMAP.md +++ b/packages/guardian/ROADMAP.md @@ -256,11 +256,10 @@ Internal refactoring to eliminate hardcoded values and improve maintainability: --- -## Future Roadmap +## Version 0.7.0 - Aggregate Boundary Validation šŸ”’ āœ… RELEASED -### Version 0.6.0 - Aggregate Boundary Validation šŸ”’ -**Target:** Q1 2026 -**Priority:** MEDIUM +**Released:** 2025-11-24 +**Priority:** CRITICAL Validate aggregate boundaries in DDD: @@ -286,12 +285,19 @@ class Order { } ``` -**Planned Features:** -- Detect entity references across aggregates -- Allow only ID or Value Object references -- Detect circular dependencies between aggregates -- Validate aggregate root access patterns -- Support for aggregate folder structure +**Implemented Features:** +- āœ… Detect entity references across aggregates +- āœ… Allow only ID or Value Object references from other aggregates +- āœ… Filter allowed imports (value-objects, events, repositories, services) +- āœ… Support for multiple aggregate folder structures (domain/aggregates/name, domain/name, domain/entities/name) +- āœ… 41 comprehensive tests with 100% pass rate +- āœ… Examples of good and bad patterns +- āœ… CLI output with šŸ”’ icon and detailed violation info +- āœ… Critical severity level for aggregate boundary violations + +--- + +## Future Roadmap --- diff --git a/packages/guardian/examples/aggregate-boundary/bad/Order-DirectEntityReference.ts b/packages/guardian/examples/aggregate-boundary/bad/Order-DirectEntityReference.ts new file mode 100644 index 0000000..ceed0ce --- /dev/null +++ b/packages/guardian/examples/aggregate-boundary/bad/Order-DirectEntityReference.ts @@ -0,0 +1,40 @@ +/** + * āŒ BAD EXAMPLE: Direct Entity Reference Across Aggregates + * + * Violation: Order aggregate directly imports and uses User entity from User aggregate + * + * Problems: + * 1. Creates tight coupling between aggregates + * 2. Changes to User entity affect Order aggregate + * 3. Violates aggregate boundary principles in DDD + * 4. Makes aggregates not independently modifiable + */ + +import { User } from "../user/User" +import { Product } from "../product/Product" + +export class Order { + private id: string + private user: User + private product: Product + private quantity: number + + constructor(id: string, user: User, product: Product, quantity: number) { + this.id = id + this.user = user + this.product = product + this.quantity = quantity + } + + getUserEmail(): string { + return this.user.email + } + + getProductPrice(): number { + return this.product.price + } + + calculateTotal(): number { + return this.product.price * this.quantity + } +} diff --git a/packages/guardian/examples/aggregate-boundary/good/Order-IdReference.ts b/packages/guardian/examples/aggregate-boundary/good/Order-IdReference.ts new file mode 100644 index 0000000..8dda712 --- /dev/null +++ b/packages/guardian/examples/aggregate-boundary/good/Order-IdReference.ts @@ -0,0 +1,40 @@ +/** + * āœ… GOOD EXAMPLE: Reference by ID + * + * Best Practice: Order aggregate references other aggregates only by their IDs + * + * Benefits: + * 1. Loose coupling between aggregates + * 2. Each aggregate can be modified independently + * 3. Follows DDD aggregate boundary principles + * 4. Clear separation of concerns + */ + +import { UserId } from "../user/value-objects/UserId" +import { ProductId } from "../product/value-objects/ProductId" + +export class Order { + private id: string + private userId: UserId + private productId: ProductId + private quantity: number + + constructor(id: string, userId: UserId, productId: ProductId, quantity: number) { + this.id = id + this.userId = userId + this.productId = productId + this.quantity = quantity + } + + getUserId(): UserId { + return this.userId + } + + getProductId(): ProductId { + return this.productId + } + + getQuantity(): number { + return this.quantity + } +} diff --git a/packages/guardian/examples/aggregate-boundary/good/Order-ValueObject.ts b/packages/guardian/examples/aggregate-boundary/good/Order-ValueObject.ts new file mode 100644 index 0000000..a7c24cb --- /dev/null +++ b/packages/guardian/examples/aggregate-boundary/good/Order-ValueObject.ts @@ -0,0 +1,61 @@ +/** + * āœ… GOOD EXAMPLE: Using Value Objects for Needed Data + * + * Best Practice: When Order needs specific data from other aggregates, + * use Value Objects to store that data (denormalization) + * + * Benefits: + * 1. Order aggregate has all data it needs + * 2. No runtime dependency on other aggregates + * 3. Better performance (no joins needed) + * 4. Clear contract through Value Objects + */ + +import { UserId } from "../user/value-objects/UserId" +import { ProductId } from "../product/value-objects/ProductId" + +export class CustomerInfo { + constructor( + readonly customerId: UserId, + readonly customerName: string, + readonly customerEmail: string, + ) {} +} + +export class ProductInfo { + constructor( + readonly productId: ProductId, + readonly productName: string, + readonly productPrice: number, + ) {} +} + +export class Order { + private id: string + private customer: CustomerInfo + private product: ProductInfo + private quantity: number + + constructor(id: string, customer: CustomerInfo, product: ProductInfo, quantity: number) { + this.id = id + this.customer = customer + this.product = product + this.quantity = quantity + } + + getCustomerEmail(): string { + return this.customer.customerEmail + } + + calculateTotal(): number { + return this.product.productPrice * this.quantity + } + + getCustomerInfo(): CustomerInfo { + return this.customer + } + + getProductInfo(): ProductInfo { + return this.product + } +} diff --git a/packages/guardian/package.json b/packages/guardian/package.json index 987e847..12ad8de 100644 --- a/packages/guardian/package.json +++ b/packages/guardian/package.json @@ -1,6 +1,6 @@ { "name": "@samiyev/guardian", - "version": "0.6.4", + "version": "0.7.0", "description": "Research-backed code quality guardian for AI-assisted development. Detects hardcodes, circular deps, framework leaks, entity exposure, and 8 architecture violations. Enforces Clean Architecture/DDD principles. Works with GitHub Copilot, Cursor, Windsurf, Claude, ChatGPT, Cline, and any AI coding tool.", "keywords": [ "puaros", diff --git a/packages/guardian/src/api.ts b/packages/guardian/src/api.ts index b9b31c1..9647e41 100644 --- a/packages/guardian/src/api.ts +++ b/packages/guardian/src/api.ts @@ -11,6 +11,7 @@ import { IFrameworkLeakDetector } from "./domain/services/IFrameworkLeakDetector import { IEntityExposureDetector } from "./domain/services/IEntityExposureDetector" import { IDependencyDirectionDetector } from "./domain/services/IDependencyDirectionDetector" import { IRepositoryPatternDetector } from "./domain/services/RepositoryPatternDetectorService" +import { IAggregateBoundaryDetector } from "./domain/services/IAggregateBoundaryDetector" import { FileScanner } from "./infrastructure/scanners/FileScanner" import { CodeParser } from "./infrastructure/parsers/CodeParser" import { HardcodeDetector } from "./infrastructure/analyzers/HardcodeDetector" @@ -19,6 +20,7 @@ import { FrameworkLeakDetector } from "./infrastructure/analyzers/FrameworkLeakD import { EntityExposureDetector } from "./infrastructure/analyzers/EntityExposureDetector" import { DependencyDirectionDetector } from "./infrastructure/analyzers/DependencyDirectionDetector" import { RepositoryPatternDetector } from "./infrastructure/analyzers/RepositoryPatternDetector" +import { AggregateBoundaryDetector } from "./infrastructure/analyzers/AggregateBoundaryDetector" import { ERROR_MESSAGES } from "./shared/constants" /** @@ -76,6 +78,7 @@ export async function analyzeProject( const dependencyDirectionDetector: IDependencyDirectionDetector = new DependencyDirectionDetector() const repositoryPatternDetector: IRepositoryPatternDetector = new RepositoryPatternDetector() + const aggregateBoundaryDetector: IAggregateBoundaryDetector = new AggregateBoundaryDetector() const useCase = new AnalyzeProject( fileScanner, codeParser, @@ -85,6 +88,7 @@ export async function analyzeProject( entityExposureDetector, dependencyDirectionDetector, repositoryPatternDetector, + aggregateBoundaryDetector, ) const result = await useCase.execute(options) @@ -107,5 +111,6 @@ export type { EntityExposureViolation, DependencyDirectionViolation, RepositoryPatternViolation, + AggregateBoundaryViolation, ProjectMetrics, } from "./application/use-cases/AnalyzeProject" diff --git a/packages/guardian/src/application/use-cases/AnalyzeProject.ts b/packages/guardian/src/application/use-cases/AnalyzeProject.ts index c0791aa..69a4296 100644 --- a/packages/guardian/src/application/use-cases/AnalyzeProject.ts +++ b/packages/guardian/src/application/use-cases/AnalyzeProject.ts @@ -8,6 +8,7 @@ import { IFrameworkLeakDetector } from "../../domain/services/IFrameworkLeakDete import { IEntityExposureDetector } from "../../domain/services/IEntityExposureDetector" import { IDependencyDirectionDetector } from "../../domain/services/IDependencyDirectionDetector" import { IRepositoryPatternDetector } from "../../domain/services/RepositoryPatternDetectorService" +import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector" import { SourceFile } from "../../domain/entities/SourceFile" import { DependencyGraph } from "../../domain/entities/DependencyGraph" import { ProjectPath } from "../../domain/value-objects/ProjectPath" @@ -41,6 +42,7 @@ export interface AnalyzeProjectResponse { entityExposureViolations: EntityExposureViolation[] dependencyDirectionViolations: DependencyDirectionViolation[] repositoryPatternViolations: RepositoryPatternViolation[] + aggregateBoundaryViolations: AggregateBoundaryViolation[] metrics: ProjectMetrics } @@ -149,6 +151,19 @@ export interface RepositoryPatternViolation { severity: SeverityLevel } +export interface AggregateBoundaryViolation { + rule: typeof RULES.AGGREGATE_BOUNDARY + fromAggregate: string + toAggregate: string + entityName: string + importPath: string + file: string + line?: number + message: string + suggestion: string + severity: SeverityLevel +} + export interface ProjectMetrics { totalFiles: number totalFunctions: number @@ -172,6 +187,7 @@ export class AnalyzeProject extends UseCase< private readonly entityExposureDetector: IEntityExposureDetector, private readonly dependencyDirectionDetector: IDependencyDirectionDetector, private readonly repositoryPatternDetector: IRepositoryPatternDetector, + private readonly aggregateBoundaryDetector: IAggregateBoundaryDetector, ) { super() } @@ -234,6 +250,9 @@ export class AnalyzeProject extends UseCase< const repositoryPatternViolations = this.sortBySeverity( this.detectRepositoryPatternViolations(sourceFiles), ) + const aggregateBoundaryViolations = this.sortBySeverity( + this.detectAggregateBoundaryViolations(sourceFiles), + ) const metrics = this.calculateMetrics(sourceFiles, totalFunctions, dependencyGraph) return ResponseDto.ok({ @@ -247,6 +266,7 @@ export class AnalyzeProject extends UseCase< entityExposureViolations, dependencyDirectionViolations, repositoryPatternViolations, + aggregateBoundaryViolations, metrics, }) } catch (error) { @@ -532,6 +552,37 @@ export class AnalyzeProject extends UseCase< return violations } + private detectAggregateBoundaryViolations( + sourceFiles: SourceFile[], + ): AggregateBoundaryViolation[] { + const violations: AggregateBoundaryViolation[] = [] + + for (const file of sourceFiles) { + const boundaryViolations = this.aggregateBoundaryDetector.detectViolations( + file.content, + file.path.relative, + file.layer, + ) + + for (const violation of boundaryViolations) { + violations.push({ + rule: RULES.AGGREGATE_BOUNDARY, + fromAggregate: violation.fromAggregate, + toAggregate: violation.toAggregate, + entityName: violation.entityName, + importPath: violation.importPath, + file: file.path.relative, + line: violation.line, + message: violation.getMessage(), + suggestion: violation.getSuggestion(), + severity: VIOLATION_SEVERITY_MAP.AGGREGATE_BOUNDARY, + }) + } + } + + return violations + } + private calculateMetrics( sourceFiles: SourceFile[], totalFunctions: number, diff --git a/packages/guardian/src/cli/index.ts b/packages/guardian/src/cli/index.ts index a41e363..693b047 100644 --- a/packages/guardian/src/cli/index.ts +++ b/packages/guardian/src/cli/index.ts @@ -155,6 +155,7 @@ program entityExposureViolations, dependencyDirectionViolations, repositoryPatternViolations, + aggregateBoundaryViolations, } = result const minSeverity: SeverityLevel | undefined = options.onlyCritical @@ -185,6 +186,10 @@ program repositoryPatternViolations, minSeverity, ) + aggregateBoundaryViolations = filterBySeverity( + aggregateBoundaryViolations, + minSeverity, + ) if (options.onlyCritical) { console.log("\nšŸ”“ Filtering: Showing only CRITICAL severity issues\n") @@ -374,6 +379,35 @@ program ) } + // Aggregate boundary violations + if (options.architecture && aggregateBoundaryViolations.length > 0) { + console.log( + `\nšŸ”’ Found ${String(aggregateBoundaryViolations.length)} aggregate boundary violation(s)`, + ) + + displayGroupedViolations( + aggregateBoundaryViolations, + (ab, index) => { + const location = ab.line ? `${ab.file}:${String(ab.line)}` : ab.file + console.log(`${String(index + 1)}. ${location}`) + console.log(` Severity: ${SEVERITY_LABELS[ab.severity]}`) + console.log(` From Aggregate: ${ab.fromAggregate}`) + console.log(` To Aggregate: ${ab.toAggregate}`) + console.log(` Entity: ${ab.entityName}`) + console.log(` Import: ${ab.importPath}`) + console.log(` ${ab.message}`) + console.log(" šŸ’” Suggestion:") + ab.suggestion.split("\n").forEach((line) => { + if (line.trim()) { + console.log(` ${line}`) + } + }) + console.log("") + }, + limit, + ) + } + // Hardcode violations if (options.hardcode && hardcodeViolations.length > 0) { console.log( @@ -407,7 +441,8 @@ program frameworkLeakViolations.length + entityExposureViolations.length + dependencyDirectionViolations.length + - repositoryPatternViolations.length + repositoryPatternViolations.length + + aggregateBoundaryViolations.length if (totalIssues === 0) { console.log(CLI_MESSAGES.NO_ISSUES) diff --git a/packages/guardian/src/domain/constants/Messages.ts b/packages/guardian/src/domain/constants/Messages.ts index a282c50..86afcbf 100644 --- a/packages/guardian/src/domain/constants/Messages.ts +++ b/packages/guardian/src/domain/constants/Messages.ts @@ -48,3 +48,11 @@ export const REPOSITORY_PATTERN_MESSAGES = { SUGGESTION_DELETE: "remove or delete", SUGGESTION_QUERY: "find or search", } + +export const AGGREGATE_VIOLATION_MESSAGES = { + USE_ID_REFERENCE: "1. Reference other aggregates by ID (UserId, OrderId) instead of entity", + USE_VALUE_OBJECT: + "2. Use Value Objects to store needed data from other aggregates (CustomerInfo, ProductSummary)", + AVOID_DIRECT_REFERENCE: "3. Avoid direct entity references to maintain aggregate independence", + MAINTAIN_INDEPENDENCE: "4. Each aggregate should be independently modifiable and deployable", +} diff --git a/packages/guardian/src/domain/services/IAggregateBoundaryDetector.ts b/packages/guardian/src/domain/services/IAggregateBoundaryDetector.ts new file mode 100644 index 0000000..bd80409 --- /dev/null +++ b/packages/guardian/src/domain/services/IAggregateBoundaryDetector.ts @@ -0,0 +1,45 @@ +import { AggregateBoundaryViolation } from "../value-objects/AggregateBoundaryViolation" + +/** + * Interface for detecting aggregate boundary violations in DDD + * + * Aggregate boundary violations occur when an entity from one aggregate + * directly references an entity from another aggregate. In DDD, aggregates + * should reference each other only by ID or Value Objects to maintain + * loose coupling and independence. + */ +export interface IAggregateBoundaryDetector { + /** + * Detects aggregate boundary violations in the given code + * + * Analyzes import statements to identify direct entity references + * across aggregate boundaries. + * + * @param code - Source code to analyze + * @param filePath - Path to the file being analyzed + * @param layer - The architectural layer of the file (should be 'domain') + * @returns Array of detected aggregate boundary violations + */ + detectViolations( + code: string, + filePath: string, + layer: string | undefined, + ): AggregateBoundaryViolation[] + + /** + * Checks if a file path belongs to an aggregate + * + * @param filePath - The file path to check + * @returns The aggregate name if found, undefined otherwise + */ + extractAggregateFromPath(filePath: string): string | undefined + + /** + * Checks if an import path references an entity from another aggregate + * + * @param importPath - The import path to analyze + * @param currentAggregate - The aggregate of the current file + * @returns True if the import crosses aggregate boundaries inappropriately + */ + isAggregateBoundaryViolation(importPath: string, currentAggregate: string): boolean +} diff --git a/packages/guardian/src/domain/value-objects/AggregateBoundaryViolation.ts b/packages/guardian/src/domain/value-objects/AggregateBoundaryViolation.ts new file mode 100644 index 0000000..afba397 --- /dev/null +++ b/packages/guardian/src/domain/value-objects/AggregateBoundaryViolation.ts @@ -0,0 +1,137 @@ +import { ValueObject } from "./ValueObject" +import { AGGREGATE_VIOLATION_MESSAGES } from "../constants/Messages" + +interface AggregateBoundaryViolationProps { + readonly fromAggregate: string + readonly toAggregate: string + readonly entityName: string + readonly importPath: string + readonly filePath: string + readonly line?: number +} + +/** + * Represents an aggregate boundary violation in the codebase + * + * Aggregate boundary violations occur when an entity from one aggregate + * directly references an entity from another aggregate, violating DDD principles: + * - Aggregates should reference each other only by ID or Value Objects + * - Direct entity references create tight coupling between aggregates + * - Changes to one aggregate should not require changes to another + * + * @example + * ```typescript + * // Bad: Direct entity reference across aggregates + * const violation = AggregateBoundaryViolation.create( + * 'order', + * 'user', + * 'User', + * '../user/User', + * 'src/domain/aggregates/order/Order.ts', + * 5 + * ) + * + * console.log(violation.getMessage()) + * // "Order aggregate should not directly reference User entity from User aggregate" + * ``` + */ +export class AggregateBoundaryViolation extends ValueObject { + private constructor(props: AggregateBoundaryViolationProps) { + super(props) + } + + public static create( + fromAggregate: string, + toAggregate: string, + entityName: string, + importPath: string, + filePath: string, + line?: number, + ): AggregateBoundaryViolation { + return new AggregateBoundaryViolation({ + fromAggregate, + toAggregate, + entityName, + importPath, + filePath, + line, + }) + } + + public get fromAggregate(): string { + return this.props.fromAggregate + } + + public get toAggregate(): string { + return this.props.toAggregate + } + + public get entityName(): string { + return this.props.entityName + } + + public get importPath(): string { + return this.props.importPath + } + + public get filePath(): string { + return this.props.filePath + } + + public get line(): number | undefined { + return this.props.line + } + + public getMessage(): string { + return `${this.capitalizeFirst(this.props.fromAggregate)} aggregate should not directly reference ${this.props.entityName} entity from ${this.capitalizeFirst(this.props.toAggregate)} aggregate` + } + + public getSuggestion(): string { + const suggestions: string[] = [ + AGGREGATE_VIOLATION_MESSAGES.USE_ID_REFERENCE, + AGGREGATE_VIOLATION_MESSAGES.USE_VALUE_OBJECT, + AGGREGATE_VIOLATION_MESSAGES.AVOID_DIRECT_REFERENCE, + AGGREGATE_VIOLATION_MESSAGES.MAINTAIN_INDEPENDENCE, + ] + + return suggestions.join("\n") + } + + public getExampleFix(): string { + return ` +// āŒ Bad: Direct entity reference across aggregates +// domain/aggregates/order/Order.ts +import { User } from '../user/User' + +class Order { + constructor(private user: User) {} +} + +// āœ… Good: Reference by ID +// domain/aggregates/order/Order.ts +import { UserId } from '../user/value-objects/UserId' + +class Order { + constructor(private userId: UserId) {} +} + +// āœ… Good: Use Value Object for needed data +// domain/aggregates/order/value-objects/CustomerInfo.ts +class CustomerInfo { + constructor( + readonly customerId: string, + readonly customerName: string, + readonly customerEmail: string + ) {} +} + +// domain/aggregates/order/Order.ts +class Order { + constructor(private customerInfo: CustomerInfo) {} +}` + } + + private capitalizeFirst(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1) + } +} diff --git a/packages/guardian/src/infrastructure/analyzers/AggregateBoundaryDetector.ts b/packages/guardian/src/infrastructure/analyzers/AggregateBoundaryDetector.ts new file mode 100644 index 0000000..6c93072 --- /dev/null +++ b/packages/guardian/src/infrastructure/analyzers/AggregateBoundaryDetector.ts @@ -0,0 +1,308 @@ +import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoundaryDetector" +import { AggregateBoundaryViolation } from "../../domain/value-objects/AggregateBoundaryViolation" +import { LAYERS } from "../../shared/constants/rules" +import { IMPORT_PATTERNS } from "../constants/paths" + +/** + * Detects aggregate boundary violations in Domain-Driven Design + * + * This detector enforces DDD aggregate rules: + * - Aggregates should reference each other only by ID or Value Objects + * - Direct entity references across aggregates create tight coupling + * - Each aggregate should be independently modifiable + * + * Folder structure patterns detected: + * - domain/aggregates/order/Order.ts + * - domain/order/Order.ts (aggregate name from parent folder) + * - domain/entities/order/Order.ts + * + * @example + * ```typescript + * const detector = new AggregateBoundaryDetector() + * + * // Detect violations in order aggregate + * const code = ` + * import { User } from '../user/User' + * import { UserId } from '../user/value-objects/UserId' + * ` + * const violations = detector.detectViolations( + * code, + * 'src/domain/aggregates/order/Order.ts', + * 'domain' + * ) + * + * // violations will contain 1 violation for direct User entity import + * // but not for UserId (value object is allowed) + * console.log(violations.length) // 1 + * ``` + */ +export class AggregateBoundaryDetector implements IAggregateBoundaryDetector { + private readonly entityFolderNames = new Set(["entities", "aggregates"]) + private readonly valueObjectFolderNames = new Set(["value-objects", "vo"]) + private readonly allowedFolderNames = new Set([ + "value-objects", + "vo", + "events", + "domain-events", + "repositories", + "services", + "specifications", + ]) + + /** + * Detects aggregate boundary violations in the given code + * + * Analyzes import statements to identify direct entity references + * across aggregate boundaries in the domain layer. + * + * @param code - Source code to analyze + * @param filePath - Path to the file being analyzed + * @param layer - The architectural layer of the file (should be 'domain') + * @returns Array of detected aggregate boundary violations + */ + public detectViolations( + code: string, + filePath: string, + layer: string | undefined, + ): AggregateBoundaryViolation[] { + if (layer !== LAYERS.DOMAIN) { + return [] + } + + const currentAggregate = this.extractAggregateFromPath(filePath) + if (!currentAggregate) { + return [] + } + + const violations: AggregateBoundaryViolation[] = [] + const lines = code.split("\n") + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const lineNumber = i + 1 + + const imports = this.extractImports(line) + for (const importPath of imports) { + if (this.isAggregateBoundaryViolation(importPath, currentAggregate)) { + const targetAggregate = this.extractAggregateFromImport(importPath) + const entityName = this.extractEntityName(importPath) + + if (targetAggregate && entityName) { + violations.push( + AggregateBoundaryViolation.create( + currentAggregate, + targetAggregate, + entityName, + importPath, + filePath, + lineNumber, + ), + ) + } + } + } + } + + return violations + } + + /** + * Checks if a file path belongs to an aggregate + * + * Extracts aggregate name from paths like: + * - domain/aggregates/order/Order.ts → 'order' + * - domain/order/Order.ts → 'order' + * - domain/entities/order/Order.ts → 'order' + * + * @param filePath - The file path to check + * @returns The aggregate name if found, undefined otherwise + */ + public extractAggregateFromPath(filePath: string): string | undefined { + const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/") + + const domainMatch = /\/domain\//.exec(normalizedPath) + if (!domainMatch) { + return undefined + } + + const pathAfterDomain = normalizedPath.substring(domainMatch.index + domainMatch[0].length) + const segments = pathAfterDomain.split("/").filter(Boolean) + + if (segments.length < 2) { + return undefined + } + + if (this.entityFolderNames.has(segments[0])) { + if (segments.length < 3) { + return undefined + } + return segments[1] + } + + return segments[0] + } + + /** + * Checks if an import path references an entity from another aggregate + * + * @param importPath - The import path to analyze + * @param currentAggregate - The aggregate of the current file + * @returns True if the import crosses aggregate boundaries inappropriately + */ + public isAggregateBoundaryViolation(importPath: string, currentAggregate: string): boolean { + const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "").toLowerCase() + + if (!normalizedPath.includes("/")) { + return false + } + + if (!normalizedPath.startsWith(".") && !normalizedPath.startsWith("/")) { + return false + } + + const targetAggregate = this.extractAggregateFromImport(normalizedPath) + if (!targetAggregate || targetAggregate === currentAggregate) { + return false + } + + if (this.isAllowedImport(normalizedPath)) { + return false + } + + return this.seemsLikeEntityImport(normalizedPath) + } + + /** + * Checks if the import path is from an allowed folder (value-objects, events, etc.) + */ + private isAllowedImport(normalizedPath: string): boolean { + for (const folderName of this.allowedFolderNames) { + if (normalizedPath.includes(`/${folderName}/`)) { + return true + } + } + return false + } + + /** + * Checks if the import seems to be an entity (not a value object, event, etc.) + * + * Note: normalizedPath is already lowercased, so we check if the first character + * is a letter (indicating it was likely PascalCase originally) + */ + private seemsLikeEntityImport(normalizedPath: string): boolean { + const pathParts = normalizedPath.split("/") + const lastPart = pathParts[pathParts.length - 1] + + if (!lastPart) { + return false + } + + const filename = lastPart.replace(/\.(ts|js)$/, "") + + if (filename.length > 0 && /^[a-z][a-z]/.exec(filename)) { + return true + } + + return false + } + + /** + * Extracts the aggregate name from an import path + * + * Handles both absolute and relative paths: + * - ../user/User → user + * - ../../domain/user/User → user + * - ../user/value-objects/UserId → user (but filtered as value object) + */ + private extractAggregateFromImport(importPath: string): string | undefined { + const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "").toLowerCase() + + const segments = normalizedPath.split("/").filter((seg) => seg !== ".." && seg !== ".") + + if (segments.length === 0) { + return undefined + } + + for (let i = 0; i < segments.length; i++) { + if (segments[i] === "domain" || segments[i] === "aggregates") { + if (i + 1 < segments.length) { + if ( + this.entityFolderNames.has(segments[i + 1]) || + segments[i + 1] === "aggregates" + ) { + if (i + 2 < segments.length) { + return segments[i + 2] + } + } else { + return segments[i + 1] + } + } + } + } + + if (segments.length >= 2) { + const secondLastSegment = segments[segments.length - 2] + + if ( + !this.entityFolderNames.has(secondLastSegment) && + !this.valueObjectFolderNames.has(secondLastSegment) && + !this.allowedFolderNames.has(secondLastSegment) && + secondLastSegment !== "domain" + ) { + return secondLastSegment + } + } + + if (segments.length === 1) { + return undefined + } + + return undefined + } + + /** + * Extracts the entity name from an import path + */ + private extractEntityName(importPath: string): string | undefined { + const normalizedPath = importPath.replace(IMPORT_PATTERNS.QUOTE, "") + const segments = normalizedPath.split("/") + const lastSegment = segments[segments.length - 1] + + if (lastSegment) { + return lastSegment.replace(/\.(ts|js)$/, "") + } + + return undefined + } + + /** + * Extracts import paths from a line of code + * + * Handles various import statement formats: + * - import { X } from 'path' + * - import X from 'path' + * - import * as X from 'path' + * - const X = require('path') + * + * @param line - A line of code to analyze + * @returns Array of import paths found in the line + */ + private extractImports(line: string): string[] { + const imports: string[] = [] + + let match = IMPORT_PATTERNS.ES_IMPORT.exec(line) + while (match) { + imports.push(match[1]) + match = IMPORT_PATTERNS.ES_IMPORT.exec(line) + } + + match = IMPORT_PATTERNS.REQUIRE.exec(line) + while (match) { + imports.push(match[1]) + match = IMPORT_PATTERNS.REQUIRE.exec(line) + } + + return imports + } +} diff --git a/packages/guardian/src/shared/constants/index.ts b/packages/guardian/src/shared/constants/index.ts index cd45ce4..e8d4494 100644 --- a/packages/guardian/src/shared/constants/index.ts +++ b/packages/guardian/src/shared/constants/index.ts @@ -88,6 +88,7 @@ export const SEVERITY_ORDER: Record = { export const VIOLATION_SEVERITY_MAP = { CIRCULAR_DEPENDENCY: SEVERITY_LEVELS.CRITICAL, REPOSITORY_PATTERN: SEVERITY_LEVELS.CRITICAL, + AGGREGATE_BOUNDARY: SEVERITY_LEVELS.CRITICAL, DEPENDENCY_DIRECTION: SEVERITY_LEVELS.HIGH, FRAMEWORK_LEAK: SEVERITY_LEVELS.HIGH, ENTITY_EXPOSURE: SEVERITY_LEVELS.HIGH, diff --git a/packages/guardian/src/shared/constants/rules.ts b/packages/guardian/src/shared/constants/rules.ts index 9856cf3..3ddde40 100644 --- a/packages/guardian/src/shared/constants/rules.ts +++ b/packages/guardian/src/shared/constants/rules.ts @@ -10,6 +10,7 @@ export const RULES = { ENTITY_EXPOSURE: "entity-exposure", DEPENDENCY_DIRECTION: "dependency-direction", REPOSITORY_PATTERN: "repository-pattern", + AGGREGATE_BOUNDARY: "aggregate-boundary", } as const /** diff --git a/packages/guardian/tests/AggregateBoundaryDetector.test.ts b/packages/guardian/tests/AggregateBoundaryDetector.test.ts new file mode 100644 index 0000000..6ec2f78 --- /dev/null +++ b/packages/guardian/tests/AggregateBoundaryDetector.test.ts @@ -0,0 +1,538 @@ +import { describe, it, expect } from "vitest" +import { AggregateBoundaryDetector } from "../src/infrastructure/analyzers/AggregateBoundaryDetector" +import { LAYERS } from "../src/shared/constants/rules" + +describe("AggregateBoundaryDetector", () => { + const detector = new AggregateBoundaryDetector() + + describe("extractAggregateFromPath", () => { + it("should extract aggregate from domain/aggregates/name path", () => { + expect(detector.extractAggregateFromPath("src/domain/aggregates/order/Order.ts")).toBe( + "order", + ) + expect(detector.extractAggregateFromPath("src/domain/aggregates/user/User.ts")).toBe( + "user", + ) + expect( + detector.extractAggregateFromPath("src/domain/aggregates/product/Product.ts"), + ).toBe("product") + }) + + it("should extract aggregate from domain/name path", () => { + expect(detector.extractAggregateFromPath("src/domain/order/Order.ts")).toBe("order") + expect(detector.extractAggregateFromPath("src/domain/user/User.ts")).toBe("user") + expect(detector.extractAggregateFromPath("src/domain/cart/ShoppingCart.ts")).toBe( + "cart", + ) + }) + + it("should extract aggregate from domain/entities/name path", () => { + expect(detector.extractAggregateFromPath("src/domain/entities/order/Order.ts")).toBe( + "order", + ) + expect(detector.extractAggregateFromPath("src/domain/entities/user/User.ts")).toBe( + "user", + ) + }) + + it("should return undefined for non-domain paths", () => { + expect( + detector.extractAggregateFromPath("src/application/use-cases/CreateUser.ts"), + ).toBeUndefined() + expect( + detector.extractAggregateFromPath( + "src/infrastructure/repositories/UserRepository.ts", + ), + ).toBeUndefined() + expect(detector.extractAggregateFromPath("src/shared/types/Result.ts")).toBeUndefined() + }) + + it("should return undefined for paths without aggregate structure", () => { + expect(detector.extractAggregateFromPath("src/domain/User.ts")).toBeUndefined() + expect(detector.extractAggregateFromPath("src/User.ts")).toBeUndefined() + }) + + it("should handle Windows-style paths", () => { + expect( + detector.extractAggregateFromPath("src\\domain\\aggregates\\order\\Order.ts"), + ).toBe("order") + expect(detector.extractAggregateFromPath("src\\domain\\user\\User.ts")).toBe("user") + }) + }) + + describe("isAggregateBoundaryViolation", () => { + it("should detect direct entity import from another aggregate", () => { + expect(detector.isAggregateBoundaryViolation("../user/User", "order")).toBe(true) + expect(detector.isAggregateBoundaryViolation("../../user/User", "order")).toBe(true) + expect( + detector.isAggregateBoundaryViolation("../../../domain/user/User", "order"), + ).toBe(true) + }) + + it("should NOT detect import from same aggregate", () => { + expect(detector.isAggregateBoundaryViolation("../order/Order", "order")).toBe(false) + expect(detector.isAggregateBoundaryViolation("./OrderItem", "order")).toBe(false) + }) + + it("should NOT detect value object imports", () => { + expect( + detector.isAggregateBoundaryViolation("../user/value-objects/UserId", "order"), + ).toBe(false) + expect(detector.isAggregateBoundaryViolation("../user/vo/Email", "order")).toBe(false) + }) + + it("should NOT detect event imports", () => { + expect( + detector.isAggregateBoundaryViolation("../user/events/UserCreatedEvent", "order"), + ).toBe(false) + expect( + detector.isAggregateBoundaryViolation( + "../user/domain-events/UserRegisteredEvent", + "order", + ), + ).toBe(false) + }) + + it("should NOT detect repository interface imports", () => { + expect( + detector.isAggregateBoundaryViolation( + "../user/repositories/IUserRepository", + "order", + ), + ).toBe(false) + }) + + it("should NOT detect service imports", () => { + expect( + detector.isAggregateBoundaryViolation("../user/services/UserService", "order"), + ).toBe(false) + }) + + it("should NOT detect external package imports", () => { + expect(detector.isAggregateBoundaryViolation("express", "order")).toBe(false) + expect(detector.isAggregateBoundaryViolation("@nestjs/common", "order")).toBe(false) + }) + + it("should NOT detect imports without path separator", () => { + expect(detector.isAggregateBoundaryViolation("User", "order")).toBe(false) + }) + }) + + describe("detectViolations", () => { + describe("Domain layer aggregate boundary violations", () => { + it("should detect direct entity import from another aggregate", () => { + const code = ` +import { User } from '../user/User' + +export class Order { + constructor(private user: User) {} +}` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(1) + expect(violations[0].fromAggregate).toBe("order") + expect(violations[0].toAggregate).toBe("user") + expect(violations[0].entityName).toBe("User") + expect(violations[0].importPath).toBe("../user/User") + expect(violations[0].line).toBe(2) + }) + + it("should detect multiple entity imports from different aggregates", () => { + const code = ` +import { User } from '../user/User' +import { Product } from '../product/Product' +import { Category } from '../catalog/Category' + +export class Order { + constructor( + private user: User, + private product: Product, + private category: Category + ) {} +}` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(3) + expect(violations[0].entityName).toBe("User") + expect(violations[1].entityName).toBe("Product") + expect(violations[2].entityName).toBe("Category") + }) + + it("should NOT detect value object imports", () => { + const code = ` +import { UserId } from '../user/value-objects/UserId' +import { ProductId } from '../product/value-objects/ProductId' + +export class Order { + constructor( + private userId: UserId, + private productId: ProductId + ) {} +}` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(0) + }) + + it("should NOT detect event imports", () => { + const code = ` +import { UserCreatedEvent } from '../user/events/UserCreatedEvent' +import { ProductAddedEvent } from '../product/domain-events/ProductAddedEvent' + +export class Order { + handle(event: UserCreatedEvent): void {} +}` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(0) + }) + + it("should NOT detect repository interface imports", () => { + const code = ` +import { IUserRepository } from '../user/repositories/IUserRepository' + +export class OrderService { + constructor(private userRepo: IUserRepository) {} +}` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/OrderService.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(0) + }) + + it("should NOT detect imports from same aggregate", () => { + const code = ` +import { OrderItem } from './OrderItem' +import { OrderStatus } from './value-objects/OrderStatus' + +export class Order { + constructor( + private items: OrderItem[], + private status: OrderStatus + ) {} +}` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(0) + }) + }) + + describe("Non-domain layers", () => { + it("should return empty array for application layer", () => { + const code = ` +import { User } from '../../domain/aggregates/user/User' +import { Order } from '../../domain/aggregates/order/Order' + +export class CreateOrder { + constructor() {} +}` + const violations = detector.detectViolations( + code, + "src/application/use-cases/CreateOrder.ts", + LAYERS.APPLICATION, + ) + + expect(violations).toHaveLength(0) + }) + + it("should return empty array for infrastructure layer", () => { + const code = ` +import { User } from '../../domain/aggregates/user/User' + +export class UserController { + constructor() {} +}` + const violations = detector.detectViolations( + code, + "src/infrastructure/controllers/UserController.ts", + LAYERS.INFRASTRUCTURE, + ) + + expect(violations).toHaveLength(0) + }) + + it("should return empty array for undefined layer", () => { + const code = `import { User } from '../user/User'` + const violations = detector.detectViolations(code, "src/utils/helper.ts", undefined) + + expect(violations).toHaveLength(0) + }) + }) + + describe("Import statement formats", () => { + it("should detect violations in named imports", () => { + const code = `import { User, UserProfile } from '../user/User'` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(1) + }) + + it("should detect violations in default imports", () => { + const code = `import User from '../user/User'` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(1) + }) + + it("should detect violations in namespace imports", () => { + const code = `import * as UserAggregate from '../user/User'` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(1) + }) + + it("should detect violations in require statements", () => { + const code = `const User = require('../user/User')` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(1) + }) + }) + + describe("Different path structures", () => { + it("should detect violations in domain/aggregates/name structure", () => { + const code = `import { User } from '../user/User'` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(1) + expect(violations[0].fromAggregate).toBe("order") + expect(violations[0].toAggregate).toBe("user") + }) + + it("should detect violations in domain/name structure", () => { + const code = `import { User } from '../user/User'` + const violations = detector.detectViolations( + code, + "src/domain/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(1) + expect(violations[0].fromAggregate).toBe("order") + expect(violations[0].toAggregate).toBe("user") + }) + + it("should detect violations in domain/entities/name structure", () => { + const code = `import { User } from '../../user/User'` + const violations = detector.detectViolations( + code, + "src/domain/entities/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(1) + expect(violations[0].fromAggregate).toBe("order") + expect(violations[0].toAggregate).toBe("user") + }) + }) + + describe("Edge cases", () => { + it("should handle empty code", () => { + const violations = detector.detectViolations( + "", + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(0) + }) + + it("should handle code with no imports", () => { + const code = ` +export class Order { + constructor(private id: string) {} +}` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(0) + }) + + it("should handle file without aggregate in path", () => { + const code = `import { User } from '../user/User'` + const violations = detector.detectViolations( + code, + "src/domain/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(0) + }) + + it("should handle comments in imports", () => { + const code = ` +// This is a comment +import { User } from '../user/User' // Bad import +` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(1) + }) + }) + + describe("getMessage", () => { + it("should return correct violation message", () => { + const code = `import { User } from '../user/User'` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations[0].getMessage()).toBe( + "Order aggregate should not directly reference User entity from User aggregate", + ) + }) + + it("should capitalize aggregate names in message", () => { + const code = `import { Product } from '../product/Product'` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/cart/ShoppingCart.ts", + LAYERS.DOMAIN, + ) + + expect(violations[0].getMessage()).toContain("Cart aggregate") + expect(violations[0].getMessage()).toContain("Product aggregate") + }) + }) + + describe("getSuggestion", () => { + it("should return suggestions for fixing aggregate boundary violations", () => { + const code = `import { User } from '../user/User'` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + const suggestion = violations[0].getSuggestion() + expect(suggestion).toContain("Reference other aggregates by ID") + expect(suggestion).toContain("Use Value Objects") + expect(suggestion).toContain("Avoid direct entity references") + expect(suggestion).toContain("independently modifiable") + }) + }) + + describe("getExampleFix", () => { + it("should return example fix for aggregate boundary violation", () => { + const code = `import { User } from '../user/User'` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + const example = violations[0].getExampleFix() + expect(example).toContain("// āŒ Bad") + expect(example).toContain("// āœ… Good") + expect(example).toContain("UserId") + expect(example).toContain("CustomerInfo") + }) + }) + }) + + describe("Complex scenarios", () => { + it("should detect mixed valid and invalid imports", () => { + const code = ` +import { User } from '../user/User' // VIOLATION +import { UserId } from '../user/value-objects/UserId' // OK +import { Product } from '../product/Product' // VIOLATION +import { ProductId } from '../product/value-objects/ProductId' // OK +import { OrderItem } from './OrderItem' // OK - same aggregate + +export class Order { + constructor( + private user: User, + private userId: UserId, + private product: Product, + private productId: ProductId, + private items: OrderItem[] + ) {} +}` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(2) + expect(violations[0].entityName).toBe("User") + expect(violations[1].entityName).toBe("Product") + }) + + it("should handle deeply nested import paths", () => { + const code = `import { User } from '../../../domain/aggregates/user/entities/User'` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(1) + expect(violations[0].entityName).toBe("User") + }) + + it("should detect violations with .ts extension in import", () => { + const code = `import { User } from '../user/User.ts'` + const violations = detector.detectViolations( + code, + "src/domain/aggregates/order/Order.ts", + LAYERS.DOMAIN, + ) + + expect(violations).toHaveLength(1) + expect(violations[0].entityName).toBe("User") + }) + }) +})