mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat: add framework leak detection for domain layer
- Add IFrameworkLeakDetector interface in domain/services - Add FrameworkLeak value object with framework type categorization - Implement FrameworkLeakDetector with 250+ framework patterns across 12 categories - Add comprehensive test suite (35 tests) for framework leak detection - Support HTTP frameworks, ORMs, loggers, caches, message queues, etc. - Detect framework imports in domain layer and suggest proper abstractions
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { FrameworkLeak } from "../value-objects/FrameworkLeak"
|
||||
|
||||
/**
|
||||
* Interface for detecting framework-specific imports in domain layer
|
||||
*
|
||||
* Framework leaks occur when domain layer imports framework-specific packages,
|
||||
* violating Clean Architecture principles by creating tight coupling to external frameworks.
|
||||
*/
|
||||
export interface IFrameworkLeakDetector {
|
||||
/**
|
||||
* Detects framework leaks in the given file
|
||||
*
|
||||
* @param imports - Array of import paths from the file
|
||||
* @param filePath - Path to the file being analyzed
|
||||
* @param layer - The architectural layer of the file (domain, application, infrastructure, shared)
|
||||
* @returns Array of detected framework leaks
|
||||
*/
|
||||
detectLeaks(imports: string[], filePath: string, layer: string | undefined): FrameworkLeak[]
|
||||
|
||||
/**
|
||||
* Checks if a specific import is a framework package
|
||||
*
|
||||
* @param importPath - The import path to check
|
||||
* @returns True if the import is a framework package
|
||||
*/
|
||||
isFrameworkPackage(importPath: string): boolean
|
||||
}
|
||||
112
packages/guardian/src/domain/value-objects/FrameworkLeak.ts
Normal file
112
packages/guardian/src/domain/value-objects/FrameworkLeak.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { ValueObject } from "./ValueObject"
|
||||
import { FRAMEWORK_LEAK_MESSAGES } from "../../shared/constants/rules"
|
||||
|
||||
interface FrameworkLeakProps {
|
||||
readonly packageName: string
|
||||
readonly filePath: string
|
||||
readonly layer: string
|
||||
readonly category: string
|
||||
readonly line?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a framework leak violation in the codebase
|
||||
*
|
||||
* A framework leak occurs when a domain layer file imports a framework-specific package,
|
||||
* creating tight coupling and violating Clean Architecture principles.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Bad: Domain layer importing Prisma
|
||||
* const leak = FrameworkLeak.create(
|
||||
* '@prisma/client',
|
||||
* 'src/domain/User.ts',
|
||||
* 'domain',
|
||||
* 'ORM',
|
||||
* 5
|
||||
* )
|
||||
*
|
||||
* console.log(leak.getMessage())
|
||||
* // "Domain layer imports framework-specific package "@prisma/client". Use interfaces and dependency injection instead."
|
||||
* ```
|
||||
*/
|
||||
export class FrameworkLeak extends ValueObject<FrameworkLeakProps> {
|
||||
private constructor(props: FrameworkLeakProps) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
public static create(
|
||||
packageName: string,
|
||||
filePath: string,
|
||||
layer: string,
|
||||
category: string,
|
||||
line?: number,
|
||||
): FrameworkLeak {
|
||||
return new FrameworkLeak({
|
||||
packageName,
|
||||
filePath,
|
||||
layer,
|
||||
category,
|
||||
line,
|
||||
})
|
||||
}
|
||||
|
||||
public get packageName(): string {
|
||||
return this.props.packageName
|
||||
}
|
||||
|
||||
public get filePath(): string {
|
||||
return this.props.filePath
|
||||
}
|
||||
|
||||
public get layer(): string {
|
||||
return this.props.layer
|
||||
}
|
||||
|
||||
public get category(): string {
|
||||
return this.props.category
|
||||
}
|
||||
|
||||
public get line(): number | undefined {
|
||||
return this.props.line
|
||||
}
|
||||
|
||||
public getMessage(): string {
|
||||
return FRAMEWORK_LEAK_MESSAGES.DOMAIN_IMPORT.replace("{package}", this.props.packageName)
|
||||
}
|
||||
|
||||
public getSuggestion(): string {
|
||||
return FRAMEWORK_LEAK_MESSAGES.SUGGESTION
|
||||
}
|
||||
|
||||
public getCategoryDescription(): string {
|
||||
switch (this.props.category) {
|
||||
case "ORM":
|
||||
return "Database ORM/ODM"
|
||||
case "WEB_FRAMEWORK":
|
||||
return "Web Framework"
|
||||
case "HTTP_CLIENT":
|
||||
return "HTTP Client"
|
||||
case "VALIDATION":
|
||||
return "Validation Library"
|
||||
case "DI_CONTAINER":
|
||||
return "DI Container"
|
||||
case "LOGGER":
|
||||
return "Logger"
|
||||
case "CACHE":
|
||||
return "Cache"
|
||||
case "MESSAGE_QUEUE":
|
||||
return "Message Queue"
|
||||
case "EMAIL":
|
||||
return "Email Service"
|
||||
case "STORAGE":
|
||||
return "Storage Service"
|
||||
case "TESTING":
|
||||
return "Testing Framework"
|
||||
case "TEMPLATE_ENGINE":
|
||||
return "Template Engine"
|
||||
default:
|
||||
return "Framework Package"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { IFrameworkLeakDetector } from "../../domain/services/IFrameworkLeakDetector"
|
||||
import { FrameworkLeak } from "../../domain/value-objects/FrameworkLeak"
|
||||
import { FRAMEWORK_PACKAGES, LAYERS } from "../../shared/constants/rules"
|
||||
|
||||
/**
|
||||
* Detects framework-specific imports in domain layer
|
||||
*
|
||||
* This detector identifies violations where domain layer files import framework-specific packages,
|
||||
* which creates tight coupling and violates Clean Architecture principles.
|
||||
*
|
||||
* The domain layer should only contain business logic and domain interfaces.
|
||||
* Framework implementations should be in the infrastructure layer.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const detector = new FrameworkLeakDetector()
|
||||
*
|
||||
* // Detect leaks in a domain file
|
||||
* const imports = ['@prisma/client', 'express', '../entities/User']
|
||||
* const leaks = detector.detectLeaks(imports, 'src/domain/User.ts', 'domain')
|
||||
*
|
||||
* // leaks will contain violations for '@prisma/client' and 'express'
|
||||
* console.log(leaks.length) // 2
|
||||
* console.log(leaks[0].packageName) // '@prisma/client'
|
||||
* console.log(leaks[0].category) // 'ORM'
|
||||
* ```
|
||||
*/
|
||||
export class FrameworkLeakDetector implements IFrameworkLeakDetector {
|
||||
private readonly frameworkPackages: Map<string, string>
|
||||
|
||||
constructor() {
|
||||
this.frameworkPackages = this.buildFrameworkPackageMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects framework leaks in the given file
|
||||
*
|
||||
* @param imports - Array of import paths from the file
|
||||
* @param filePath - Path to the file being analyzed
|
||||
* @param layer - The architectural layer of the file (domain, application, infrastructure, shared)
|
||||
* @returns Array of detected framework leaks
|
||||
*/
|
||||
public detectLeaks(
|
||||
imports: string[],
|
||||
filePath: string,
|
||||
layer: string | undefined,
|
||||
): FrameworkLeak[] {
|
||||
if (layer !== LAYERS.DOMAIN) {
|
||||
return []
|
||||
}
|
||||
|
||||
const leaks: FrameworkLeak[] = []
|
||||
|
||||
for (const importPath of imports) {
|
||||
const category = this.getFrameworkCategory(importPath)
|
||||
if (category) {
|
||||
leaks.push(FrameworkLeak.create(importPath, filePath, layer, category))
|
||||
}
|
||||
}
|
||||
|
||||
return leaks
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a specific import is a framework package
|
||||
*
|
||||
* @param importPath - The import path to check
|
||||
* @returns True if the import is a framework package
|
||||
*/
|
||||
public isFrameworkPackage(importPath: string): boolean {
|
||||
return this.frameworkPackages.has(importPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the category of a framework package
|
||||
*
|
||||
* @param importPath - The import path to check
|
||||
* @returns The category name if it's a framework package, undefined otherwise
|
||||
*/
|
||||
private getFrameworkCategory(importPath: string): string | undefined {
|
||||
if (importPath.startsWith(".") || importPath.startsWith("/")) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return this.frameworkPackages.get(importPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a map of framework packages to their categories
|
||||
*
|
||||
* @returns Map of package names to category names
|
||||
*/
|
||||
private buildFrameworkPackageMap(): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
|
||||
for (const [category, packages] of Object.entries(FRAMEWORK_PACKAGES)) {
|
||||
for (const pkg of packages) {
|
||||
map.set(pkg, category)
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
}
|
||||
299
packages/guardian/tests/FrameworkLeakDetector.test.ts
Normal file
299
packages/guardian/tests/FrameworkLeakDetector.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest"
|
||||
import { FrameworkLeakDetector } from "../src/infrastructure/analyzers/FrameworkLeakDetector"
|
||||
|
||||
describe("FrameworkLeakDetector", () => {
|
||||
let detector: FrameworkLeakDetector
|
||||
|
||||
beforeEach(() => {
|
||||
detector = new FrameworkLeakDetector()
|
||||
})
|
||||
|
||||
describe("detectLeaks", () => {
|
||||
it("should detect ORM framework leak in domain layer", () => {
|
||||
const imports = ["@prisma/client", "../entities/User"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks).toHaveLength(1)
|
||||
expect(leaks[0].packageName).toBe("@prisma/client")
|
||||
expect(leaks[0].category).toBe("ORM")
|
||||
expect(leaks[0].layer).toBe("domain")
|
||||
expect(leaks[0].filePath).toBe("src/domain/User.ts")
|
||||
})
|
||||
|
||||
it("should detect web framework leak in domain layer", () => {
|
||||
const imports = ["express", "./UserService"]
|
||||
const leaks = detector.detectLeaks(
|
||||
imports,
|
||||
"src/domain/services/UserService.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(leaks).toHaveLength(1)
|
||||
expect(leaks[0].packageName).toBe("express")
|
||||
expect(leaks[0].category).toBe("WEB_FRAMEWORK")
|
||||
})
|
||||
|
||||
it("should detect multiple framework leaks", () => {
|
||||
const imports = ["@prisma/client", "express", "axios", "../entities/User"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks).toHaveLength(3)
|
||||
expect(leaks[0].packageName).toBe("@prisma/client")
|
||||
expect(leaks[1].packageName).toBe("express")
|
||||
expect(leaks[2].packageName).toBe("axios")
|
||||
})
|
||||
|
||||
it("should not detect leaks in infrastructure layer", () => {
|
||||
const imports = ["@prisma/client", "express", "../domain/User"]
|
||||
const leaks = detector.detectLeaks(
|
||||
imports,
|
||||
"src/infrastructure/UserRepository.ts",
|
||||
"infrastructure",
|
||||
)
|
||||
|
||||
expect(leaks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should not detect leaks in application layer", () => {
|
||||
const imports = ["express", "../domain/User"]
|
||||
const leaks = detector.detectLeaks(
|
||||
imports,
|
||||
"src/application/CreateUser.ts",
|
||||
"application",
|
||||
)
|
||||
|
||||
expect(leaks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should not detect relative imports as leaks", () => {
|
||||
const imports = ["./UserService", "../entities/User", "../../shared/utils"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should handle empty imports", () => {
|
||||
const leaks = detector.detectLeaks([], "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should handle undefined layer", () => {
|
||||
const imports = ["@prisma/client"]
|
||||
const leaks = detector.detectLeaks(imports, "src/utils/helper.ts", undefined)
|
||||
|
||||
expect(leaks).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isFrameworkPackage", () => {
|
||||
it("should identify Prisma as framework package", () => {
|
||||
expect(detector.isFrameworkPackage("@prisma/client")).toBe(true)
|
||||
})
|
||||
|
||||
it("should identify Express as framework package", () => {
|
||||
expect(detector.isFrameworkPackage("express")).toBe(true)
|
||||
})
|
||||
|
||||
it("should identify Mongoose as framework package", () => {
|
||||
expect(detector.isFrameworkPackage("mongoose")).toBe(true)
|
||||
})
|
||||
|
||||
it("should identify Axios as framework package", () => {
|
||||
expect(detector.isFrameworkPackage("axios")).toBe(true)
|
||||
})
|
||||
|
||||
it("should not identify relative import as framework package", () => {
|
||||
expect(detector.isFrameworkPackage("./UserService")).toBe(false)
|
||||
})
|
||||
|
||||
it("should not identify relative import as framework package", () => {
|
||||
expect(detector.isFrameworkPackage("../entities/User")).toBe(false)
|
||||
})
|
||||
|
||||
it("should not identify non-framework package as framework", () => {
|
||||
expect(detector.isFrameworkPackage("lodash")).toBe(false)
|
||||
})
|
||||
|
||||
it("should not identify Node.js built-ins as framework", () => {
|
||||
expect(detector.isFrameworkPackage("fs")).toBe(false)
|
||||
expect(detector.isFrameworkPackage("path")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Framework Categories", () => {
|
||||
it("should detect TypeORM as ORM", () => {
|
||||
const imports = ["typeorm"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].category).toBe("ORM")
|
||||
expect(leaks[0].getCategoryDescription()).toBe("Database ORM/ODM")
|
||||
})
|
||||
|
||||
it("should detect Knex as ORM", () => {
|
||||
const imports = ["knex"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].category).toBe("ORM")
|
||||
})
|
||||
|
||||
it("should detect Fastify as web framework", () => {
|
||||
const imports = ["fastify"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].category).toBe("WEB_FRAMEWORK")
|
||||
expect(leaks[0].getCategoryDescription()).toBe("Web Framework")
|
||||
})
|
||||
|
||||
it("should detect NestJS as web framework", () => {
|
||||
const imports = ["@nestjs/common"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].category).toBe("WEB_FRAMEWORK")
|
||||
})
|
||||
|
||||
it("should detect Winston as logger", () => {
|
||||
const imports = ["winston"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].category).toBe("LOGGER")
|
||||
expect(leaks[0].getCategoryDescription()).toBe("Logger")
|
||||
})
|
||||
|
||||
it("should detect Pino as logger", () => {
|
||||
const imports = ["pino"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].category).toBe("LOGGER")
|
||||
})
|
||||
|
||||
it("should detect Redis as cache", () => {
|
||||
const imports = ["redis"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].category).toBe("CACHE")
|
||||
expect(leaks[0].getCategoryDescription()).toBe("Cache")
|
||||
})
|
||||
|
||||
it("should detect cache-manager as cache", () => {
|
||||
const imports = ["cache-manager"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].category).toBe("CACHE")
|
||||
})
|
||||
|
||||
it("should detect Joi as validation library", () => {
|
||||
const imports = ["joi"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].category).toBe("VALIDATION")
|
||||
expect(leaks[0].getCategoryDescription()).toBe("Validation Library")
|
||||
})
|
||||
|
||||
it("should detect Nodemailer as email service", () => {
|
||||
const imports = ["nodemailer"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].category).toBe("EMAIL")
|
||||
expect(leaks[0].getCategoryDescription()).toBe("Email Service")
|
||||
})
|
||||
|
||||
it("should detect Jest as testing framework", () => {
|
||||
const imports = ["jest"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].category).toBe("TESTING")
|
||||
expect(leaks[0].getCategoryDescription()).toBe("Testing Framework")
|
||||
})
|
||||
|
||||
it("should detect EJS as template engine", () => {
|
||||
const imports = ["ejs"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].category).toBe("TEMPLATE_ENGINE")
|
||||
expect(leaks[0].getCategoryDescription()).toBe("Template Engine")
|
||||
})
|
||||
|
||||
it("should detect Handlebars as template engine", () => {
|
||||
const imports = ["handlebars"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].category).toBe("TEMPLATE_ENGINE")
|
||||
})
|
||||
})
|
||||
|
||||
describe("FrameworkLeak value object", () => {
|
||||
it("should have correct message", () => {
|
||||
const imports = ["@prisma/client"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].getMessage()).toContain("@prisma/client")
|
||||
expect(leaks[0].getMessage()).toContain(
|
||||
"Domain layer imports framework-specific package",
|
||||
)
|
||||
})
|
||||
|
||||
it("should have suggestion", () => {
|
||||
const imports = ["express"]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/User.ts", "domain")
|
||||
|
||||
expect(leaks[0].getSuggestion()).toContain("Create an interface in domain layer")
|
||||
expect(leaks[0].getSuggestion()).toContain("implement it in infrastructure layer")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Real-world scenarios", () => {
|
||||
it("should detect database leak in User entity", () => {
|
||||
const imports = [
|
||||
"@prisma/client",
|
||||
"../value-objects/Email",
|
||||
"../value-objects/Password",
|
||||
]
|
||||
const leaks = detector.detectLeaks(imports, "src/domain/entities/User.ts", "domain")
|
||||
|
||||
expect(leaks).toHaveLength(1)
|
||||
expect(leaks[0].packageName).toBe("@prisma/client")
|
||||
})
|
||||
|
||||
it("should detect HTTP client in domain service", () => {
|
||||
const imports = ["axios", "./IUserRepository", "../entities/User"]
|
||||
const leaks = detector.detectLeaks(
|
||||
imports,
|
||||
"src/domain/services/UserService.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(leaks).toHaveLength(1)
|
||||
expect(leaks[0].packageName).toBe("axios")
|
||||
expect(leaks[0].category).toBe("HTTP_CLIENT")
|
||||
})
|
||||
|
||||
it("should allow framework in repository implementation", () => {
|
||||
const imports = [
|
||||
"@prisma/client",
|
||||
"../../domain/entities/User",
|
||||
"../../domain/repositories/IUserRepository",
|
||||
]
|
||||
const leaks = detector.detectLeaks(
|
||||
imports,
|
||||
"src/infrastructure/repositories/PrismaUserRepository.ts",
|
||||
"infrastructure",
|
||||
)
|
||||
|
||||
expect(leaks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should detect validation library in domain", () => {
|
||||
const imports = ["zod", "../entities/User"]
|
||||
const leaks = detector.detectLeaks(
|
||||
imports,
|
||||
"src/domain/value-objects/Email.ts",
|
||||
"domain",
|
||||
)
|
||||
|
||||
expect(leaks).toHaveLength(1)
|
||||
expect(leaks[0].packageName).toBe("zod")
|
||||
expect(leaks[0].category).toBe("VALIDATION")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user