diff --git a/packages/guardian/ROADMAP.md b/packages/guardian/ROADMAP.md index dc86165..b1b239a 100644 --- a/packages/guardian/ROADMAP.md +++ b/packages/guardian/ROADMAP.md @@ -2,7 +2,7 @@ This document outlines the current features and future plans for @puaros/guardian. -## Current Version: 0.1.0 āœ… RELEASED +## Current Version: 0.3.0 āœ… RELEASED **Released:** 2025-11-24 @@ -42,10 +42,9 @@ This document outlines the current features and future plans for @puaros/guardia --- -## Future Roadmap +## Version 0.3.0 - Entity Exposure Detection šŸŽ­ āœ… RELEASED -### Version 0.2.0 - Entity Exposure Detection šŸŽ­ -**Target:** Q1 2026 +**Released:** 2025-11-24 **Priority:** HIGH Prevent domain entities from leaking to API responses: @@ -63,15 +62,18 @@ async getUser(id: string): Promise { } ``` -**Planned Features:** -- Analyze return types in controllers/routes -- Check if returned type is from domain/entities -- Suggest using DTOs and Mappers -- Examples of proper DTO usage +**Implemented Features:** +- āœ… Analyze return types in controllers/routes +- āœ… Check if returned type is from domain/entities +- āœ… Suggest using DTOs and Mappers +- āœ… Examples of proper DTO usage +- āœ… 24 tests covering all scenarios --- -### Version 0.3.0 - Dependency Direction Enforcement šŸŽÆ +## Future Roadmap + +### Version 0.4.0 - Dependency Direction Enforcement šŸŽÆ **Target:** Q1 2026 **Priority:** HIGH @@ -111,7 +113,7 @@ import { User } from '../../domain/entities/User' // OK --- -### Version 0.4.0 - Repository Pattern Validation šŸ“š +### Version 0.5.0 - Repository Pattern Validation šŸ“š **Target:** Q1 2026 **Priority:** HIGH @@ -152,7 +154,7 @@ class CreateUser { --- -### Version 0.5.0 - Aggregate Boundary Validation šŸ”’ +### Version 0.6.0 - Aggregate Boundary Validation šŸ”’ **Target:** Q1 2026 **Priority:** MEDIUM @@ -189,7 +191,7 @@ class Order { --- -### Version 0.6.0 - Anemic Domain Model Detection 🩺 +### Version 0.7.0 - Anemic Domain Model Detection 🩺 **Target:** Q2 2026 **Priority:** MEDIUM @@ -1746,4 +1748,4 @@ Until we reach 1.0.0, minor version bumps (0.x.0) may include breaking changes a --- **Last Updated:** 2025-11-24 -**Current Version:** 0.2.0 +**Current Version:** 0.3.0 diff --git a/packages/guardian/examples/bad-architecture/entity-exposure/infrastructure/controllers/BadOrderController.ts b/packages/guardian/examples/bad-architecture/entity-exposure/infrastructure/controllers/BadOrderController.ts new file mode 100644 index 0000000..5fa5863 --- /dev/null +++ b/packages/guardian/examples/bad-architecture/entity-exposure/infrastructure/controllers/BadOrderController.ts @@ -0,0 +1,33 @@ +// āŒ BAD: Exposing domain entity Order in API response + +class Order { + constructor( + public id: string, + public items: OrderItem[], + public total: number, + public customerId: string, + ) {} +} + +class OrderItem { + constructor( + public productId: string, + public quantity: number, + public price: number, + ) {} +} + +class BadOrderController { + async getOrder(orderId: string): Promise { + return { + id: orderId, + items: [], + total: 100, + customerId: "customer-123", + } + } + + async listOrders(): Promise { + return [] + } +} diff --git a/packages/guardian/examples/bad-architecture/entity-exposure/BadUserController.ts b/packages/guardian/examples/bad-architecture/entity-exposure/infrastructure/controllers/BadUserController.ts similarity index 100% rename from packages/guardian/examples/bad-architecture/entity-exposure/BadUserController.ts rename to packages/guardian/examples/bad-architecture/entity-exposure/infrastructure/controllers/BadUserController.ts diff --git a/packages/guardian/examples/good-architecture/entity-exposure/GoodUserController.ts b/packages/guardian/examples/good-architecture/entity-exposure/GoodUserController.ts new file mode 100644 index 0000000..357ffc9 --- /dev/null +++ b/packages/guardian/examples/good-architecture/entity-exposure/GoodUserController.ts @@ -0,0 +1,42 @@ +// āœ… GOOD: Using DTOs and Mappers instead of exposing domain entities + +class User { + constructor( + private readonly id: string, + private email: string, + private password: string, + ) {} + + getId(): string { + return this.id + } + + getEmail(): string { + return this.email + } +} + +class UserResponseDto { + constructor( + public readonly id: string, + public readonly email: string, + ) {} +} + +class UserMapper { + static toDto(user: User): UserResponseDto { + return new UserResponseDto(user.getId(), user.getEmail()) + } +} + +class GoodUserController { + async getUser(userId: string): Promise { + const user = new User(userId, "user@example.com", "hashed-password") + return UserMapper.toDto(user) + } + + async listUsers(): Promise { + const users = [new User("1", "user1@example.com", "password")] + return users.map((user) => UserMapper.toDto(user)) + } +} diff --git a/packages/guardian/package.json b/packages/guardian/package.json index 13db3a2..41a1779 100644 --- a/packages/guardian/package.json +++ b/packages/guardian/package.json @@ -1,6 +1,6 @@ { "name": "@samiyev/guardian", - "version": "0.2.0", + "version": "0.3.0", "description": "Code quality guardian for vibe coders and enterprise teams - catch hardcodes, architecture violations, and circular deps. Enforce Clean Architecture at scale. Works with Claude, GPT, Copilot.", "keywords": [ "puaros", diff --git a/packages/guardian/src/api.ts b/packages/guardian/src/api.ts index 966283d..c756ea4 100644 --- a/packages/guardian/src/api.ts +++ b/packages/guardian/src/api.ts @@ -8,11 +8,13 @@ import { ICodeParser } from "./domain/services/ICodeParser" 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 { 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 { ERROR_MESSAGES } from "./shared/constants" /** @@ -66,12 +68,14 @@ export async function analyzeProject( const hardcodeDetector: IHardcodeDetector = new HardcodeDetector() const namingConventionDetector: INamingConventionDetector = new NamingConventionDetector() const frameworkLeakDetector: IFrameworkLeakDetector = new FrameworkLeakDetector() + const entityExposureDetector: IEntityExposureDetector = new EntityExposureDetector() const useCase = new AnalyzeProject( fileScanner, codeParser, hardcodeDetector, namingConventionDetector, frameworkLeakDetector, + entityExposureDetector, ) const result = await useCase.execute(options) @@ -91,5 +95,6 @@ export type { CircularDependencyViolation, NamingConventionViolation, FrameworkLeakViolation, + EntityExposureViolation, 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 1253fc1..ee89d3d 100644 --- a/packages/guardian/src/application/use-cases/AnalyzeProject.ts +++ b/packages/guardian/src/application/use-cases/AnalyzeProject.ts @@ -5,6 +5,7 @@ import { ICodeParser } from "../../domain/services/ICodeParser" 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 { SourceFile } from "../../domain/entities/SourceFile" import { DependencyGraph } from "../../domain/entities/DependencyGraph" import { ProjectPath } from "../../domain/value-objects/ProjectPath" @@ -32,6 +33,7 @@ export interface AnalyzeProjectResponse { circularDependencyViolations: CircularDependencyViolation[] namingViolations: NamingConventionViolation[] frameworkLeakViolations: FrameworkLeakViolation[] + entityExposureViolations: EntityExposureViolation[] metrics: ProjectMetrics } @@ -95,6 +97,18 @@ export interface FrameworkLeakViolation { suggestion: string } +export interface EntityExposureViolation { + rule: typeof RULES.ENTITY_EXPOSURE + entityName: string + returnType: string + file: string + layer: string + line?: number + methodName?: string + message: string + suggestion: string +} + export interface ProjectMetrics { totalFiles: number totalFunctions: number @@ -115,6 +129,7 @@ export class AnalyzeProject extends UseCase< private readonly hardcodeDetector: IHardcodeDetector, private readonly namingConventionDetector: INamingConventionDetector, private readonly frameworkLeakDetector: IFrameworkLeakDetector, + private readonly entityExposureDetector: IEntityExposureDetector, ) { super() } @@ -164,6 +179,7 @@ export class AnalyzeProject extends UseCase< const circularDependencyViolations = this.detectCircularDependencies(dependencyGraph) const namingViolations = this.detectNamingConventions(sourceFiles) const frameworkLeakViolations = this.detectFrameworkLeaks(sourceFiles) + const entityExposureViolations = this.detectEntityExposures(sourceFiles) const metrics = this.calculateMetrics(sourceFiles, totalFunctions, dependencyGraph) return ResponseDto.ok({ @@ -174,6 +190,7 @@ export class AnalyzeProject extends UseCase< circularDependencyViolations, namingViolations, frameworkLeakViolations, + entityExposureViolations, metrics, }) } catch (error) { @@ -364,6 +381,34 @@ export class AnalyzeProject extends UseCase< return violations } + private detectEntityExposures(sourceFiles: SourceFile[]): EntityExposureViolation[] { + const violations: EntityExposureViolation[] = [] + + for (const file of sourceFiles) { + const exposures = this.entityExposureDetector.detectExposures( + file.content, + file.path.relative, + file.layer, + ) + + for (const exposure of exposures) { + violations.push({ + rule: RULES.ENTITY_EXPOSURE, + entityName: exposure.entityName, + returnType: exposure.returnType, + file: file.path.relative, + layer: exposure.layer, + line: exposure.line, + methodName: exposure.methodName, + message: exposure.getMessage(), + suggestion: exposure.getSuggestion(), + }) + } + } + + 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 65a02d0..de745e2 100644 --- a/packages/guardian/src/cli/index.ts +++ b/packages/guardian/src/cli/index.ts @@ -39,6 +39,7 @@ program circularDependencyViolations, namingViolations, frameworkLeakViolations, + entityExposureViolations, metrics, } = result @@ -126,6 +127,33 @@ program }) } + // Entity exposure violations + if (options.architecture && entityExposureViolations.length > 0) { + console.log( + `\nšŸŽ­ Found ${String(entityExposureViolations.length)} entity exposure(s):\n`, + ) + + entityExposureViolations.forEach((ee, index) => { + const location = ee.line ? `${ee.file}:${String(ee.line)}` : ee.file + console.log(`${String(index + 1)}. ${location}`) + console.log(` Entity: ${ee.entityName}`) + console.log(` Return Type: ${ee.returnType}`) + if (ee.methodName) { + console.log(` Method: ${ee.methodName}`) + } + console.log(` Layer: ${ee.layer}`) + console.log(` Rule: ${ee.rule}`) + console.log(` ${ee.message}`) + console.log(" šŸ’” Suggestion:") + ee.suggestion.split("\n").forEach((line) => { + if (line.trim()) { + console.log(` ${line}`) + } + }) + console.log("") + }) + } + // Hardcode violations if (options.hardcode && hardcodeViolations.length > 0) { console.log( @@ -151,7 +179,8 @@ program hardcodeViolations.length + circularDependencyViolations.length + namingViolations.length + - frameworkLeakViolations.length + frameworkLeakViolations.length + + entityExposureViolations.length if (totalIssues === 0) { console.log(CLI_MESSAGES.NO_ISSUES) diff --git a/packages/guardian/src/domain/entities/DependencyGraph.ts b/packages/guardian/src/domain/entities/DependencyGraph.ts index bb2bd97..830e604 100644 --- a/packages/guardian/src/domain/entities/DependencyGraph.ts +++ b/packages/guardian/src/domain/entities/DependencyGraph.ts @@ -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) diff --git a/packages/guardian/src/domain/services/IEntityExposureDetector.ts b/packages/guardian/src/domain/services/IEntityExposureDetector.ts new file mode 100644 index 0000000..660063d --- /dev/null +++ b/packages/guardian/src/domain/services/IEntityExposureDetector.ts @@ -0,0 +1,34 @@ +import { EntityExposure } from "../value-objects/EntityExposure" + +/** + * Interface for detecting entity exposure violations in the codebase + * + * Entity exposure occurs when domain entities are directly returned from + * controllers/routes instead of using DTOs (Data Transfer Objects). + * This violates separation of concerns and can expose internal domain logic. + */ +export interface IEntityExposureDetector { + /** + * Detects entity exposure violations in the given code + * + * Analyzes method return types in controllers/routes to identify + * domain entities being directly exposed to external clients. + * + * @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 entity exposure violations + */ + detectExposures(code: string, filePath: string, layer: string | undefined): EntityExposure[] + + /** + * Checks if a return type is a domain entity + * + * Domain entities are typically PascalCase nouns without Dto/Request/Response suffixes + * and are defined in the domain layer. + * + * @param returnType - The return type to check + * @returns True if the return type appears to be a domain entity + */ + isDomainEntity(returnType: string): boolean +} diff --git a/packages/guardian/src/domain/value-objects/EntityExposure.ts b/packages/guardian/src/domain/value-objects/EntityExposure.ts new file mode 100644 index 0000000..6b1b563 --- /dev/null +++ b/packages/guardian/src/domain/value-objects/EntityExposure.ts @@ -0,0 +1,109 @@ +import { ValueObject } from "./ValueObject" + +interface EntityExposureProps { + readonly entityName: string + readonly returnType: string + readonly filePath: string + readonly layer: string + readonly line?: number + readonly methodName?: string +} + +/** + * Represents an entity exposure violation in the codebase + * + * Entity exposure occurs when a domain entity is directly exposed in API responses + * instead of using DTOs (Data Transfer Objects). This violates the separation of concerns + * and can lead to exposing internal domain logic to external clients. + * + * @example + * ```typescript + * // Bad: Controller returning domain entity + * const exposure = EntityExposure.create( + * 'User', + * 'User', + * 'src/infrastructure/controllers/UserController.ts', + * 'infrastructure', + * 25, + * 'getUser' + * ) + * + * console.log(exposure.getMessage()) + * // "Method 'getUser' returns domain entity 'User' instead of DTO" + * ``` + */ +export class EntityExposure extends ValueObject { + private constructor(props: EntityExposureProps) { + super(props) + } + + public static create( + entityName: string, + returnType: string, + filePath: string, + layer: string, + line?: number, + methodName?: string, + ): EntityExposure { + return new EntityExposure({ + entityName, + returnType, + filePath, + layer, + line, + methodName, + }) + } + + public get entityName(): string { + return this.props.entityName + } + + public get returnType(): string { + return this.props.returnType + } + + public get filePath(): string { + return this.props.filePath + } + + public get layer(): string { + return this.props.layer + } + + public get line(): number | undefined { + return this.props.line + } + + public get methodName(): string | undefined { + return this.props.methodName + } + + public getMessage(): string { + const method = this.props.methodName ? `Method '${this.props.methodName}'` : "Method" + return `${method} returns domain entity '${this.props.entityName}' instead of DTO` + } + + public getSuggestion(): string { + const suggestions = [ + `Create a DTO class (e.g., ${this.props.entityName}ResponseDto) in the application layer`, + `Create a mapper to convert ${this.props.entityName} to ${this.props.entityName}ResponseDto`, + `Update the method to return ${this.props.entityName}ResponseDto instead of ${this.props.entityName}`, + ] + return suggestions.join("\n") + } + + public getExampleFix(): string { + return ` +// āŒ Bad: Exposing domain entity +async ${this.props.methodName || "getEntity"}(): Promise<${this.props.entityName}> { + return await this.service.find() +} + +// āœ… Good: Using DTO +async ${this.props.methodName || "getEntity"}(): Promise<${this.props.entityName}ResponseDto> { + const entity = await this.service.find() + return ${this.props.entityName}Mapper.toDto(entity) +}` + } +} diff --git a/packages/guardian/src/infrastructure/analyzers/EntityExposureDetector.ts b/packages/guardian/src/infrastructure/analyzers/EntityExposureDetector.ts new file mode 100644 index 0000000..1431413 --- /dev/null +++ b/packages/guardian/src/infrastructure/analyzers/EntityExposureDetector.ts @@ -0,0 +1,214 @@ +import { IEntityExposureDetector } from "../../domain/services/IEntityExposureDetector" +import { EntityExposure } from "../../domain/value-objects/EntityExposure" +import { LAYERS } from "../../shared/constants/rules" + +/** + * Detects domain entity exposure in controller/route return types + * + * This detector identifies violations where controllers or route handlers + * directly return domain entities instead of using DTOs (Data Transfer Objects). + * This violates separation of concerns and can expose internal domain logic. + * + * @example + * ```typescript + * const detector = new EntityExposureDetector() + * + * // Detect exposures in a controller file + * const code = ` + * class UserController { + * async getUser(id: string): Promise { + * return this.userService.findById(id) + * } + * } + * ` + * const exposures = detector.detectExposures(code, 'src/infrastructure/controllers/UserController.ts', 'infrastructure') + * + * // exposures will contain violation for returning User entity + * console.log(exposures.length) // 1 + * console.log(exposures[0].entityName) // 'User' + * ``` + */ +export class EntityExposureDetector implements IEntityExposureDetector { + private readonly dtoSuffixes = [ + "Dto", + "DTO", + "Request", + "Response", + "Command", + "Query", + "Result", + ] + private readonly controllerPatterns = [ + /Controller/i, + /Route/i, + /Handler/i, + /Resolver/i, + /Gateway/i, + ] + + /** + * Detects entity exposure violations in the given code + * + * Analyzes method return types in controllers/routes to identify + * domain entities being directly exposed to external clients. + * + * @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 entity exposure violations + */ + public detectExposures( + code: string, + filePath: string, + layer: string | undefined, + ): EntityExposure[] { + if (layer !== LAYERS.INFRASTRUCTURE || !this.isControllerFile(filePath)) { + return [] + } + + const exposures: EntityExposure[] = [] + const lines = code.split("\n") + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const lineNumber = i + 1 + + const methodMatches = this.findMethodReturnTypes(line) + for (const match of methodMatches) { + const { methodName, returnType } = match + + if (this.isDomainEntity(returnType)) { + exposures.push( + EntityExposure.create( + returnType, + returnType, + filePath, + layer, + lineNumber, + methodName, + ), + ) + } + } + } + + return exposures + } + + /** + * Checks if a return type is a domain entity + * + * Domain entities are typically PascalCase nouns without Dto/Request/Response suffixes + * and are defined in the domain layer. + * + * @param returnType - The return type to check + * @returns True if the return type appears to be a domain entity + */ + public isDomainEntity(returnType: string): boolean { + if (!returnType || returnType.trim() === "") { + return false + } + + const cleanType = this.extractCoreType(returnType) + + if (this.isPrimitiveType(cleanType)) { + return false + } + + if (this.hasAllowedSuffix(cleanType)) { + return false + } + + return this.isPascalCase(cleanType) + } + + /** + * Checks if the file is a controller/route file + */ + private isControllerFile(filePath: string): boolean { + return this.controllerPatterns.some((pattern) => pattern.test(filePath)) + } + + /** + * Finds method return types in a line of code + */ + private findMethodReturnTypes(line: string): { methodName: string; returnType: string }[] { + const matches: { methodName: string; returnType: string }[] = [] + + const methodRegex = + /(?:async\s+)?(\w+)\s*\([^)]*\)\s*:\s*Promise<([^>]+)>|(?:async\s+)?(\w+)\s*\([^)]*\)\s*:\s*([A-Z]\w+)/g + + let match + while ((match = methodRegex.exec(line)) !== null) { + const methodName = match[1] || match[3] + const returnType = match[2] || match[4] + + if (methodName && returnType) { + matches.push({ methodName, returnType }) + } + } + + return matches + } + + /** + * Extracts core type from complex type annotations + * Examples: + * - "Promise" -> "User" + * - "User[]" -> "User" + * - "User | null" -> "User" + */ + private extractCoreType(returnType: string): string { + let cleanType = returnType.trim() + + cleanType = cleanType.replace(/Promise<([^>]+)>/, "$1") + + cleanType = cleanType.replace(/\[\]$/, "") + + if (cleanType.includes("|")) { + const types = cleanType.split("|").map((t) => t.trim()) + const nonNullTypes = types.filter((t) => t !== "null" && t !== "undefined") + if (nonNullTypes.length > 0) { + cleanType = nonNullTypes[0] + } + } + + return cleanType.trim() + } + + /** + * Checks if a type is a primitive type + */ + private isPrimitiveType(type: string): boolean { + const primitives = [ + "string", + "number", + "boolean", + "void", + "any", + "unknown", + "null", + "undefined", + "object", + "never", + ] + return primitives.includes(type.toLowerCase()) + } + + /** + * Checks if a type has an allowed DTO/Response suffix + */ + private hasAllowedSuffix(type: string): boolean { + return this.dtoSuffixes.some((suffix) => type.endsWith(suffix)) + } + + /** + * Checks if a string is in PascalCase + */ + private isPascalCase(str: string): boolean { + if (!str || str.length === 0) { + return false + } + return /^[A-Z]([a-z0-9]+[A-Z]?)*[a-z0-9]*$/.test(str) && /[a-z]/.test(str) + } +} diff --git a/packages/guardian/src/shared/constants/rules.ts b/packages/guardian/src/shared/constants/rules.ts index e0198fc..086e588 100644 --- a/packages/guardian/src/shared/constants/rules.ts +++ b/packages/guardian/src/shared/constants/rules.ts @@ -7,6 +7,7 @@ export const RULES = { CIRCULAR_DEPENDENCY: "circular-dependency", NAMING_CONVENTION: "naming-convention", FRAMEWORK_LEAK: "framework-leak", + ENTITY_EXPOSURE: "entity-exposure", } as const /** diff --git a/packages/guardian/tests/EntityExposureDetector.test.ts b/packages/guardian/tests/EntityExposureDetector.test.ts new file mode 100644 index 0000000..8b6b30c --- /dev/null +++ b/packages/guardian/tests/EntityExposureDetector.test.ts @@ -0,0 +1,362 @@ +import { describe, it, expect, beforeEach } from "vitest" +import { EntityExposureDetector } from "../src/infrastructure/analyzers/EntityExposureDetector" + +describe("EntityExposureDetector", () => { + let detector: EntityExposureDetector + + beforeEach(() => { + detector = new EntityExposureDetector() + }) + + describe("detectExposures", () => { + it("should detect entity exposure in controller", () => { + const code = ` +class UserController { + async getUser(id: string): Promise { + return this.userService.findById(id) + } +} +` + const exposures = detector.detectExposures( + code, + "src/infrastructure/controllers/UserController.ts", + "infrastructure", + ) + + expect(exposures).toHaveLength(1) + expect(exposures[0].entityName).toBe("User") + expect(exposures[0].returnType).toBe("User") + expect(exposures[0].methodName).toBe("getUser") + expect(exposures[0].layer).toBe("infrastructure") + }) + + it("should detect multiple entity exposures", () => { + const code = ` +class OrderController { + async getOrder(id: string): Promise { + return this.orderService.findById(id) + } + + async getUser(userId: string): Promise { + return this.userService.findById(userId) + } +} +` + const exposures = detector.detectExposures( + code, + "src/infrastructure/controllers/OrderController.ts", + "infrastructure", + ) + + expect(exposures).toHaveLength(2) + expect(exposures[0].entityName).toBe("Order") + expect(exposures[1].entityName).toBe("User") + }) + + it("should not detect DTO return types", () => { + const code = ` +class UserController { + async getUser(id: string): Promise { + const user = await this.userService.findById(id) + return UserMapper.toDto(user) + } +} +` + const exposures = detector.detectExposures( + code, + "src/infrastructure/controllers/UserController.ts", + "infrastructure", + ) + + expect(exposures).toHaveLength(0) + }) + + it("should not detect primitive return types", () => { + const code = ` +class UserController { + async getUserCount(): Promise { + return this.userService.count() + } + + async getUserName(id: string): Promise { + return this.userService.getName(id) + } + + async deleteUser(id: string): Promise { + await this.userService.delete(id) + } +} +` + const exposures = detector.detectExposures( + code, + "src/infrastructure/controllers/UserController.ts", + "infrastructure", + ) + + expect(exposures).toHaveLength(0) + }) + + it("should not detect exposures in non-controller files", () => { + const code = ` +class UserService { + async findById(id: string): Promise { + return this.repository.findById(id) + } +} +` + const exposures = detector.detectExposures( + code, + "src/application/services/UserService.ts", + "application", + ) + + expect(exposures).toHaveLength(0) + }) + + it("should not detect exposures outside infrastructure layer", () => { + const code = ` +class CreateUser { + async execute(request: CreateUserRequest): Promise { + return User.create(request) + } +} +` + const exposures = detector.detectExposures( + code, + "src/application/use-cases/CreateUser.ts", + "application", + ) + + expect(exposures).toHaveLength(0) + }) + + it("should detect exposures in route handlers", () => { + const code = ` +class UserRoutes { + async getUser(id: string): Promise { + return this.service.findById(id) + } +} +` + const exposures = detector.detectExposures( + code, + "src/infrastructure/routes/UserRoutes.ts", + "infrastructure", + ) + + expect(exposures).toHaveLength(1) + expect(exposures[0].entityName).toBe("User") + }) + + it("should detect exposures with async methods", () => { + const code = ` +class UserHandler { + async handleGetUser(id: string): Promise { + return this.service.findById(id) + } +} +` + const exposures = detector.detectExposures( + code, + "src/infrastructure/handlers/UserHandler.ts", + "infrastructure", + ) + + expect(exposures).toHaveLength(1) + }) + + it("should not detect Request/Response suffixes", () => { + const code = ` +class UserController { + async createUser(request: CreateUserRequest): Promise { + return this.service.create(request) + } +} +` + const exposures = detector.detectExposures( + code, + "src/infrastructure/controllers/UserController.ts", + "infrastructure", + ) + + expect(exposures).toHaveLength(0) + }) + + it("should handle undefined layer", () => { + const code = ` +class UserController { + async getUser(id: string): Promise { + return this.service.findById(id) + } +} +` + const exposures = detector.detectExposures( + code, + "src/controllers/UserController.ts", + undefined, + ) + + expect(exposures).toHaveLength(0) + }) + }) + + describe("isDomainEntity", () => { + it("should identify PascalCase nouns as entities", () => { + expect(detector.isDomainEntity("User")).toBe(true) + expect(detector.isDomainEntity("Order")).toBe(true) + expect(detector.isDomainEntity("Product")).toBe(true) + }) + + it("should not identify DTOs", () => { + expect(detector.isDomainEntity("UserDto")).toBe(false) + expect(detector.isDomainEntity("UserDTO")).toBe(false) + expect(detector.isDomainEntity("UserResponse")).toBe(false) + expect(detector.isDomainEntity("CreateUserRequest")).toBe(false) + }) + + it("should not identify primitives", () => { + expect(detector.isDomainEntity("string")).toBe(false) + expect(detector.isDomainEntity("number")).toBe(false) + expect(detector.isDomainEntity("boolean")).toBe(false) + expect(detector.isDomainEntity("void")).toBe(false) + expect(detector.isDomainEntity("any")).toBe(false) + expect(detector.isDomainEntity("unknown")).toBe(false) + }) + + it("should handle Promise wrapped types", () => { + expect(detector.isDomainEntity("Promise")).toBe(true) + expect(detector.isDomainEntity("Promise")).toBe(false) + }) + + it("should handle array types", () => { + expect(detector.isDomainEntity("User[]")).toBe(true) + expect(detector.isDomainEntity("UserDto[]")).toBe(false) + }) + + it("should handle union types", () => { + expect(detector.isDomainEntity("User | null")).toBe(true) + expect(detector.isDomainEntity("UserDto | null")).toBe(false) + }) + + it("should not identify non-PascalCase", () => { + expect(detector.isDomainEntity("user")).toBe(false) + expect(detector.isDomainEntity("USER")).toBe(false) + expect(detector.isDomainEntity("user_entity")).toBe(false) + }) + + it("should handle empty strings", () => { + expect(detector.isDomainEntity("")).toBe(false) + expect(detector.isDomainEntity(" ")).toBe(false) + }) + + it("should identify Command/Query/Result suffixes as allowed", () => { + expect(detector.isDomainEntity("CreateUserCommand")).toBe(false) + expect(detector.isDomainEntity("GetUserQuery")).toBe(false) + expect(detector.isDomainEntity("UserResult")).toBe(false) + }) + }) + + describe("Real-world scenarios", () => { + it("should detect User entity exposure in REST API", () => { + const code = ` +class UserController { + async getUser(req: Request, res: Response): Promise { + const user = await this.userService.findById(req.params.id) + return user + } +} +` + const exposures = detector.detectExposures( + code, + "src/infrastructure/controllers/UserController.ts", + "infrastructure", + ) + + expect(exposures).toHaveLength(1) + expect(exposures[0].entityName).toBe("User") + expect(exposures[0].getMessage()).toContain("returns domain entity 'User'") + }) + + it("should detect Order entity exposure in GraphQL resolver", () => { + const code = ` +class OrderResolver { + async getOrder(id: string): Promise { + return this.orderService.findById(id) + } +} +` + const exposures = detector.detectExposures( + code, + "src/infrastructure/resolvers/OrderResolver.ts", + "infrastructure", + ) + + expect(exposures).toHaveLength(1) + expect(exposures[0].entityName).toBe("Order") + }) + + it("should allow DTO usage in controller", () => { + const code = ` +class UserController { + async getUser(id: string): Promise { + const user = await this.userService.findById(id) + return UserMapper.toDto(user) + } + + async createUser(request: CreateUserRequest): Promise { + const user = await this.userService.create(request) + return UserMapper.toDto(user) + } +} +` + const exposures = detector.detectExposures( + code, + "src/infrastructure/controllers/UserController.ts", + "infrastructure", + ) + + expect(exposures).toHaveLength(0) + }) + + it("should detect mixed exposures and DTOs", () => { + const code = ` +class UserController { + async getUser(id: string): Promise { + return this.userService.findById(id) + } + + async listUsers(): Promise { + const users = await this.userService.findAll() + return UserMapper.toListDto(users) + } +} +` + const exposures = detector.detectExposures( + code, + "src/infrastructure/controllers/UserController.ts", + "infrastructure", + ) + + expect(exposures).toHaveLength(1) + expect(exposures[0].methodName).toBe("getUser") + }) + + it("should provide helpful suggestions", () => { + const code = ` +class UserController { + async getUser(id: string): Promise { + return this.userService.findById(id) + } +} +` + const exposures = detector.detectExposures( + code, + "src/infrastructure/controllers/UserController.ts", + "infrastructure", + ) + + expect(exposures[0].getSuggestion()).toContain("UserResponseDto") + expect(exposures[0].getSuggestion()).toContain("mapper") + }) + }) +})