mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat: add entity exposure detection (v0.3.0)
Implement entity exposure detection to prevent domain entities from leaking to API responses. Detects when controllers/routes return domain entities instead of DTOs. Features: - EntityExposure value object with detailed suggestions - IEntityExposureDetector interface in domain layer - EntityExposureDetector implementation in infrastructure - Integration into AnalyzeProject use case - CLI display with helpful suggestions - 24 comprehensive unit tests (98% coverage) - Examples for bad and good patterns Detection scope: - Infrastructure layer only (controllers, routes, handlers, resolvers, gateways) - Identifies PascalCase entities without Dto/Request/Response suffixes - Parses async methods with Promise<T> return types - Provides step-by-step remediation suggestions Test coverage: - EntityExposureDetector: 98.07% - Overall project: 90.6% statements, 83.97% branches - 218 tests passing BREAKING CHANGE: Version bump to 0.3.0
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
This document outlines the current features and future plans for @puaros/guardian.
|
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
|
**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 🎭
|
**Released:** 2025-11-24
|
||||||
**Target:** Q1 2026
|
|
||||||
**Priority:** HIGH
|
**Priority:** HIGH
|
||||||
|
|
||||||
Prevent domain entities from leaking to API responses:
|
Prevent domain entities from leaking to API responses:
|
||||||
@@ -63,15 +62,18 @@ async getUser(id: string): Promise<UserResponseDto> {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Planned Features:**
|
**Implemented Features:**
|
||||||
- Analyze return types in controllers/routes
|
- ✅ Analyze return types in controllers/routes
|
||||||
- Check if returned type is from domain/entities
|
- ✅ Check if returned type is from domain/entities
|
||||||
- Suggest using DTOs and Mappers
|
- ✅ Suggest using DTOs and Mappers
|
||||||
- Examples of proper DTO usage
|
- ✅ 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
|
**Target:** Q1 2026
|
||||||
**Priority:** HIGH
|
**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
|
**Target:** Q1 2026
|
||||||
**Priority:** HIGH
|
**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
|
**Target:** Q1 2026
|
||||||
**Priority:** MEDIUM
|
**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
|
**Target:** Q2 2026
|
||||||
**Priority:** MEDIUM
|
**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
|
**Last Updated:** 2025-11-24
|
||||||
**Current Version:** 0.2.0
|
**Current Version:** 0.3.0
|
||||||
|
|||||||
@@ -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<Order> {
|
||||||
|
return {
|
||||||
|
id: orderId,
|
||||||
|
items: [],
|
||||||
|
total: 100,
|
||||||
|
customerId: "customer-123",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listOrders(): Promise<Order[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<UserResponseDto> {
|
||||||
|
const user = new User(userId, "user@example.com", "hashed-password")
|
||||||
|
return UserMapper.toDto(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsers(): Promise<UserResponseDto[]> {
|
||||||
|
const users = [new User("1", "user1@example.com", "password")]
|
||||||
|
return users.map((user) => UserMapper.toDto(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@samiyev/guardian",
|
"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.",
|
"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": [
|
"keywords": [
|
||||||
"puaros",
|
"puaros",
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { ICodeParser } from "./domain/services/ICodeParser"
|
|||||||
import { IHardcodeDetector } from "./domain/services/IHardcodeDetector"
|
import { IHardcodeDetector } from "./domain/services/IHardcodeDetector"
|
||||||
import { INamingConventionDetector } from "./domain/services/INamingConventionDetector"
|
import { INamingConventionDetector } from "./domain/services/INamingConventionDetector"
|
||||||
import { IFrameworkLeakDetector } from "./domain/services/IFrameworkLeakDetector"
|
import { IFrameworkLeakDetector } from "./domain/services/IFrameworkLeakDetector"
|
||||||
|
import { IEntityExposureDetector } from "./domain/services/IEntityExposureDetector"
|
||||||
import { FileScanner } from "./infrastructure/scanners/FileScanner"
|
import { FileScanner } from "./infrastructure/scanners/FileScanner"
|
||||||
import { CodeParser } from "./infrastructure/parsers/CodeParser"
|
import { CodeParser } from "./infrastructure/parsers/CodeParser"
|
||||||
import { HardcodeDetector } from "./infrastructure/analyzers/HardcodeDetector"
|
import { HardcodeDetector } from "./infrastructure/analyzers/HardcodeDetector"
|
||||||
import { NamingConventionDetector } from "./infrastructure/analyzers/NamingConventionDetector"
|
import { NamingConventionDetector } from "./infrastructure/analyzers/NamingConventionDetector"
|
||||||
import { FrameworkLeakDetector } from "./infrastructure/analyzers/FrameworkLeakDetector"
|
import { FrameworkLeakDetector } from "./infrastructure/analyzers/FrameworkLeakDetector"
|
||||||
|
import { EntityExposureDetector } from "./infrastructure/analyzers/EntityExposureDetector"
|
||||||
import { ERROR_MESSAGES } from "./shared/constants"
|
import { ERROR_MESSAGES } from "./shared/constants"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,12 +68,14 @@ export async function analyzeProject(
|
|||||||
const hardcodeDetector: IHardcodeDetector = new HardcodeDetector()
|
const hardcodeDetector: IHardcodeDetector = new HardcodeDetector()
|
||||||
const namingConventionDetector: INamingConventionDetector = new NamingConventionDetector()
|
const namingConventionDetector: INamingConventionDetector = new NamingConventionDetector()
|
||||||
const frameworkLeakDetector: IFrameworkLeakDetector = new FrameworkLeakDetector()
|
const frameworkLeakDetector: IFrameworkLeakDetector = new FrameworkLeakDetector()
|
||||||
|
const entityExposureDetector: IEntityExposureDetector = new EntityExposureDetector()
|
||||||
const useCase = new AnalyzeProject(
|
const useCase = new AnalyzeProject(
|
||||||
fileScanner,
|
fileScanner,
|
||||||
codeParser,
|
codeParser,
|
||||||
hardcodeDetector,
|
hardcodeDetector,
|
||||||
namingConventionDetector,
|
namingConventionDetector,
|
||||||
frameworkLeakDetector,
|
frameworkLeakDetector,
|
||||||
|
entityExposureDetector,
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = await useCase.execute(options)
|
const result = await useCase.execute(options)
|
||||||
@@ -91,5 +95,6 @@ export type {
|
|||||||
CircularDependencyViolation,
|
CircularDependencyViolation,
|
||||||
NamingConventionViolation,
|
NamingConventionViolation,
|
||||||
FrameworkLeakViolation,
|
FrameworkLeakViolation,
|
||||||
|
EntityExposureViolation,
|
||||||
ProjectMetrics,
|
ProjectMetrics,
|
||||||
} from "./application/use-cases/AnalyzeProject"
|
} from "./application/use-cases/AnalyzeProject"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ICodeParser } from "../../domain/services/ICodeParser"
|
|||||||
import { IHardcodeDetector } from "../../domain/services/IHardcodeDetector"
|
import { IHardcodeDetector } from "../../domain/services/IHardcodeDetector"
|
||||||
import { INamingConventionDetector } from "../../domain/services/INamingConventionDetector"
|
import { INamingConventionDetector } from "../../domain/services/INamingConventionDetector"
|
||||||
import { IFrameworkLeakDetector } from "../../domain/services/IFrameworkLeakDetector"
|
import { IFrameworkLeakDetector } from "../../domain/services/IFrameworkLeakDetector"
|
||||||
|
import { IEntityExposureDetector } from "../../domain/services/IEntityExposureDetector"
|
||||||
import { SourceFile } from "../../domain/entities/SourceFile"
|
import { SourceFile } from "../../domain/entities/SourceFile"
|
||||||
import { DependencyGraph } from "../../domain/entities/DependencyGraph"
|
import { DependencyGraph } from "../../domain/entities/DependencyGraph"
|
||||||
import { ProjectPath } from "../../domain/value-objects/ProjectPath"
|
import { ProjectPath } from "../../domain/value-objects/ProjectPath"
|
||||||
@@ -32,6 +33,7 @@ export interface AnalyzeProjectResponse {
|
|||||||
circularDependencyViolations: CircularDependencyViolation[]
|
circularDependencyViolations: CircularDependencyViolation[]
|
||||||
namingViolations: NamingConventionViolation[]
|
namingViolations: NamingConventionViolation[]
|
||||||
frameworkLeakViolations: FrameworkLeakViolation[]
|
frameworkLeakViolations: FrameworkLeakViolation[]
|
||||||
|
entityExposureViolations: EntityExposureViolation[]
|
||||||
metrics: ProjectMetrics
|
metrics: ProjectMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +97,18 @@ export interface FrameworkLeakViolation {
|
|||||||
suggestion: string
|
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 {
|
export interface ProjectMetrics {
|
||||||
totalFiles: number
|
totalFiles: number
|
||||||
totalFunctions: number
|
totalFunctions: number
|
||||||
@@ -115,6 +129,7 @@ export class AnalyzeProject extends UseCase<
|
|||||||
private readonly hardcodeDetector: IHardcodeDetector,
|
private readonly hardcodeDetector: IHardcodeDetector,
|
||||||
private readonly namingConventionDetector: INamingConventionDetector,
|
private readonly namingConventionDetector: INamingConventionDetector,
|
||||||
private readonly frameworkLeakDetector: IFrameworkLeakDetector,
|
private readonly frameworkLeakDetector: IFrameworkLeakDetector,
|
||||||
|
private readonly entityExposureDetector: IEntityExposureDetector,
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
@@ -164,6 +179,7 @@ export class AnalyzeProject extends UseCase<
|
|||||||
const circularDependencyViolations = this.detectCircularDependencies(dependencyGraph)
|
const circularDependencyViolations = this.detectCircularDependencies(dependencyGraph)
|
||||||
const namingViolations = this.detectNamingConventions(sourceFiles)
|
const namingViolations = this.detectNamingConventions(sourceFiles)
|
||||||
const frameworkLeakViolations = this.detectFrameworkLeaks(sourceFiles)
|
const frameworkLeakViolations = this.detectFrameworkLeaks(sourceFiles)
|
||||||
|
const entityExposureViolations = this.detectEntityExposures(sourceFiles)
|
||||||
const metrics = this.calculateMetrics(sourceFiles, totalFunctions, dependencyGraph)
|
const metrics = this.calculateMetrics(sourceFiles, totalFunctions, dependencyGraph)
|
||||||
|
|
||||||
return ResponseDto.ok({
|
return ResponseDto.ok({
|
||||||
@@ -174,6 +190,7 @@ export class AnalyzeProject extends UseCase<
|
|||||||
circularDependencyViolations,
|
circularDependencyViolations,
|
||||||
namingViolations,
|
namingViolations,
|
||||||
frameworkLeakViolations,
|
frameworkLeakViolations,
|
||||||
|
entityExposureViolations,
|
||||||
metrics,
|
metrics,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -364,6 +381,34 @@ export class AnalyzeProject extends UseCase<
|
|||||||
return violations
|
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(
|
private calculateMetrics(
|
||||||
sourceFiles: SourceFile[],
|
sourceFiles: SourceFile[],
|
||||||
totalFunctions: number,
|
totalFunctions: number,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ program
|
|||||||
circularDependencyViolations,
|
circularDependencyViolations,
|
||||||
namingViolations,
|
namingViolations,
|
||||||
frameworkLeakViolations,
|
frameworkLeakViolations,
|
||||||
|
entityExposureViolations,
|
||||||
metrics,
|
metrics,
|
||||||
} = result
|
} = 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
|
// Hardcode violations
|
||||||
if (options.hardcode && hardcodeViolations.length > 0) {
|
if (options.hardcode && hardcodeViolations.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -151,7 +179,8 @@ program
|
|||||||
hardcodeViolations.length +
|
hardcodeViolations.length +
|
||||||
circularDependencyViolations.length +
|
circularDependencyViolations.length +
|
||||||
namingViolations.length +
|
namingViolations.length +
|
||||||
frameworkLeakViolations.length
|
frameworkLeakViolations.length +
|
||||||
|
entityExposureViolations.length
|
||||||
|
|
||||||
if (totalIssues === 0) {
|
if (totalIssues === 0) {
|
||||||
console.log(CLI_MESSAGES.NO_ISSUES)
|
console.log(CLI_MESSAGES.NO_ISSUES)
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export class DependencyGraph extends BaseEntity {
|
|||||||
totalDependencies: number
|
totalDependencies: number
|
||||||
avgDependencies: number
|
avgDependencies: number
|
||||||
maxDependencies: number
|
maxDependencies: number
|
||||||
} {
|
} {
|
||||||
const nodes = Array.from(this.nodes.values())
|
const nodes = Array.from(this.nodes.values())
|
||||||
const totalFiles = nodes.length
|
const totalFiles = nodes.length
|
||||||
const totalDependencies = nodes.reduce((sum, node) => sum + node.dependencies.length, 0)
|
const totalDependencies = nodes.reduce((sum, node) => sum + node.dependencies.length, 0)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
109
packages/guardian/src/domain/value-objects/EntityExposure.ts
Normal file
109
packages/guardian/src/domain/value-objects/EntityExposure.ts
Normal file
@@ -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<EntityExposureProps> {
|
||||||
|
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)
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<User> {
|
||||||
|
* 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"
|
||||||
|
* - "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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export const RULES = {
|
|||||||
CIRCULAR_DEPENDENCY: "circular-dependency",
|
CIRCULAR_DEPENDENCY: "circular-dependency",
|
||||||
NAMING_CONVENTION: "naming-convention",
|
NAMING_CONVENTION: "naming-convention",
|
||||||
FRAMEWORK_LEAK: "framework-leak",
|
FRAMEWORK_LEAK: "framework-leak",
|
||||||
|
ENTITY_EXPOSURE: "entity-exposure",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
362
packages/guardian/tests/EntityExposureDetector.test.ts
Normal file
362
packages/guardian/tests/EntityExposureDetector.test.ts
Normal file
@@ -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<User> {
|
||||||
|
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<Order> {
|
||||||
|
return this.orderService.findById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUser(userId: string): Promise<User> {
|
||||||
|
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<UserResponseDto> {
|
||||||
|
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<number> {
|
||||||
|
return this.userService.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserName(id: string): Promise<string> {
|
||||||
|
return this.userService.getName(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(id: string): Promise<void> {
|
||||||
|
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<User> {
|
||||||
|
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<User> {
|
||||||
|
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<User> {
|
||||||
|
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<User> {
|
||||||
|
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<UserResponse> {
|
||||||
|
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<User> {
|
||||||
|
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<User>")).toBe(true)
|
||||||
|
expect(detector.isDomainEntity("Promise<UserDto>")).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<User> {
|
||||||
|
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<Order> {
|
||||||
|
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<UserResponseDto> {
|
||||||
|
const user = await this.userService.findById(id)
|
||||||
|
return UserMapper.toDto(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(request: CreateUserRequest): Promise<UserResponseDto> {
|
||||||
|
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<User> {
|
||||||
|
return this.userService.findById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async listUsers(): Promise<UserListResponse> {
|
||||||
|
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<User> {
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user