From 0e23938e2091e0097ea817e7f4b5c6e335a85fd7 Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Mon, 24 Nov 2025 12:53:37 +0500 Subject: [PATCH] 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 --- .../domain/services/IFrameworkLeakDetector.ts | 27 ++ .../src/domain/value-objects/FrameworkLeak.ts | 112 +++++++ .../analyzers/FrameworkLeakDetector.ts | 104 ++++++ .../tests/FrameworkLeakDetector.test.ts | 299 ++++++++++++++++++ 4 files changed, 542 insertions(+) create mode 100644 packages/guardian/src/domain/services/IFrameworkLeakDetector.ts create mode 100644 packages/guardian/src/domain/value-objects/FrameworkLeak.ts create mode 100644 packages/guardian/src/infrastructure/analyzers/FrameworkLeakDetector.ts create mode 100644 packages/guardian/tests/FrameworkLeakDetector.test.ts diff --git a/packages/guardian/src/domain/services/IFrameworkLeakDetector.ts b/packages/guardian/src/domain/services/IFrameworkLeakDetector.ts new file mode 100644 index 0000000..b2802a7 --- /dev/null +++ b/packages/guardian/src/domain/services/IFrameworkLeakDetector.ts @@ -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 +} diff --git a/packages/guardian/src/domain/value-objects/FrameworkLeak.ts b/packages/guardian/src/domain/value-objects/FrameworkLeak.ts new file mode 100644 index 0000000..f13979c --- /dev/null +++ b/packages/guardian/src/domain/value-objects/FrameworkLeak.ts @@ -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 { + 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" + } + } +} diff --git a/packages/guardian/src/infrastructure/analyzers/FrameworkLeakDetector.ts b/packages/guardian/src/infrastructure/analyzers/FrameworkLeakDetector.ts new file mode 100644 index 0000000..c6207eb --- /dev/null +++ b/packages/guardian/src/infrastructure/analyzers/FrameworkLeakDetector.ts @@ -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 + + 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 { + const map = new Map() + + for (const [category, packages] of Object.entries(FRAMEWORK_PACKAGES)) { + for (const pkg of packages) { + map.set(pkg, category) + } + } + + return map + } +} diff --git a/packages/guardian/tests/FrameworkLeakDetector.test.ts b/packages/guardian/tests/FrameworkLeakDetector.test.ts new file mode 100644 index 0000000..38895eb --- /dev/null +++ b/packages/guardian/tests/FrameworkLeakDetector.test.ts @@ -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") + }) + }) +})