mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
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
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AggregateBoundaryViolation } from "../value-objects/AggregateBoundaryViolation"
|
||||
|
||||
/**
|
||||
* Interface for detecting aggregate boundary violations in DDD
|
||||
*
|
||||
* Aggregate boundary violations occur when an entity from one aggregate
|
||||
* directly references an entity from another aggregate. In DDD, aggregates
|
||||
* should reference each other only by ID or Value Objects to maintain
|
||||
* loose coupling and independence.
|
||||
*/
|
||||
export interface IAggregateBoundaryDetector {
|
||||
/**
|
||||
* Detects aggregate boundary violations in the given code
|
||||
*
|
||||
* Analyzes import statements to identify direct entity references
|
||||
* across aggregate boundaries.
|
||||
*
|
||||
* @param code - Source code to analyze
|
||||
* @param filePath - Path to the file being analyzed
|
||||
* @param layer - The architectural layer of the file (should be 'domain')
|
||||
* @returns Array of detected aggregate boundary violations
|
||||
*/
|
||||
detectViolations(
|
||||
code: string,
|
||||
filePath: string,
|
||||
layer: string | undefined,
|
||||
): AggregateBoundaryViolation[]
|
||||
|
||||
/**
|
||||
* Checks if a file path belongs to an aggregate
|
||||
*
|
||||
* @param filePath - The file path to check
|
||||
* @returns The aggregate name if found, undefined otherwise
|
||||
*/
|
||||
extractAggregateFromPath(filePath: string): string | undefined
|
||||
|
||||
/**
|
||||
* Checks if an import path references an entity from another aggregate
|
||||
*
|
||||
* @param importPath - The import path to analyze
|
||||
* @param currentAggregate - The aggregate of the current file
|
||||
* @returns True if the import crosses aggregate boundaries inappropriately
|
||||
*/
|
||||
isAggregateBoundaryViolation(importPath: string, currentAggregate: string): boolean
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ValueObject } from "./ValueObject"
|
||||
import { AGGREGATE_VIOLATION_MESSAGES } from "../constants/Messages"
|
||||
|
||||
interface AggregateBoundaryViolationProps {
|
||||
readonly fromAggregate: string
|
||||
readonly toAggregate: string
|
||||
readonly entityName: string
|
||||
readonly importPath: string
|
||||
readonly filePath: string
|
||||
readonly line?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an aggregate boundary violation in the codebase
|
||||
*
|
||||
* Aggregate boundary violations occur when an entity from one aggregate
|
||||
* directly references an entity from another aggregate, violating DDD principles:
|
||||
* - Aggregates should reference each other only by ID or Value Objects
|
||||
* - Direct entity references create tight coupling between aggregates
|
||||
* - Changes to one aggregate should not require changes to another
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Bad: Direct entity reference across aggregates
|
||||
* const violation = AggregateBoundaryViolation.create(
|
||||
* 'order',
|
||||
* 'user',
|
||||
* 'User',
|
||||
* '../user/User',
|
||||
* 'src/domain/aggregates/order/Order.ts',
|
||||
* 5
|
||||
* )
|
||||
*
|
||||
* console.log(violation.getMessage())
|
||||
* // "Order aggregate should not directly reference User entity from User aggregate"
|
||||
* ```
|
||||
*/
|
||||
export class AggregateBoundaryViolation extends ValueObject<AggregateBoundaryViolationProps> {
|
||||
private constructor(props: AggregateBoundaryViolationProps) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
public static create(
|
||||
fromAggregate: string,
|
||||
toAggregate: string,
|
||||
entityName: string,
|
||||
importPath: string,
|
||||
filePath: string,
|
||||
line?: number,
|
||||
): AggregateBoundaryViolation {
|
||||
return new AggregateBoundaryViolation({
|
||||
fromAggregate,
|
||||
toAggregate,
|
||||
entityName,
|
||||
importPath,
|
||||
filePath,
|
||||
line,
|
||||
})
|
||||
}
|
||||
|
||||
public get fromAggregate(): string {
|
||||
return this.props.fromAggregate
|
||||
}
|
||||
|
||||
public get toAggregate(): string {
|
||||
return this.props.toAggregate
|
||||
}
|
||||
|
||||
public get entityName(): string {
|
||||
return this.props.entityName
|
||||
}
|
||||
|
||||
public get importPath(): string {
|
||||
return this.props.importPath
|
||||
}
|
||||
|
||||
public get filePath(): string {
|
||||
return this.props.filePath
|
||||
}
|
||||
|
||||
public get line(): number | undefined {
|
||||
return this.props.line
|
||||
}
|
||||
|
||||
public getMessage(): string {
|
||||
return `${this.capitalizeFirst(this.props.fromAggregate)} aggregate should not directly reference ${this.props.entityName} entity from ${this.capitalizeFirst(this.props.toAggregate)} aggregate`
|
||||
}
|
||||
|
||||
public getSuggestion(): string {
|
||||
const suggestions: string[] = [
|
||||
AGGREGATE_VIOLATION_MESSAGES.USE_ID_REFERENCE,
|
||||
AGGREGATE_VIOLATION_MESSAGES.USE_VALUE_OBJECT,
|
||||
AGGREGATE_VIOLATION_MESSAGES.AVOID_DIRECT_REFERENCE,
|
||||
AGGREGATE_VIOLATION_MESSAGES.MAINTAIN_INDEPENDENCE,
|
||||
]
|
||||
|
||||
return suggestions.join("\n")
|
||||
}
|
||||
|
||||
public getExampleFix(): string {
|
||||
return `
|
||||
// ❌ Bad: Direct entity reference across aggregates
|
||||
// domain/aggregates/order/Order.ts
|
||||
import { User } from '../user/User'
|
||||
|
||||
class Order {
|
||||
constructor(private user: User) {}
|
||||
}
|
||||
|
||||
// ✅ Good: Reference by ID
|
||||
// domain/aggregates/order/Order.ts
|
||||
import { UserId } from '../user/value-objects/UserId'
|
||||
|
||||
class Order {
|
||||
constructor(private userId: UserId) {}
|
||||
}
|
||||
|
||||
// ✅ Good: Use Value Object for needed data
|
||||
// domain/aggregates/order/value-objects/CustomerInfo.ts
|
||||
class CustomerInfo {
|
||||
constructor(
|
||||
readonly customerId: string,
|
||||
readonly customerName: string,
|
||||
readonly customerEmail: string
|
||||
) {}
|
||||
}
|
||||
|
||||
// domain/aggregates/order/Order.ts
|
||||
class Order {
|
||||
constructor(private customerInfo: CustomerInfo) {}
|
||||
}`
|
||||
}
|
||||
|
||||
private capitalizeFirst(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,7 @@ export const SEVERITY_ORDER: Record<SeverityLevel, number> = {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user