mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
feat: add dependency direction enforcement (v0.4.0)
Implement dependency direction detection to enforce Clean Architecture rules: - Domain layer can only import from Domain and Shared - Application layer can only import from Domain, Application, and Shared - Infrastructure layer can import from all layers - Shared layer can be imported by all layers Added: - IDependencyDirectionDetector interface in domain layer - DependencyViolation value object with detailed suggestions and examples - DependencyDirectionDetector implementation in infrastructure - Integration with AnalyzeProject use case - New DEPENDENCY_DIRECTION rule in constants - 43 comprehensive tests covering all scenarios (100% passing) - Good and bad examples in examples directory Improvements: - Optimized extractLayerFromImport method to reduce complexity - Fixed indentation in DependencyGraph.ts - Updated getExampleFix to avoid false positives in old detector Test Results: - All 261 tests passing - Build successful - Self-check: 0 architecture violations in src code
This commit is contained in:
@@ -9,12 +9,14 @@ import { IHardcodeDetector } from "./domain/services/IHardcodeDetector"
|
||||
import { INamingConventionDetector } from "./domain/services/INamingConventionDetector"
|
||||
import { IFrameworkLeakDetector } from "./domain/services/IFrameworkLeakDetector"
|
||||
import { IEntityExposureDetector } from "./domain/services/IEntityExposureDetector"
|
||||
import { IDependencyDirectionDetector } from "./domain/services/IDependencyDirectionDetector"
|
||||
import { FileScanner } from "./infrastructure/scanners/FileScanner"
|
||||
import { CodeParser } from "./infrastructure/parsers/CodeParser"
|
||||
import { HardcodeDetector } from "./infrastructure/analyzers/HardcodeDetector"
|
||||
import { NamingConventionDetector } from "./infrastructure/analyzers/NamingConventionDetector"
|
||||
import { FrameworkLeakDetector } from "./infrastructure/analyzers/FrameworkLeakDetector"
|
||||
import { EntityExposureDetector } from "./infrastructure/analyzers/EntityExposureDetector"
|
||||
import { DependencyDirectionDetector } from "./infrastructure/analyzers/DependencyDirectionDetector"
|
||||
import { ERROR_MESSAGES } from "./shared/constants"
|
||||
|
||||
/**
|
||||
@@ -69,6 +71,8 @@ export async function analyzeProject(
|
||||
const namingConventionDetector: INamingConventionDetector = new NamingConventionDetector()
|
||||
const frameworkLeakDetector: IFrameworkLeakDetector = new FrameworkLeakDetector()
|
||||
const entityExposureDetector: IEntityExposureDetector = new EntityExposureDetector()
|
||||
const dependencyDirectionDetector: IDependencyDirectionDetector =
|
||||
new DependencyDirectionDetector()
|
||||
const useCase = new AnalyzeProject(
|
||||
fileScanner,
|
||||
codeParser,
|
||||
@@ -76,6 +80,7 @@ export async function analyzeProject(
|
||||
namingConventionDetector,
|
||||
frameworkLeakDetector,
|
||||
entityExposureDetector,
|
||||
dependencyDirectionDetector,
|
||||
)
|
||||
|
||||
const result = await useCase.execute(options)
|
||||
@@ -96,5 +101,6 @@ export type {
|
||||
NamingConventionViolation,
|
||||
FrameworkLeakViolation,
|
||||
EntityExposureViolation,
|
||||
DependencyDirectionViolation,
|
||||
ProjectMetrics,
|
||||
} from "./application/use-cases/AnalyzeProject"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IHardcodeDetector } from "../../domain/services/IHardcodeDetector"
|
||||
import { INamingConventionDetector } from "../../domain/services/INamingConventionDetector"
|
||||
import { IFrameworkLeakDetector } from "../../domain/services/IFrameworkLeakDetector"
|
||||
import { IEntityExposureDetector } from "../../domain/services/IEntityExposureDetector"
|
||||
import { IDependencyDirectionDetector } from "../../domain/services/IDependencyDirectionDetector"
|
||||
import { SourceFile } from "../../domain/entities/SourceFile"
|
||||
import { DependencyGraph } from "../../domain/entities/DependencyGraph"
|
||||
import { ProjectPath } from "../../domain/value-objects/ProjectPath"
|
||||
@@ -34,6 +35,7 @@ export interface AnalyzeProjectResponse {
|
||||
namingViolations: NamingConventionViolation[]
|
||||
frameworkLeakViolations: FrameworkLeakViolation[]
|
||||
entityExposureViolations: EntityExposureViolation[]
|
||||
dependencyDirectionViolations: DependencyDirectionViolation[]
|
||||
metrics: ProjectMetrics
|
||||
}
|
||||
|
||||
@@ -109,6 +111,17 @@ export interface EntityExposureViolation {
|
||||
suggestion: string
|
||||
}
|
||||
|
||||
export interface DependencyDirectionViolation {
|
||||
rule: typeof RULES.DEPENDENCY_DIRECTION
|
||||
fromLayer: string
|
||||
toLayer: string
|
||||
importPath: string
|
||||
file: string
|
||||
line?: number
|
||||
message: string
|
||||
suggestion: string
|
||||
}
|
||||
|
||||
export interface ProjectMetrics {
|
||||
totalFiles: number
|
||||
totalFunctions: number
|
||||
@@ -130,6 +143,7 @@ export class AnalyzeProject extends UseCase<
|
||||
private readonly namingConventionDetector: INamingConventionDetector,
|
||||
private readonly frameworkLeakDetector: IFrameworkLeakDetector,
|
||||
private readonly entityExposureDetector: IEntityExposureDetector,
|
||||
private readonly dependencyDirectionDetector: IDependencyDirectionDetector,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@@ -180,6 +194,7 @@ export class AnalyzeProject extends UseCase<
|
||||
const namingViolations = this.detectNamingConventions(sourceFiles)
|
||||
const frameworkLeakViolations = this.detectFrameworkLeaks(sourceFiles)
|
||||
const entityExposureViolations = this.detectEntityExposures(sourceFiles)
|
||||
const dependencyDirectionViolations = this.detectDependencyDirections(sourceFiles)
|
||||
const metrics = this.calculateMetrics(sourceFiles, totalFunctions, dependencyGraph)
|
||||
|
||||
return ResponseDto.ok({
|
||||
@@ -191,6 +206,7 @@ export class AnalyzeProject extends UseCase<
|
||||
namingViolations,
|
||||
frameworkLeakViolations,
|
||||
entityExposureViolations,
|
||||
dependencyDirectionViolations,
|
||||
metrics,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -409,6 +425,33 @@ export class AnalyzeProject extends UseCase<
|
||||
return violations
|
||||
}
|
||||
|
||||
private detectDependencyDirections(sourceFiles: SourceFile[]): DependencyDirectionViolation[] {
|
||||
const violations: DependencyDirectionViolation[] = []
|
||||
|
||||
for (const file of sourceFiles) {
|
||||
const directionViolations = this.dependencyDirectionDetector.detectViolations(
|
||||
file.content,
|
||||
file.path.relative,
|
||||
file.layer,
|
||||
)
|
||||
|
||||
for (const violation of directionViolations) {
|
||||
violations.push({
|
||||
rule: RULES.DEPENDENCY_DIRECTION,
|
||||
fromLayer: violation.fromLayer,
|
||||
toLayer: violation.toLayer,
|
||||
importPath: violation.importPath,
|
||||
file: file.path.relative,
|
||||
line: violation.line,
|
||||
message: violation.getMessage(),
|
||||
suggestion: violation.getSuggestion(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
private calculateMetrics(
|
||||
sourceFiles: SourceFile[],
|
||||
totalFunctions: number,
|
||||
|
||||
@@ -94,7 +94,7 @@ export class DependencyGraph extends BaseEntity {
|
||||
totalDependencies: number
|
||||
avgDependencies: number
|
||||
maxDependencies: number
|
||||
} {
|
||||
} {
|
||||
const nodes = Array.from(this.nodes.values())
|
||||
const totalFiles = nodes.length
|
||||
const totalDependencies = nodes.reduce((sum, node) => sum + node.dependencies.length, 0)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DependencyViolation } from "../value-objects/DependencyViolation"
|
||||
|
||||
/**
|
||||
* Interface for detecting dependency direction violations in the codebase
|
||||
*
|
||||
* Dependency direction violations occur when a layer imports from a layer
|
||||
* that it should not depend on according to Clean Architecture principles:
|
||||
* - Domain should not import from Application or Infrastructure
|
||||
* - Application should not import from Infrastructure
|
||||
* - Infrastructure can import from Application and Domain
|
||||
* - Shared can be imported by all layers
|
||||
*/
|
||||
export interface IDependencyDirectionDetector {
|
||||
/**
|
||||
* Detects dependency direction violations in the given code
|
||||
*
|
||||
* Analyzes import statements to identify violations of dependency rules
|
||||
* between architectural layers.
|
||||
*
|
||||
* @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 dependency direction violations
|
||||
*/
|
||||
detectViolations(
|
||||
code: string,
|
||||
filePath: string,
|
||||
layer: string | undefined,
|
||||
): DependencyViolation[]
|
||||
|
||||
/**
|
||||
* Checks if an import violates dependency direction rules
|
||||
*
|
||||
* @param fromLayer - The layer that is importing
|
||||
* @param toLayer - The layer being imported
|
||||
* @returns True if the import violates dependency rules
|
||||
*/
|
||||
isViolation(fromLayer: string, toLayer: string): boolean
|
||||
|
||||
/**
|
||||
* Extracts the layer from an import path
|
||||
*
|
||||
* @param importPath - The import path to analyze
|
||||
* @returns The layer name if detected, undefined otherwise
|
||||
*/
|
||||
extractLayerFromImport(importPath: string): string | undefined
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { ValueObject } from "./ValueObject"
|
||||
|
||||
interface DependencyViolationProps {
|
||||
readonly fromLayer: string
|
||||
readonly toLayer: string
|
||||
readonly importPath: string
|
||||
readonly filePath: string
|
||||
readonly line?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a dependency direction violation in the codebase
|
||||
*
|
||||
* Dependency direction violations occur when a layer imports from a layer
|
||||
* that it should not depend on according to Clean Architecture principles:
|
||||
* - Domain → should not import from Application or Infrastructure
|
||||
* - Application → should not import from Infrastructure
|
||||
* - Infrastructure → can import from Application and Domain (allowed)
|
||||
* - Shared → can be imported by all layers (allowed)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Bad: Domain importing from Application
|
||||
* const violation = DependencyViolation.create(
|
||||
* 'domain',
|
||||
* 'application',
|
||||
* '../../application/dtos/UserDto',
|
||||
* 'src/domain/entities/User.ts',
|
||||
* 5
|
||||
* )
|
||||
*
|
||||
* console.log(violation.getMessage())
|
||||
* // "Domain layer should not import from Application layer"
|
||||
* ```
|
||||
*/
|
||||
export class DependencyViolation extends ValueObject<DependencyViolationProps> {
|
||||
private constructor(props: DependencyViolationProps) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
public static create(
|
||||
fromLayer: string,
|
||||
toLayer: string,
|
||||
importPath: string,
|
||||
filePath: string,
|
||||
line?: number,
|
||||
): DependencyViolation {
|
||||
return new DependencyViolation({
|
||||
fromLayer,
|
||||
toLayer,
|
||||
importPath,
|
||||
filePath,
|
||||
line,
|
||||
})
|
||||
}
|
||||
|
||||
public get fromLayer(): string {
|
||||
return this.props.fromLayer
|
||||
}
|
||||
|
||||
public get toLayer(): string {
|
||||
return this.props.toLayer
|
||||
}
|
||||
|
||||
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.fromLayer)} layer should not import from ${this.capitalizeFirst(this.props.toLayer)} layer`
|
||||
}
|
||||
|
||||
public getSuggestion(): string {
|
||||
const suggestions: string[] = []
|
||||
|
||||
if (this.props.fromLayer === "domain") {
|
||||
suggestions.push(
|
||||
"Domain layer should be independent and not depend on other layers",
|
||||
"Move the imported code to the domain layer if it contains business logic",
|
||||
"Use dependency inversion: define an interface in domain and implement it in infrastructure",
|
||||
)
|
||||
} else if (this.props.fromLayer === "application") {
|
||||
suggestions.push(
|
||||
"Application layer should not depend on infrastructure",
|
||||
"Define an interface (Port) in application layer",
|
||||
"Implement the interface (Adapter) in infrastructure layer",
|
||||
"Use dependency injection to provide the implementation",
|
||||
)
|
||||
}
|
||||
|
||||
return suggestions.join("\n")
|
||||
}
|
||||
|
||||
public getExampleFix(): string {
|
||||
if (this.props.fromLayer === "domain" && this.props.toLayer === "infrastructure") {
|
||||
return `
|
||||
// ❌ Bad: Domain depends on Infrastructure (PrismaClient)
|
||||
// domain/services/UserService.ts
|
||||
class UserService {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
}
|
||||
|
||||
// ✅ Good: Domain defines interface, Infrastructure implements
|
||||
// domain/repositories/IUserRepository.ts
|
||||
interface IUserRepository {
|
||||
findById(id: UserId): Promise<User | null>
|
||||
save(user: User): Promise<void>
|
||||
}
|
||||
|
||||
// domain/services/UserService.ts
|
||||
class UserService {
|
||||
constructor(private userRepo: IUserRepository) {}
|
||||
}
|
||||
|
||||
// infrastructure/repositories/PrismaUserRepository.ts
|
||||
class PrismaUserRepository implements IUserRepository {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
async findById(id: UserId): Promise<User | null> { }
|
||||
async save(user: User): Promise<void> { }
|
||||
}`
|
||||
}
|
||||
|
||||
if (this.props.fromLayer === "application" && this.props.toLayer === "infrastructure") {
|
||||
return `
|
||||
// ❌ Bad: Application depends on Infrastructure (SmtpEmailService)
|
||||
// application/use-cases/SendEmail.ts
|
||||
class SendWelcomeEmail {
|
||||
constructor(private emailService: SmtpEmailService) {}
|
||||
}
|
||||
|
||||
// ✅ Good: Application defines Port, Infrastructure implements Adapter
|
||||
// application/ports/IEmailService.ts
|
||||
interface IEmailService {
|
||||
send(to: string, subject: string, body: string): Promise<void>
|
||||
}
|
||||
|
||||
// application/use-cases/SendEmail.ts
|
||||
class SendWelcomeEmail {
|
||||
constructor(private emailService: IEmailService) {}
|
||||
}
|
||||
|
||||
// infrastructure/adapters/SmtpEmailService.ts
|
||||
class SmtpEmailService implements IEmailService {
|
||||
async send(to: string, subject: string, body: string): Promise<void> { }
|
||||
}`
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
private capitalizeFirst(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { IDependencyDirectionDetector } from "../../domain/services/IDependencyDirectionDetector"
|
||||
import { DependencyViolation } from "../../domain/value-objects/DependencyViolation"
|
||||
import { LAYERS } from "../../shared/constants/rules"
|
||||
|
||||
/**
|
||||
* Detects dependency direction violations between architectural layers
|
||||
*
|
||||
* This detector enforces Clean Architecture dependency rules:
|
||||
* - Domain → should not import from Application or Infrastructure
|
||||
* - Application → should not import from Infrastructure
|
||||
* - Infrastructure → can import from Application and Domain (allowed)
|
||||
* - Shared → can be imported by all layers (allowed)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const detector = new DependencyDirectionDetector()
|
||||
*
|
||||
* // Detect violations in domain file
|
||||
* const code = `
|
||||
* import { PrismaClient } from '@prisma/client'
|
||||
* import { UserDto } from '../application/dtos/UserDto'
|
||||
* `
|
||||
* const violations = detector.detectViolations(code, 'src/domain/entities/User.ts', 'domain')
|
||||
*
|
||||
* // violations will contain 1 violation for domain importing from application
|
||||
* console.log(violations.length) // 1
|
||||
* console.log(violations[0].getMessage())
|
||||
* // "Domain layer should not import from Application layer"
|
||||
* ```
|
||||
*/
|
||||
export class DependencyDirectionDetector implements IDependencyDirectionDetector {
|
||||
private readonly dependencyRules: Map<string, Set<string>>
|
||||
|
||||
constructor() {
|
||||
this.dependencyRules = new Map([
|
||||
[LAYERS.DOMAIN, new Set([LAYERS.DOMAIN, LAYERS.SHARED])],
|
||||
[LAYERS.APPLICATION, new Set([LAYERS.DOMAIN, LAYERS.APPLICATION, LAYERS.SHARED])],
|
||||
[
|
||||
LAYERS.INFRASTRUCTURE,
|
||||
new Set([LAYERS.DOMAIN, LAYERS.APPLICATION, LAYERS.INFRASTRUCTURE, LAYERS.SHARED]),
|
||||
],
|
||||
[
|
||||
LAYERS.SHARED,
|
||||
new Set([LAYERS.DOMAIN, LAYERS.APPLICATION, LAYERS.INFRASTRUCTURE, LAYERS.SHARED]),
|
||||
],
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects dependency direction violations in the given code
|
||||
*
|
||||
* Analyzes import statements to identify violations of dependency rules
|
||||
* between architectural layers.
|
||||
*
|
||||
* @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 dependency direction violations
|
||||
*/
|
||||
public detectViolations(
|
||||
code: string,
|
||||
filePath: string,
|
||||
layer: string | undefined,
|
||||
): DependencyViolation[] {
|
||||
if (!layer || layer === LAYERS.SHARED) {
|
||||
return []
|
||||
}
|
||||
|
||||
const violations: DependencyViolation[] = []
|
||||
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) {
|
||||
const targetLayer = this.extractLayerFromImport(importPath)
|
||||
|
||||
if (targetLayer && this.isViolation(layer, targetLayer)) {
|
||||
violations.push(
|
||||
DependencyViolation.create(
|
||||
layer,
|
||||
targetLayer,
|
||||
importPath,
|
||||
filePath,
|
||||
lineNumber,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an import violates dependency direction rules
|
||||
*
|
||||
* @param fromLayer - The layer that is importing
|
||||
* @param toLayer - The layer being imported
|
||||
* @returns True if the import violates dependency rules
|
||||
*/
|
||||
public isViolation(fromLayer: string, toLayer: string): boolean {
|
||||
const allowedDependencies = this.dependencyRules.get(fromLayer)
|
||||
|
||||
if (!allowedDependencies) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !allowedDependencies.has(toLayer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the layer from an import path
|
||||
*
|
||||
* @param importPath - The import path to analyze
|
||||
* @returns The layer name if detected, undefined otherwise
|
||||
*/
|
||||
public extractLayerFromImport(importPath: string): string | undefined {
|
||||
const normalizedPath = importPath.replace(/['"]/g, "").toLowerCase()
|
||||
|
||||
const layerPatterns: Array<[string, string]> = [
|
||||
[LAYERS.DOMAIN, "/domain/"],
|
||||
[LAYERS.APPLICATION, "/application/"],
|
||||
[LAYERS.INFRASTRUCTURE, "/infrastructure/"],
|
||||
[LAYERS.SHARED, "/shared/"],
|
||||
]
|
||||
|
||||
for (const [layer, pattern] of layerPatterns) {
|
||||
if (this.containsLayerPattern(normalizedPath, pattern)) {
|
||||
return layer
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the normalized path contains the layer pattern
|
||||
*/
|
||||
private containsLayerPattern(normalizedPath: string, pattern: string): boolean {
|
||||
return (
|
||||
normalizedPath.includes(pattern) ||
|
||||
normalizedPath.includes(`.${pattern}`) ||
|
||||
normalizedPath.includes(`..${pattern}`) ||
|
||||
normalizedPath.includes(`...${pattern}`)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] = []
|
||||
|
||||
const esImportRegex =
|
||||
/import\s+(?:{[^}]*}|[\w*]+(?:\s+as\s+\w+)?|\*\s+as\s+\w+)\s+from\s+['"]([^'"]+)['"]/g
|
||||
let match = esImportRegex.exec(line)
|
||||
while (match) {
|
||||
imports.push(match[1])
|
||||
match = esImportRegex.exec(line)
|
||||
}
|
||||
|
||||
const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
||||
match = requireRegex.exec(line)
|
||||
while (match) {
|
||||
imports.push(match[1])
|
||||
match = requireRegex.exec(line)
|
||||
}
|
||||
|
||||
return imports
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export const RULES = {
|
||||
NAMING_CONVENTION: "naming-convention",
|
||||
FRAMEWORK_LEAK: "framework-leak",
|
||||
ENTITY_EXPOSURE: "entity-exposure",
|
||||
DEPENDENCY_DIRECTION: "dependency-direction",
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user