mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0534fdf1bd | ||
|
|
3fecc98676 | ||
|
|
f46048172f | ||
|
|
a3cd71070e |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* ❌ BAD: Application layer with incorrect dependencies
|
||||||
|
*
|
||||||
|
* Application importing from Infrastructure layer
|
||||||
|
* This violates Clean Architecture dependency rules!
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { User } from "../../good-architecture/domain/entities/User"
|
||||||
|
import { Email } from "../../good-architecture/domain/value-objects/Email"
|
||||||
|
import { UserId } from "../../good-architecture/domain/value-objects/UserId"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Application importing from Infrastructure layer
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from "@prisma/client"
|
||||||
|
|
||||||
|
export class CreateUser {
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Application use case depending on concrete infrastructure (Prisma)
|
||||||
|
*/
|
||||||
|
constructor(private prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async execute(email: string): Promise<User> {
|
||||||
|
const userId = UserId.generate()
|
||||||
|
const emailVO = Email.create(email).value
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Application logic directly accessing database
|
||||||
|
*/
|
||||||
|
await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: userId.getValue(),
|
||||||
|
email: emailVO.getValue(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new User(userId, emailVO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Application importing concrete email service from infrastructure
|
||||||
|
*/
|
||||||
|
import { SmtpEmailService } from "../../good-architecture/infrastructure/adapters/SmtpEmailService"
|
||||||
|
|
||||||
|
export class SendWelcomeEmail {
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Application depending on concrete infrastructure implementation
|
||||||
|
* Should depend on IEmailService interface instead
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private prisma: PrismaClient,
|
||||||
|
private emailService: SmtpEmailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(userId: string): Promise<void> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.emailService.sendWelcomeEmail(user.email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Application importing from infrastructure controller
|
||||||
|
*/
|
||||||
|
import { UserController } from "../../good-architecture/infrastructure/controllers/UserController"
|
||||||
|
|
||||||
|
export class ValidateUser {
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Application use case depending on infrastructure controller
|
||||||
|
* The dependency direction is completely wrong!
|
||||||
|
*/
|
||||||
|
constructor(private userController: UserController) {}
|
||||||
|
|
||||||
|
async execute(userId: string): Promise<boolean> {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Application importing HTTP framework
|
||||||
|
*/
|
||||||
|
import express from "express"
|
||||||
|
|
||||||
|
export class ProcessUserRequest {
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Application layer knows about HTTP/Express
|
||||||
|
* HTTP concerns should be in infrastructure layer
|
||||||
|
*/
|
||||||
|
async execute(req: express.Request): Promise<void> {
|
||||||
|
const email = req.body.email
|
||||||
|
console.log(`Processing user: ${email}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Application importing infrastructure repository implementation
|
||||||
|
*/
|
||||||
|
import { InMemoryUserRepository } from "../../good-architecture/infrastructure/repositories/InMemoryUserRepository"
|
||||||
|
|
||||||
|
export class GetUser {
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Application depending on concrete repository implementation
|
||||||
|
* Should depend on IUserRepository interface from domain
|
||||||
|
*/
|
||||||
|
constructor(private userRepo: InMemoryUserRepository) {}
|
||||||
|
|
||||||
|
async execute(userId: string): Promise<User | null> {
|
||||||
|
return await this.userRepo.findById(UserId.from(userId))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* ❌ BAD: Domain layer with incorrect dependencies
|
||||||
|
*
|
||||||
|
* Domain importing from Application and Infrastructure layers
|
||||||
|
* This violates Clean Architecture dependency rules!
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Email } from "../../good-architecture/domain/value-objects/Email"
|
||||||
|
import { UserId } from "../../good-architecture/domain/value-objects/UserId"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Domain importing from Application layer
|
||||||
|
*/
|
||||||
|
import { UserResponseDto } from "../../good-architecture/application/dtos/UserResponseDto"
|
||||||
|
|
||||||
|
export class User {
|
||||||
|
private readonly id: UserId
|
||||||
|
private email: Email
|
||||||
|
|
||||||
|
constructor(id: UserId, email: Email) {
|
||||||
|
this.id = id
|
||||||
|
this.email = email
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Domain entity returning DTO from application layer
|
||||||
|
*/
|
||||||
|
toDto(): UserResponseDto {
|
||||||
|
return {
|
||||||
|
id: this.id.getValue(),
|
||||||
|
email: this.email.getValue(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Domain importing from Infrastructure layer
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from "@prisma/client"
|
||||||
|
|
||||||
|
export class UserService {
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Domain service depending on concrete infrastructure implementation
|
||||||
|
*/
|
||||||
|
constructor(private prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async createUser(email: string): Promise<User> {
|
||||||
|
const userId = UserId.generate()
|
||||||
|
const emailVO = Email.create(email).value
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Domain logic directly accessing database
|
||||||
|
*/
|
||||||
|
await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: userId.getValue(),
|
||||||
|
email: emailVO.getValue(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new User(userId, emailVO)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Domain importing email service from infrastructure
|
||||||
|
*/
|
||||||
|
import { SmtpEmailService } from "../../good-architecture/infrastructure/adapters/SmtpEmailService"
|
||||||
|
|
||||||
|
export class UserRegistration {
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Domain depending on infrastructure email service
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private userService: UserService,
|
||||||
|
private emailService: SmtpEmailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(email: string): Promise<User> {
|
||||||
|
const user = await this.userService.createUser(email)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ❌ VIOLATION: Domain calling infrastructure service directly
|
||||||
|
*/
|
||||||
|
await this.emailService.sendWelcomeEmail(email)
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,91 @@
|
|||||||
|
/**
|
||||||
|
* ✅ GOOD: Application layer with correct dependencies
|
||||||
|
*
|
||||||
|
* Application should only import from:
|
||||||
|
* - Domain layer
|
||||||
|
* - Other application files
|
||||||
|
* - Shared utilities
|
||||||
|
*
|
||||||
|
* Application should NOT import from:
|
||||||
|
* - Infrastructure layer
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { User } from "../domain/entities/User"
|
||||||
|
import { Email } from "../domain/value-objects/Email"
|
||||||
|
import { UserId } from "../domain/value-objects/UserId"
|
||||||
|
import { IUserRepository } from "../domain/repositories/IUserRepository"
|
||||||
|
import { Result } from "../../../src/shared/types/Result"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Use case depends on domain interfaces (IUserRepository)
|
||||||
|
* NOT on infrastructure implementations
|
||||||
|
*/
|
||||||
|
export class CreateUser {
|
||||||
|
constructor(private readonly userRepo: IUserRepository) {}
|
||||||
|
|
||||||
|
async execute(request: CreateUserRequest): Promise<Result<UserResponseDto>> {
|
||||||
|
const emailResult = Email.create(request.email)
|
||||||
|
if (emailResult.isFailure) {
|
||||||
|
return Result.fail(emailResult.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = UserId.generate()
|
||||||
|
const user = new User(userId, emailResult.value)
|
||||||
|
|
||||||
|
await this.userRepo.save(user)
|
||||||
|
|
||||||
|
return Result.ok(UserMapper.toDto(user))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ DTO in application layer
|
||||||
|
*/
|
||||||
|
export interface CreateUserRequest {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserResponseDto {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Mapper in application layer converting domain to DTO
|
||||||
|
*/
|
||||||
|
export class UserMapper {
|
||||||
|
static toDto(user: User): UserResponseDto {
|
||||||
|
return {
|
||||||
|
id: user.getId().getValue(),
|
||||||
|
email: user.getEmail().getValue(),
|
||||||
|
createdAt: user.getCreatedAt().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Application defines Port (interface) for email service
|
||||||
|
* Infrastructure will provide the Adapter (implementation)
|
||||||
|
*/
|
||||||
|
export interface IEmailService {
|
||||||
|
sendWelcomeEmail(email: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SendWelcomeEmail {
|
||||||
|
constructor(
|
||||||
|
private readonly userRepo: IUserRepository,
|
||||||
|
private readonly emailService: IEmailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(userId: string): Promise<Result<void>> {
|
||||||
|
const user = await this.userRepo.findById(UserId.from(userId))
|
||||||
|
if (!user) {
|
||||||
|
return Result.fail("User not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.emailService.sendWelcomeEmail(user.getEmail().getValue())
|
||||||
|
return Result.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* ✅ GOOD: Domain layer with correct dependencies
|
||||||
|
*
|
||||||
|
* Domain should only import from:
|
||||||
|
* - Other domain files
|
||||||
|
* - Shared utilities
|
||||||
|
*
|
||||||
|
* Domain should NOT import from:
|
||||||
|
* - Application layer
|
||||||
|
* - Infrastructure layer
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Email } from "../domain/value-objects/Email"
|
||||||
|
import { UserId } from "../domain/value-objects/UserId"
|
||||||
|
import { Result } from "../../../src/shared/types/Result"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Domain entity using only domain value objects and shared types
|
||||||
|
*/
|
||||||
|
export class User {
|
||||||
|
private readonly id: UserId
|
||||||
|
private email: Email
|
||||||
|
private readonly createdAt: Date
|
||||||
|
|
||||||
|
constructor(id: UserId, email: Email, createdAt: Date = new Date()) {
|
||||||
|
this.id = id
|
||||||
|
this.email = email
|
||||||
|
this.createdAt = createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
public getId(): UserId {
|
||||||
|
return this.id
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEmail(): Email {
|
||||||
|
return this.email
|
||||||
|
}
|
||||||
|
|
||||||
|
public changeEmail(newEmail: Email): Result<void> {
|
||||||
|
if (this.email.equals(newEmail)) {
|
||||||
|
return Result.fail("Email is the same")
|
||||||
|
}
|
||||||
|
|
||||||
|
this.email = newEmail
|
||||||
|
return Result.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Domain repository interface (not importing from infrastructure)
|
||||||
|
*/
|
||||||
|
export interface IUserRepository {
|
||||||
|
findById(id: UserId): Promise<User | null>
|
||||||
|
save(user: User): Promise<void>
|
||||||
|
delete(id: UserId): Promise<void>
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* ✅ GOOD: Infrastructure layer with correct dependencies
|
||||||
|
*
|
||||||
|
* Infrastructure CAN import from:
|
||||||
|
* - Domain layer
|
||||||
|
* - Application layer
|
||||||
|
* - Other infrastructure files
|
||||||
|
* - Shared utilities
|
||||||
|
* - External libraries (ORM, frameworks, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { User } from "../domain/entities/User"
|
||||||
|
import { UserId } from "../domain/value-objects/UserId"
|
||||||
|
import { Email } from "../domain/value-objects/Email"
|
||||||
|
import { IUserRepository } from "../domain/repositories/IUserRepository"
|
||||||
|
import { CreateUser } from "../application/use-cases/CreateUser"
|
||||||
|
import { UserResponseDto } from "../application/dtos/UserResponseDto"
|
||||||
|
import { IEmailService } from "../application/ports/IEmailService"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Infrastructure implements domain interface
|
||||||
|
*/
|
||||||
|
export class InMemoryUserRepository implements IUserRepository {
|
||||||
|
private users: Map<string, User> = new Map()
|
||||||
|
|
||||||
|
async findById(id: UserId): Promise<User | null> {
|
||||||
|
return this.users.get(id.getValue()) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(user: User): Promise<void> {
|
||||||
|
this.users.set(user.getId().getValue(), user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: UserId): Promise<void> {
|
||||||
|
this.users.delete(id.getValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Infrastructure provides Adapter implementing application Port
|
||||||
|
*/
|
||||||
|
export class SmtpEmailService implements IEmailService {
|
||||||
|
constructor(
|
||||||
|
private readonly host: string,
|
||||||
|
private readonly port: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async sendWelcomeEmail(email: string): Promise<void> {
|
||||||
|
console.log(`Sending welcome email to ${email} via SMTP`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Controller uses application use cases and DTOs
|
||||||
|
*/
|
||||||
|
export class UserController {
|
||||||
|
constructor(private readonly createUser: CreateUser) {}
|
||||||
|
|
||||||
|
async create(request: { email: string; name: string }): Promise<UserResponseDto> {
|
||||||
|
const result = await this.createUser.execute(request)
|
||||||
|
|
||||||
|
if (result.isFailure) {
|
||||||
|
throw new Error(result.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Infrastructure can use external frameworks
|
||||||
|
*/
|
||||||
|
import express from "express"
|
||||||
|
|
||||||
|
export class ExpressServer {
|
||||||
|
private app = express()
|
||||||
|
|
||||||
|
constructor(private readonly userController: UserController) {
|
||||||
|
this.setupRoutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupRoutes(): void {
|
||||||
|
this.app.post("/users", async (req, res) => {
|
||||||
|
const user = await this.userController.create(req.body)
|
||||||
|
res.json(user)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Infrastructure can use ORM
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from "@prisma/client"
|
||||||
|
|
||||||
|
export class PrismaUserRepository implements IUserRepository {
|
||||||
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async findById(id: UserId): Promise<User | null> {
|
||||||
|
const userData = await this.prisma.user.findUnique({
|
||||||
|
where: { id: id.getValue() },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!userData) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return new User(UserId.from(userData.id), Email.create(userData.email).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(user: User): Promise<void> {
|
||||||
|
await this.prisma.user.upsert({
|
||||||
|
where: { id: user.getId().getValue() },
|
||||||
|
create: {
|
||||||
|
id: user.getId().getValue(),
|
||||||
|
email: user.getEmail().getValue(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
email: user.getEmail().getValue(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: UserId): Promise<void> {
|
||||||
|
await this.prisma.user.delete({
|
||||||
|
where: { id: id.getValue() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
220
packages/guardian/examples/repository-pattern/README.md
Normal file
220
packages/guardian/examples/repository-pattern/README.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# Repository Pattern Examples
|
||||||
|
|
||||||
|
This directory contains examples demonstrating proper and improper implementations of the Repository Pattern.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Repository Pattern provides an abstraction layer between domain logic and data access. A well-implemented repository:
|
||||||
|
|
||||||
|
1. Uses domain types, not ORM-specific types
|
||||||
|
2. Depends on interfaces, not concrete implementations
|
||||||
|
3. Uses dependency injection, not direct instantiation
|
||||||
|
4. Uses domain language, not technical database terms
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### ❌ Bad Examples
|
||||||
|
|
||||||
|
#### 1. ORM Types in Interface
|
||||||
|
**File:** `bad-orm-types-in-interface.ts`
|
||||||
|
|
||||||
|
**Problem:** Repository interface exposes Prisma-specific types (`Prisma.UserWhereInput`, `Prisma.UserCreateInput`). This couples the domain layer to infrastructure concerns.
|
||||||
|
|
||||||
|
**Violations:**
|
||||||
|
- Domain depends on ORM library
|
||||||
|
- Cannot swap ORM without changing domain
|
||||||
|
- Breaks Clean Architecture principles
|
||||||
|
|
||||||
|
#### 2. Concrete Repository in Use Case
|
||||||
|
**File:** `bad-concrete-repository-in-use-case.ts`
|
||||||
|
|
||||||
|
**Problem:** Use case depends on `PrismaUserRepository` instead of `IUserRepository` interface.
|
||||||
|
|
||||||
|
**Violations:**
|
||||||
|
- Violates Dependency Inversion Principle
|
||||||
|
- Cannot easily mock for testing
|
||||||
|
- Tightly coupled to specific implementation
|
||||||
|
|
||||||
|
#### 3. Creating Repository with 'new'
|
||||||
|
**File:** `bad-new-repository.ts`
|
||||||
|
|
||||||
|
**Problem:** Use case instantiates repositories with `new UserRepository()` instead of receiving them through constructor.
|
||||||
|
|
||||||
|
**Violations:**
|
||||||
|
- Violates Dependency Injection principle
|
||||||
|
- Hard to test (cannot mock dependencies)
|
||||||
|
- Hidden dependencies
|
||||||
|
- Creates tight coupling
|
||||||
|
|
||||||
|
#### 4. Technical Method Names
|
||||||
|
**File:** `bad-technical-method-names.ts`
|
||||||
|
|
||||||
|
**Problem:** Repository methods use database/SQL terminology (`findOne`, `insert`, `query`, `execute`).
|
||||||
|
|
||||||
|
**Violations:**
|
||||||
|
- Uses technical terms instead of domain language
|
||||||
|
- Exposes implementation details
|
||||||
|
- Not aligned with ubiquitous language
|
||||||
|
|
||||||
|
### ✅ Good Examples
|
||||||
|
|
||||||
|
#### 1. Clean Interface
|
||||||
|
**File:** `good-clean-interface.ts`
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Uses only domain types (UserId, Email, User)
|
||||||
|
- ORM-agnostic interface
|
||||||
|
- Easy to understand and maintain
|
||||||
|
- Follows Clean Architecture
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IUserRepository {
|
||||||
|
findById(id: UserId): Promise<User | null>
|
||||||
|
findByEmail(email: Email): Promise<User | null>
|
||||||
|
save(user: User): Promise<void>
|
||||||
|
delete(id: UserId): Promise<void>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Interface in Use Case
|
||||||
|
**File:** `good-interface-in-use-case.ts`
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Depends on interface, not concrete class
|
||||||
|
- Easy to test with mocks
|
||||||
|
- Can swap implementations
|
||||||
|
- Follows Dependency Inversion Principle
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class CreateUser {
|
||||||
|
constructor(private readonly userRepo: IUserRepository) {}
|
||||||
|
|
||||||
|
async execute(data: CreateUserRequest): Promise<UserResponseDto> {
|
||||||
|
// Uses interface, not concrete implementation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Dependency Injection
|
||||||
|
**File:** `good-dependency-injection.ts`
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- All dependencies injected through constructor
|
||||||
|
- Explicit dependencies (no hidden coupling)
|
||||||
|
- Easy to test with mocks
|
||||||
|
- Follows SOLID principles
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class CreateUser {
|
||||||
|
constructor(
|
||||||
|
private readonly userRepo: IUserRepository,
|
||||||
|
private readonly emailService: IEmailService
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Domain Language
|
||||||
|
**File:** `good-domain-language.ts`
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Methods use business-oriented names
|
||||||
|
- Self-documenting interface
|
||||||
|
- Aligns with ubiquitous language
|
||||||
|
- Hides implementation details
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IUserRepository {
|
||||||
|
findById(id: UserId): Promise<User | null>
|
||||||
|
findByEmail(email: Email): Promise<User | null>
|
||||||
|
findActiveUsers(): Promise<User[]>
|
||||||
|
save(user: User): Promise<void>
|
||||||
|
search(criteria: UserSearchCriteria): Promise<User[]>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Principles
|
||||||
|
|
||||||
|
### 1. Persistence Ignorance
|
||||||
|
Domain entities and repositories should not know about how data is persisted.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad: Domain knows about Prisma
|
||||||
|
interface IUserRepository {
|
||||||
|
find(query: Prisma.UserWhereInput): Promise<User>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good: Domain uses own types
|
||||||
|
interface IUserRepository {
|
||||||
|
findById(id: UserId): Promise<User | null>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Dependency Inversion
|
||||||
|
High-level modules (use cases) should not depend on low-level modules (repositories). Both should depend on abstractions (interfaces).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad: Use case depends on concrete repository
|
||||||
|
class CreateUser {
|
||||||
|
constructor(private repo: PrismaUserRepository) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good: Use case depends on interface
|
||||||
|
class CreateUser {
|
||||||
|
constructor(private repo: IUserRepository) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Dependency Injection
|
||||||
|
Don't create dependencies inside classes. Inject them through constructor.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad: Creates dependency
|
||||||
|
class CreateUser {
|
||||||
|
execute() {
|
||||||
|
const repo = new UserRepository()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good: Injects dependency
|
||||||
|
class CreateUser {
|
||||||
|
constructor(private readonly repo: IUserRepository) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Ubiquitous Language
|
||||||
|
Use domain language everywhere, including repository methods.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad: Technical terminology
|
||||||
|
interface IUserRepository {
|
||||||
|
findOne(id: string): Promise<User>
|
||||||
|
insert(user: User): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good: Domain language
|
||||||
|
interface IUserRepository {
|
||||||
|
findById(id: UserId): Promise<User | null>
|
||||||
|
save(user: User): Promise<void>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing with Guardian
|
||||||
|
|
||||||
|
Run Guardian to detect Repository Pattern violations:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
guardian check --root ./examples/repository-pattern
|
||||||
|
```
|
||||||
|
|
||||||
|
Guardian will detect:
|
||||||
|
- ORM types in repository interfaces
|
||||||
|
- Concrete repository usage in use cases
|
||||||
|
- Repository instantiation with 'new'
|
||||||
|
- Technical method names in repositories
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
- [Clean Architecture by Robert C. Martin](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
||||||
|
- [Domain-Driven Design by Eric Evans](https://www.domainlanguage.com/ddd/)
|
||||||
|
- [Repository Pattern - Martin Fowler](https://martinfowler.com/eaaCatalog/repository.html)
|
||||||
|
- [SOLID Principles](https://en.wikipedia.org/wiki/SOLID)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* ❌ BAD EXAMPLE: Concrete repository in use case
|
||||||
|
*
|
||||||
|
* Use case depends on concrete repository implementation instead of interface.
|
||||||
|
* This violates Dependency Inversion Principle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class CreateUser {
|
||||||
|
constructor(private userRepo: PrismaUserRepository) {}
|
||||||
|
|
||||||
|
async execute(data: CreateUserRequest): Promise<UserResponseDto> {
|
||||||
|
const user = User.create(data.email, data.name)
|
||||||
|
await this.userRepo.save(user)
|
||||||
|
return UserMapper.toDto(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PrismaUserRepository {
|
||||||
|
constructor(private prisma: any) {}
|
||||||
|
|
||||||
|
async save(user: User): Promise<void> {
|
||||||
|
await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: user.getEmail(),
|
||||||
|
name: user.getName(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class User {
|
||||||
|
static create(email: string, name: string): User {
|
||||||
|
return new User(email, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private email: string,
|
||||||
|
private name: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getEmail(): string {
|
||||||
|
return this.email
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return this.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateUserRequest {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserResponseDto {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserMapper {
|
||||||
|
static toDto(user: User): UserResponseDto {
|
||||||
|
return {
|
||||||
|
email: user.getEmail(),
|
||||||
|
name: user.getName(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* ❌ BAD EXAMPLE: Creating repository with 'new' in use case
|
||||||
|
*
|
||||||
|
* Use case creates repository instances directly.
|
||||||
|
* This violates Dependency Injection principle and makes testing difficult.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class CreateUser {
|
||||||
|
async execute(data: CreateUserRequest): Promise<UserResponseDto> {
|
||||||
|
const userRepo = new UserRepository()
|
||||||
|
const emailService = new EmailService()
|
||||||
|
|
||||||
|
const user = User.create(data.email, data.name)
|
||||||
|
await userRepo.save(user)
|
||||||
|
await emailService.sendWelcomeEmail(user.getEmail())
|
||||||
|
|
||||||
|
return UserMapper.toDto(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserRepository {
|
||||||
|
async save(user: User): Promise<void> {
|
||||||
|
console.warn("Saving user to database")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmailService {
|
||||||
|
async sendWelcomeEmail(email: string): Promise<void> {
|
||||||
|
console.warn(`Sending welcome email to ${email}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class User {
|
||||||
|
static create(email: string, name: string): User {
|
||||||
|
return new User(email, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private email: string,
|
||||||
|
private name: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getEmail(): string {
|
||||||
|
return this.email
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return this.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateUserRequest {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserResponseDto {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserMapper {
|
||||||
|
static toDto(user: User): UserResponseDto {
|
||||||
|
return {
|
||||||
|
email: user.getEmail(),
|
||||||
|
name: user.getName(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* ❌ BAD EXAMPLE: ORM-specific types in repository interface
|
||||||
|
*
|
||||||
|
* This violates Repository Pattern by coupling domain layer to infrastructure (ORM).
|
||||||
|
* Domain should remain persistence-agnostic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Prisma, PrismaClient } from "@prisma/client"
|
||||||
|
|
||||||
|
interface IUserRepository {
|
||||||
|
findOne(query: Prisma.UserWhereInput): Promise<User | null>
|
||||||
|
|
||||||
|
findMany(query: Prisma.UserFindManyArgs): Promise<User[]>
|
||||||
|
|
||||||
|
create(data: Prisma.UserCreateInput): Promise<User>
|
||||||
|
|
||||||
|
update(id: string, data: Prisma.UserUpdateInput): Promise<User>
|
||||||
|
}
|
||||||
|
|
||||||
|
class User {
|
||||||
|
constructor(
|
||||||
|
public id: string,
|
||||||
|
public email: string,
|
||||||
|
public name: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* ❌ BAD EXAMPLE: Technical method names
|
||||||
|
*
|
||||||
|
* Repository interface uses database/ORM terminology instead of domain language.
|
||||||
|
* Methods should reflect business operations, not technical implementation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface IUserRepository {
|
||||||
|
findOne(id: string): Promise<User | null>
|
||||||
|
|
||||||
|
findMany(filter: any): Promise<User[]>
|
||||||
|
|
||||||
|
insert(user: User): Promise<void>
|
||||||
|
|
||||||
|
updateOne(id: string, data: any): Promise<void>
|
||||||
|
|
||||||
|
deleteOne(id: string): Promise<void>
|
||||||
|
|
||||||
|
query(sql: string): Promise<any>
|
||||||
|
|
||||||
|
execute(command: string): Promise<void>
|
||||||
|
|
||||||
|
select(fields: string[]): Promise<User[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
class User {
|
||||||
|
constructor(
|
||||||
|
public id: string,
|
||||||
|
public email: string,
|
||||||
|
public name: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* ✅ GOOD EXAMPLE: Clean repository interface
|
||||||
|
*
|
||||||
|
* Repository interface uses only domain types, keeping it persistence-agnostic.
|
||||||
|
* ORM implementation details stay in infrastructure layer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface IUserRepository {
|
||||||
|
findById(id: UserId): Promise<User | null>
|
||||||
|
|
||||||
|
findByEmail(email: Email): Promise<User | null>
|
||||||
|
|
||||||
|
save(user: User): Promise<void>
|
||||||
|
|
||||||
|
delete(id: UserId): Promise<void>
|
||||||
|
|
||||||
|
findAll(criteria: UserSearchCriteria): Promise<User[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserId {
|
||||||
|
constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
getValue(): string {
|
||||||
|
return this.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Email {
|
||||||
|
constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
getValue(): string {
|
||||||
|
return this.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserSearchCriteria {
|
||||||
|
constructor(
|
||||||
|
public readonly isActive?: boolean,
|
||||||
|
public readonly registeredAfter?: Date,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class User {
|
||||||
|
constructor(
|
||||||
|
private readonly id: UserId,
|
||||||
|
private email: Email,
|
||||||
|
private name: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getId(): UserId {
|
||||||
|
return this.id
|
||||||
|
}
|
||||||
|
|
||||||
|
getEmail(): Email {
|
||||||
|
return this.email
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return this.name
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* ✅ GOOD EXAMPLE: Dependency Injection
|
||||||
|
*
|
||||||
|
* Use case receives dependencies through constructor.
|
||||||
|
* This makes code testable and follows SOLID principles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class CreateUser {
|
||||||
|
constructor(
|
||||||
|
private readonly userRepo: IUserRepository,
|
||||||
|
private readonly emailService: IEmailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(data: CreateUserRequest): Promise<UserResponseDto> {
|
||||||
|
const user = User.create(Email.from(data.email), data.name)
|
||||||
|
await this.userRepo.save(user)
|
||||||
|
await this.emailService.sendWelcomeEmail(user.getEmail())
|
||||||
|
return UserMapper.toDto(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IUserRepository {
|
||||||
|
save(user: User): Promise<void>
|
||||||
|
findByEmail(email: Email): Promise<User | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IEmailService {
|
||||||
|
sendWelcomeEmail(email: Email): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
class Email {
|
||||||
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
static from(value: string): Email {
|
||||||
|
if (!value.includes("@")) {
|
||||||
|
throw new Error("Invalid email")
|
||||||
|
}
|
||||||
|
return new Email(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
getValue(): string {
|
||||||
|
return this.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class User {
|
||||||
|
static create(email: Email, name: string): User {
|
||||||
|
return new User(email, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
private readonly email: Email,
|
||||||
|
private readonly name: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getEmail(): Email {
|
||||||
|
return this.email
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return this.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateUserRequest {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserResponseDto {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserMapper {
|
||||||
|
static toDto(user: User): UserResponseDto {
|
||||||
|
return {
|
||||||
|
email: user.getEmail().getValue(),
|
||||||
|
name: user.getName(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserRepository implements IUserRepository {
|
||||||
|
async save(_user: User): Promise<void> {
|
||||||
|
console.warn("Saving user to database")
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByEmail(_email: Email): Promise<User | null> {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmailService implements IEmailService {
|
||||||
|
async sendWelcomeEmail(email: Email): Promise<void> {
|
||||||
|
console.warn(`Sending welcome email to ${email.getValue()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* ✅ GOOD EXAMPLE: Domain language in repository
|
||||||
|
*
|
||||||
|
* Repository interface uses domain-driven method names that reflect business operations.
|
||||||
|
* Method names are self-documenting and align with ubiquitous language.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface IUserRepository {
|
||||||
|
findById(id: UserId): Promise<User | null>
|
||||||
|
|
||||||
|
findByEmail(email: Email): Promise<User | null>
|
||||||
|
|
||||||
|
findActiveUsers(): Promise<User[]>
|
||||||
|
|
||||||
|
save(user: User): Promise<void>
|
||||||
|
|
||||||
|
delete(id: UserId): Promise<void>
|
||||||
|
|
||||||
|
search(criteria: UserSearchCriteria): Promise<User[]>
|
||||||
|
|
||||||
|
countActiveUsers(): Promise<number>
|
||||||
|
|
||||||
|
existsByEmail(email: Email): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserId {
|
||||||
|
constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
getValue(): string {
|
||||||
|
return this.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Email {
|
||||||
|
constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
getValue(): string {
|
||||||
|
return this.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserSearchCriteria {
|
||||||
|
constructor(
|
||||||
|
public readonly isActive?: boolean,
|
||||||
|
public readonly registeredAfter?: Date,
|
||||||
|
public readonly department?: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class User {
|
||||||
|
constructor(
|
||||||
|
private readonly id: UserId,
|
||||||
|
private email: Email,
|
||||||
|
private name: string,
|
||||||
|
private isActive: boolean,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getId(): UserId {
|
||||||
|
return this.id
|
||||||
|
}
|
||||||
|
|
||||||
|
getEmail(): Email {
|
||||||
|
return this.email
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return this.name
|
||||||
|
}
|
||||||
|
|
||||||
|
isUserActive(): boolean {
|
||||||
|
return this.isActive
|
||||||
|
}
|
||||||
|
|
||||||
|
activate(): void {
|
||||||
|
this.isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate(): void {
|
||||||
|
this.isActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* ✅ GOOD EXAMPLE: Repository interface in use case
|
||||||
|
*
|
||||||
|
* Use case depends on repository interface, not concrete implementation.
|
||||||
|
* This follows Dependency Inversion Principle.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class CreateUser {
|
||||||
|
constructor(private readonly userRepo: IUserRepository) {}
|
||||||
|
|
||||||
|
async execute(data: CreateUserRequest): Promise<UserResponseDto> {
|
||||||
|
const user = User.create(Email.from(data.email), data.name)
|
||||||
|
await this.userRepo.save(user)
|
||||||
|
return UserMapper.toDto(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IUserRepository {
|
||||||
|
save(user: User): Promise<void>
|
||||||
|
findByEmail(email: Email): Promise<User | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
class Email {
|
||||||
|
private constructor(private readonly value: string) {}
|
||||||
|
|
||||||
|
static from(value: string): Email {
|
||||||
|
if (!value.includes("@")) {
|
||||||
|
throw new Error("Invalid email")
|
||||||
|
}
|
||||||
|
return new Email(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
getValue(): string {
|
||||||
|
return this.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class User {
|
||||||
|
static create(email: Email, name: string): User {
|
||||||
|
return new User(email, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
private readonly email: Email,
|
||||||
|
private readonly name: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
getEmail(): Email {
|
||||||
|
return this.email
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(): string {
|
||||||
|
return this.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateUserRequest {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserResponseDto {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserMapper {
|
||||||
|
static toDto(user: User): UserResponseDto {
|
||||||
|
return {
|
||||||
|
email: user.getEmail().getValue(),
|
||||||
|
name: user.getName(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@samiyev/guardian",
|
"name": "@samiyev/guardian",
|
||||||
"version": "0.2.0",
|
"version": "0.5.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,17 @@ 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 { IDependencyDirectionDetector } from "./domain/services/IDependencyDirectionDetector"
|
||||||
|
import { IRepositoryPatternDetector } from "./domain/services/RepositoryPatternDetectorService"
|
||||||
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 { DependencyDirectionDetector } from "./infrastructure/analyzers/DependencyDirectionDetector"
|
||||||
|
import { RepositoryPatternDetector } from "./infrastructure/analyzers/RepositoryPatternDetector"
|
||||||
import { ERROR_MESSAGES } from "./shared/constants"
|
import { ERROR_MESSAGES } from "./shared/constants"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,12 +72,19 @@ 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 dependencyDirectionDetector: IDependencyDirectionDetector =
|
||||||
|
new DependencyDirectionDetector()
|
||||||
|
const repositoryPatternDetector: IRepositoryPatternDetector = new RepositoryPatternDetector()
|
||||||
const useCase = new AnalyzeProject(
|
const useCase = new AnalyzeProject(
|
||||||
fileScanner,
|
fileScanner,
|
||||||
codeParser,
|
codeParser,
|
||||||
hardcodeDetector,
|
hardcodeDetector,
|
||||||
namingConventionDetector,
|
namingConventionDetector,
|
||||||
frameworkLeakDetector,
|
frameworkLeakDetector,
|
||||||
|
entityExposureDetector,
|
||||||
|
dependencyDirectionDetector,
|
||||||
|
repositoryPatternDetector,
|
||||||
)
|
)
|
||||||
|
|
||||||
const result = await useCase.execute(options)
|
const result = await useCase.execute(options)
|
||||||
@@ -91,5 +104,8 @@ export type {
|
|||||||
CircularDependencyViolation,
|
CircularDependencyViolation,
|
||||||
NamingConventionViolation,
|
NamingConventionViolation,
|
||||||
FrameworkLeakViolation,
|
FrameworkLeakViolation,
|
||||||
|
EntityExposureViolation,
|
||||||
|
DependencyDirectionViolation,
|
||||||
|
RepositoryPatternViolation,
|
||||||
ProjectMetrics,
|
ProjectMetrics,
|
||||||
} from "./application/use-cases/AnalyzeProject"
|
} from "./application/use-cases/AnalyzeProject"
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ 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 { IDependencyDirectionDetector } from "../../domain/services/IDependencyDirectionDetector"
|
||||||
|
import { IRepositoryPatternDetector } from "../../domain/services/RepositoryPatternDetectorService"
|
||||||
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"
|
||||||
@@ -14,6 +17,7 @@ import {
|
|||||||
LAYERS,
|
LAYERS,
|
||||||
NAMING_VIOLATION_TYPES,
|
NAMING_VIOLATION_TYPES,
|
||||||
REGEX_PATTERNS,
|
REGEX_PATTERNS,
|
||||||
|
REPOSITORY_VIOLATION_TYPES,
|
||||||
RULES,
|
RULES,
|
||||||
SEVERITY_LEVELS,
|
SEVERITY_LEVELS,
|
||||||
} from "../../shared/constants"
|
} from "../../shared/constants"
|
||||||
@@ -32,6 +36,9 @@ export interface AnalyzeProjectResponse {
|
|||||||
circularDependencyViolations: CircularDependencyViolation[]
|
circularDependencyViolations: CircularDependencyViolation[]
|
||||||
namingViolations: NamingConventionViolation[]
|
namingViolations: NamingConventionViolation[]
|
||||||
frameworkLeakViolations: FrameworkLeakViolation[]
|
frameworkLeakViolations: FrameworkLeakViolation[]
|
||||||
|
entityExposureViolations: EntityExposureViolation[]
|
||||||
|
dependencyDirectionViolations: DependencyDirectionViolation[]
|
||||||
|
repositoryPatternViolations: RepositoryPatternViolation[]
|
||||||
metrics: ProjectMetrics
|
metrics: ProjectMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +102,44 @@ 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 DependencyDirectionViolation {
|
||||||
|
rule: typeof RULES.DEPENDENCY_DIRECTION
|
||||||
|
fromLayer: string
|
||||||
|
toLayer: string
|
||||||
|
importPath: string
|
||||||
|
file: string
|
||||||
|
line?: number
|
||||||
|
message: string
|
||||||
|
suggestion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepositoryPatternViolation {
|
||||||
|
rule: typeof RULES.REPOSITORY_PATTERN
|
||||||
|
violationType:
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME
|
||||||
|
file: string
|
||||||
|
layer: string
|
||||||
|
line?: number
|
||||||
|
details: string
|
||||||
|
message: string
|
||||||
|
suggestion: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectMetrics {
|
export interface ProjectMetrics {
|
||||||
totalFiles: number
|
totalFiles: number
|
||||||
totalFunctions: number
|
totalFunctions: number
|
||||||
@@ -115,6 +160,9 @@ 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,
|
||||||
|
private readonly dependencyDirectionDetector: IDependencyDirectionDetector,
|
||||||
|
private readonly repositoryPatternDetector: IRepositoryPatternDetector,
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
@@ -164,6 +212,9 @@ 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 dependencyDirectionViolations = this.detectDependencyDirections(sourceFiles)
|
||||||
|
const repositoryPatternViolations = this.detectRepositoryPatternViolations(sourceFiles)
|
||||||
const metrics = this.calculateMetrics(sourceFiles, totalFunctions, dependencyGraph)
|
const metrics = this.calculateMetrics(sourceFiles, totalFunctions, dependencyGraph)
|
||||||
|
|
||||||
return ResponseDto.ok({
|
return ResponseDto.ok({
|
||||||
@@ -174,6 +225,9 @@ export class AnalyzeProject extends UseCase<
|
|||||||
circularDependencyViolations,
|
circularDependencyViolations,
|
||||||
namingViolations,
|
namingViolations,
|
||||||
frameworkLeakViolations,
|
frameworkLeakViolations,
|
||||||
|
entityExposureViolations,
|
||||||
|
dependencyDirectionViolations,
|
||||||
|
repositoryPatternViolations,
|
||||||
metrics,
|
metrics,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -364,6 +418,94 @@ 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 detectDependencyDirections(sourceFiles: SourceFile[]): DependencyDirectionViolation[] {
|
||||||
|
const violations: DependencyDirectionViolation[] = []
|
||||||
|
|
||||||
|
for (const file of sourceFiles) {
|
||||||
|
const directionViolations = this.dependencyDirectionDetector.detectViolations(
|
||||||
|
file.content,
|
||||||
|
file.path.relative,
|
||||||
|
file.layer,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const violation of directionViolations) {
|
||||||
|
violations.push({
|
||||||
|
rule: RULES.DEPENDENCY_DIRECTION,
|
||||||
|
fromLayer: violation.fromLayer,
|
||||||
|
toLayer: violation.toLayer,
|
||||||
|
importPath: violation.importPath,
|
||||||
|
file: file.path.relative,
|
||||||
|
line: violation.line,
|
||||||
|
message: violation.getMessage(),
|
||||||
|
suggestion: violation.getSuggestion(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectRepositoryPatternViolations(
|
||||||
|
sourceFiles: SourceFile[],
|
||||||
|
): RepositoryPatternViolation[] {
|
||||||
|
const violations: RepositoryPatternViolation[] = []
|
||||||
|
|
||||||
|
for (const file of sourceFiles) {
|
||||||
|
const patternViolations = this.repositoryPatternDetector.detectViolations(
|
||||||
|
file.content,
|
||||||
|
file.path.relative,
|
||||||
|
file.layer,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const violation of patternViolations) {
|
||||||
|
violations.push({
|
||||||
|
rule: RULES.REPOSITORY_PATTERN,
|
||||||
|
violationType: violation.violationType as
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
|
||||||
|
file: file.path.relative,
|
||||||
|
layer: violation.layer,
|
||||||
|
line: violation.line,
|
||||||
|
details: violation.details,
|
||||||
|
message: violation.getMessage(),
|
||||||
|
suggestion: violation.getSuggestion(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
private calculateMetrics(
|
private calculateMetrics(
|
||||||
sourceFiles: SourceFile[],
|
sourceFiles: SourceFile[],
|
||||||
totalFunctions: number,
|
totalFunctions: number,
|
||||||
|
|||||||
@@ -33,7 +33,20 @@ export const CLI_ARGUMENTS = {
|
|||||||
PATH: "<path>",
|
PATH: "<path>",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const DEFAULT_EXCLUDES = ["node_modules", "dist", "build", "coverage"] as const
|
export const DEFAULT_EXCLUDES = [
|
||||||
|
"node_modules",
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"coverage",
|
||||||
|
"tests",
|
||||||
|
"test",
|
||||||
|
"__tests__",
|
||||||
|
"examples",
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/*.test.js",
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"**/*.spec.js",
|
||||||
|
] as const
|
||||||
|
|
||||||
export const CLI_MESSAGES = {
|
export const CLI_MESSAGES = {
|
||||||
ANALYZING: "\n🛡️ Guardian - Analyzing your code...\n",
|
ANALYZING: "\n🛡️ Guardian - Analyzing your code...\n",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ export * from "./value-objects/ValueObject"
|
|||||||
export * from "./value-objects/ProjectPath"
|
export * from "./value-objects/ProjectPath"
|
||||||
export * from "./value-objects/HardcodedValue"
|
export * from "./value-objects/HardcodedValue"
|
||||||
export * from "./value-objects/NamingViolation"
|
export * from "./value-objects/NamingViolation"
|
||||||
|
export * from "./value-objects/RepositoryViolation"
|
||||||
export * from "./repositories/IBaseRepository"
|
export * from "./repositories/IBaseRepository"
|
||||||
export * from "./services/IFileScanner"
|
export * from "./services/IFileScanner"
|
||||||
export * from "./services/ICodeParser"
|
export * from "./services/ICodeParser"
|
||||||
export * from "./services/IHardcodeDetector"
|
export * from "./services/IHardcodeDetector"
|
||||||
export * from "./services/INamingConventionDetector"
|
export * from "./services/INamingConventionDetector"
|
||||||
|
export * from "./services/RepositoryPatternDetectorService"
|
||||||
export * from "./events/DomainEvent"
|
export * from "./events/DomainEvent"
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { DependencyViolation } from "../value-objects/DependencyViolation"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for detecting dependency direction violations in the codebase
|
||||||
|
*
|
||||||
|
* Dependency direction violations occur when a layer imports from a layer
|
||||||
|
* that it should not depend on according to Clean Architecture principles:
|
||||||
|
* - Domain should not import from Application or Infrastructure
|
||||||
|
* - Application should not import from Infrastructure
|
||||||
|
* - Infrastructure can import from Application and Domain
|
||||||
|
* - Shared can be imported by all layers
|
||||||
|
*/
|
||||||
|
export interface IDependencyDirectionDetector {
|
||||||
|
/**
|
||||||
|
* Detects dependency direction violations in the given code
|
||||||
|
*
|
||||||
|
* Analyzes import statements to identify violations of dependency rules
|
||||||
|
* between architectural layers.
|
||||||
|
*
|
||||||
|
* @param code - Source code to analyze
|
||||||
|
* @param filePath - Path to the file being analyzed
|
||||||
|
* @param layer - The architectural layer of the file (domain, application, infrastructure, shared)
|
||||||
|
* @returns Array of detected dependency direction violations
|
||||||
|
*/
|
||||||
|
detectViolations(
|
||||||
|
code: string,
|
||||||
|
filePath: string,
|
||||||
|
layer: string | undefined,
|
||||||
|
): DependencyViolation[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an import violates dependency direction rules
|
||||||
|
*
|
||||||
|
* @param fromLayer - The layer that is importing
|
||||||
|
* @param toLayer - The layer being imported
|
||||||
|
* @returns True if the import violates dependency rules
|
||||||
|
*/
|
||||||
|
isViolation(fromLayer: string, toLayer: string): boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the layer from an import path
|
||||||
|
*
|
||||||
|
* @param importPath - The import path to analyze
|
||||||
|
* @returns The layer name if detected, undefined otherwise
|
||||||
|
*/
|
||||||
|
extractLayerFromImport(importPath: string): string | undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { RepositoryViolation } from "../value-objects/RepositoryViolation"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for detecting Repository Pattern violations in the codebase
|
||||||
|
*
|
||||||
|
* Repository Pattern violations include:
|
||||||
|
* - ORM-specific types in repository interfaces (domain layer)
|
||||||
|
* - Concrete repository usage in use cases instead of interfaces
|
||||||
|
* - Repository instantiation with 'new' in use cases (should use DI)
|
||||||
|
* - Non-domain method names in repository interfaces
|
||||||
|
*
|
||||||
|
* The Repository Pattern ensures that domain logic remains decoupled from
|
||||||
|
* infrastructure concerns like databases and ORMs.
|
||||||
|
*/
|
||||||
|
export interface IRepositoryPatternDetector {
|
||||||
|
/**
|
||||||
|
* Detects all Repository Pattern violations in the given code
|
||||||
|
*
|
||||||
|
* Analyzes code for proper implementation of the Repository Pattern,
|
||||||
|
* including interface purity, dependency inversion, and domain language usage.
|
||||||
|
*
|
||||||
|
* @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 Repository Pattern violations
|
||||||
|
*/
|
||||||
|
detectViolations(
|
||||||
|
code: string,
|
||||||
|
filePath: string,
|
||||||
|
layer: string | undefined,
|
||||||
|
): RepositoryViolation[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a type is an ORM-specific type
|
||||||
|
*
|
||||||
|
* ORM-specific types include Prisma types, TypeORM decorators, Mongoose schemas, etc.
|
||||||
|
* These types should not appear in domain repository interfaces.
|
||||||
|
*
|
||||||
|
* @param typeName - The type name to check
|
||||||
|
* @returns True if the type is ORM-specific
|
||||||
|
*/
|
||||||
|
isOrmType(typeName: string): boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a method name follows domain language conventions
|
||||||
|
*
|
||||||
|
* Domain repository methods should use business-oriented names like:
|
||||||
|
* - findById, findByEmail, findByStatus
|
||||||
|
* - save, create, update
|
||||||
|
* - delete, remove
|
||||||
|
*
|
||||||
|
* Avoid technical database terms like:
|
||||||
|
* - findOne, findMany, query
|
||||||
|
* - insert, select, update (SQL terms)
|
||||||
|
*
|
||||||
|
* @param methodName - The method name to check
|
||||||
|
* @returns True if the method name uses domain language
|
||||||
|
*/
|
||||||
|
isDomainMethodName(methodName: string): boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a file is a repository interface
|
||||||
|
*
|
||||||
|
* Repository interfaces typically:
|
||||||
|
* - Are in the domain layer
|
||||||
|
* - Have names matching I*Repository pattern
|
||||||
|
* - Contain interface definitions
|
||||||
|
*
|
||||||
|
* @param filePath - The file path to check
|
||||||
|
* @param layer - The architectural layer
|
||||||
|
* @returns True if the file is a repository interface
|
||||||
|
*/
|
||||||
|
isRepositoryInterface(filePath: string, layer: string | undefined): boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a file is a use case
|
||||||
|
*
|
||||||
|
* Use cases typically:
|
||||||
|
* - Are in the application layer
|
||||||
|
* - Follow verb-noun naming pattern (CreateUser, UpdateProfile)
|
||||||
|
* - Contain class definitions for business operations
|
||||||
|
*
|
||||||
|
* @param filePath - The file path to check
|
||||||
|
* @param layer - The architectural layer
|
||||||
|
* @returns True if the file is a use case
|
||||||
|
*/
|
||||||
|
isUseCase(filePath: string, layer: string | undefined): boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { ValueObject } from "./ValueObject"
|
||||||
|
|
||||||
|
interface DependencyViolationProps {
|
||||||
|
readonly fromLayer: string
|
||||||
|
readonly toLayer: string
|
||||||
|
readonly importPath: string
|
||||||
|
readonly filePath: string
|
||||||
|
readonly line?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a dependency direction violation in the codebase
|
||||||
|
*
|
||||||
|
* Dependency direction violations occur when a layer imports from a layer
|
||||||
|
* that it should not depend on according to Clean Architecture principles:
|
||||||
|
* - Domain → should not import from Application or Infrastructure
|
||||||
|
* - Application → should not import from Infrastructure
|
||||||
|
* - Infrastructure → can import from Application and Domain (allowed)
|
||||||
|
* - Shared → can be imported by all layers (allowed)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Bad: Domain importing from Application
|
||||||
|
* const violation = DependencyViolation.create(
|
||||||
|
* 'domain',
|
||||||
|
* 'application',
|
||||||
|
* '../../application/dtos/UserDto',
|
||||||
|
* 'src/domain/entities/User.ts',
|
||||||
|
* 5
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* console.log(violation.getMessage())
|
||||||
|
* // "Domain layer should not import from Application layer"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class DependencyViolation extends ValueObject<DependencyViolationProps> {
|
||||||
|
private constructor(props: DependencyViolationProps) {
|
||||||
|
super(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static create(
|
||||||
|
fromLayer: string,
|
||||||
|
toLayer: string,
|
||||||
|
importPath: string,
|
||||||
|
filePath: string,
|
||||||
|
line?: number,
|
||||||
|
): DependencyViolation {
|
||||||
|
return new DependencyViolation({
|
||||||
|
fromLayer,
|
||||||
|
toLayer,
|
||||||
|
importPath,
|
||||||
|
filePath,
|
||||||
|
line,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public get fromLayer(): string {
|
||||||
|
return this.props.fromLayer
|
||||||
|
}
|
||||||
|
|
||||||
|
public get toLayer(): string {
|
||||||
|
return this.props.toLayer
|
||||||
|
}
|
||||||
|
|
||||||
|
public get importPath(): string {
|
||||||
|
return this.props.importPath
|
||||||
|
}
|
||||||
|
|
||||||
|
public get filePath(): string {
|
||||||
|
return this.props.filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
public get line(): number | undefined {
|
||||||
|
return this.props.line
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMessage(): string {
|
||||||
|
return `${this.capitalizeFirst(this.props.fromLayer)} layer should not import from ${this.capitalizeFirst(this.props.toLayer)} layer`
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSuggestion(): string {
|
||||||
|
const suggestions: string[] = []
|
||||||
|
|
||||||
|
if (this.props.fromLayer === "domain") {
|
||||||
|
suggestions.push(
|
||||||
|
"Domain layer should be independent and not depend on other layers",
|
||||||
|
"Move the imported code to the domain layer if it contains business logic",
|
||||||
|
"Use dependency inversion: define an interface in domain and implement it in infrastructure",
|
||||||
|
)
|
||||||
|
} else if (this.props.fromLayer === "application") {
|
||||||
|
suggestions.push(
|
||||||
|
"Application layer should not depend on infrastructure",
|
||||||
|
"Define an interface (Port) in application layer",
|
||||||
|
"Implement the interface (Adapter) in infrastructure layer",
|
||||||
|
"Use dependency injection to provide the implementation",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return suggestions.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
public getExampleFix(): string {
|
||||||
|
if (this.props.fromLayer === "domain" && this.props.toLayer === "infrastructure") {
|
||||||
|
return `
|
||||||
|
// ❌ Bad: Domain depends on Infrastructure (PrismaClient)
|
||||||
|
// domain/services/UserService.ts
|
||||||
|
class UserService {
|
||||||
|
constructor(private prisma: PrismaClient) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good: Domain defines interface, Infrastructure implements
|
||||||
|
// domain/repositories/IUserRepository.ts
|
||||||
|
interface IUserRepository {
|
||||||
|
findById(id: UserId): Promise<User | null>
|
||||||
|
save(user: User): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// domain/services/UserService.ts
|
||||||
|
class UserService {
|
||||||
|
constructor(private userRepo: IUserRepository) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// infrastructure/repositories/PrismaUserRepository.ts
|
||||||
|
class PrismaUserRepository implements IUserRepository {
|
||||||
|
constructor(private prisma: PrismaClient) {}
|
||||||
|
async findById(id: UserId): Promise<User | null> { }
|
||||||
|
async save(user: User): Promise<void> { }
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.fromLayer === "application" && this.props.toLayer === "infrastructure") {
|
||||||
|
return `
|
||||||
|
// ❌ Bad: Application depends on Infrastructure (SmtpEmailService)
|
||||||
|
// application/use-cases/SendEmail.ts
|
||||||
|
class SendWelcomeEmail {
|
||||||
|
constructor(private emailService: SmtpEmailService) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Good: Application defines Port, Infrastructure implements Adapter
|
||||||
|
// application/ports/IEmailService.ts
|
||||||
|
interface IEmailService {
|
||||||
|
send(to: string, subject: string, body: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// application/use-cases/SendEmail.ts
|
||||||
|
class SendWelcomeEmail {
|
||||||
|
constructor(private emailService: IEmailService) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// infrastructure/adapters/SmtpEmailService.ts
|
||||||
|
class SmtpEmailService implements IEmailService {
|
||||||
|
async send(to: string, subject: string, body: string): Promise<void> { }
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private capitalizeFirst(str: string): string {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
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,288 @@
|
|||||||
|
import { ValueObject } from "./ValueObject"
|
||||||
|
import { REPOSITORY_VIOLATION_TYPES } from "../../shared/constants/rules"
|
||||||
|
import { REPOSITORY_PATTERN_MESSAGES } from "../constants/Messages"
|
||||||
|
|
||||||
|
interface RepositoryViolationProps {
|
||||||
|
readonly violationType:
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME
|
||||||
|
readonly filePath: string
|
||||||
|
readonly layer: string
|
||||||
|
readonly line?: number
|
||||||
|
readonly details: string
|
||||||
|
readonly ormType?: string
|
||||||
|
readonly repositoryName?: string
|
||||||
|
readonly methodName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Repository Pattern violation in the codebase
|
||||||
|
*
|
||||||
|
* Repository Pattern violations occur when:
|
||||||
|
* 1. Repository interfaces contain ORM-specific types
|
||||||
|
* 2. Use cases depend on concrete repository implementations instead of interfaces
|
||||||
|
* 3. Repositories are instantiated with 'new' in use cases
|
||||||
|
* 4. Repository methods use technical names instead of domain language
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Violation: ORM type in interface
|
||||||
|
* const violation = RepositoryViolation.create(
|
||||||
|
* 'orm-type-in-interface',
|
||||||
|
* 'src/domain/repositories/IUserRepository.ts',
|
||||||
|
* 'domain',
|
||||||
|
* 15,
|
||||||
|
* 'Repository interface uses Prisma-specific type',
|
||||||
|
* 'Prisma.UserWhereInput'
|
||||||
|
* )
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class RepositoryViolation extends ValueObject<RepositoryViolationProps> {
|
||||||
|
private constructor(props: RepositoryViolationProps) {
|
||||||
|
super(props)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static create(
|
||||||
|
violationType:
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE
|
||||||
|
| typeof REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
|
||||||
|
filePath: string,
|
||||||
|
layer: string,
|
||||||
|
line: number | undefined,
|
||||||
|
details: string,
|
||||||
|
ormType?: string,
|
||||||
|
repositoryName?: string,
|
||||||
|
methodName?: string,
|
||||||
|
): RepositoryViolation {
|
||||||
|
return new RepositoryViolation({
|
||||||
|
violationType,
|
||||||
|
filePath,
|
||||||
|
layer,
|
||||||
|
line,
|
||||||
|
details,
|
||||||
|
ormType,
|
||||||
|
repositoryName,
|
||||||
|
methodName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public get violationType(): string {
|
||||||
|
return this.props.violationType
|
||||||
|
}
|
||||||
|
|
||||||
|
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 details(): string {
|
||||||
|
return this.props.details
|
||||||
|
}
|
||||||
|
|
||||||
|
public get ormType(): string | undefined {
|
||||||
|
return this.props.ormType
|
||||||
|
}
|
||||||
|
|
||||||
|
public get repositoryName(): string | undefined {
|
||||||
|
return this.props.repositoryName
|
||||||
|
}
|
||||||
|
|
||||||
|
public get methodName(): string | undefined {
|
||||||
|
return this.props.methodName
|
||||||
|
}
|
||||||
|
|
||||||
|
public getMessage(): string {
|
||||||
|
switch (this.props.violationType) {
|
||||||
|
case REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE:
|
||||||
|
return `Repository interface uses ORM-specific type '${this.props.ormType || "unknown"}'. Domain should not depend on infrastructure concerns.`
|
||||||
|
|
||||||
|
case REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE:
|
||||||
|
return `Use case depends on concrete repository '${this.props.repositoryName || "unknown"}' instead of interface. Use dependency inversion.`
|
||||||
|
|
||||||
|
case REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE:
|
||||||
|
return `Use case creates repository with 'new ${this.props.repositoryName || "Repository"}()'. Use dependency injection instead.`
|
||||||
|
|
||||||
|
case REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME:
|
||||||
|
return `Repository method '${this.props.methodName || "unknown"}' uses technical name. Use domain language instead.`
|
||||||
|
|
||||||
|
default:
|
||||||
|
return `Repository pattern violation: ${this.props.details}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSuggestion(): string {
|
||||||
|
switch (this.props.violationType) {
|
||||||
|
case REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE:
|
||||||
|
return this.getOrmTypeSuggestion()
|
||||||
|
|
||||||
|
case REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE:
|
||||||
|
return this.getConcreteRepositorySuggestion()
|
||||||
|
|
||||||
|
case REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE:
|
||||||
|
return this.getNewRepositorySuggestion()
|
||||||
|
|
||||||
|
case REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME:
|
||||||
|
return this.getNonDomainMethodSuggestion()
|
||||||
|
|
||||||
|
default:
|
||||||
|
return REPOSITORY_PATTERN_MESSAGES.DEFAULT_SUGGESTION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrmTypeSuggestion(): string {
|
||||||
|
return [
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.STEP_REMOVE_ORM_TYPES,
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.STEP_USE_DOMAIN_TYPES,
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.STEP_KEEP_CLEAN,
|
||||||
|
"",
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.EXAMPLE_PREFIX,
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.BAD_ORM_EXAMPLE,
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.GOOD_DOMAIN_EXAMPLE,
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConcreteRepositorySuggestion(): string {
|
||||||
|
return [
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.STEP_DEPEND_ON_INTERFACE,
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.STEP_MOVE_TO_INFRASTRUCTURE,
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.STEP_USE_DI,
|
||||||
|
"",
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.EXAMPLE_PREFIX,
|
||||||
|
`❌ Bad: constructor(private repo: ${this.props.repositoryName || "UserRepository"})`,
|
||||||
|
`✅ Good: constructor(private repo: I${this.props.repositoryName?.replace(/^.*?([A-Z]\w+)$/, "$1") || "UserRepository"})`,
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNewRepositorySuggestion(): string {
|
||||||
|
return [
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.STEP_REMOVE_NEW,
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.STEP_INJECT_CONSTRUCTOR,
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.STEP_CONFIGURE_DI,
|
||||||
|
"",
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.EXAMPLE_PREFIX,
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.BAD_NEW_REPO,
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.GOOD_INJECT_REPO,
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNonDomainMethodSuggestion(): string {
|
||||||
|
const technicalToDomain = {
|
||||||
|
findOne: REPOSITORY_PATTERN_MESSAGES.SUGGESTION_FINDONE,
|
||||||
|
findMany: REPOSITORY_PATTERN_MESSAGES.SUGGESTION_FINDMANY,
|
||||||
|
insert: REPOSITORY_PATTERN_MESSAGES.SUGGESTION_INSERT,
|
||||||
|
update: REPOSITORY_PATTERN_MESSAGES.SUGGESTION_UPDATE,
|
||||||
|
delete: REPOSITORY_PATTERN_MESSAGES.SUGGESTION_DELETE,
|
||||||
|
query: REPOSITORY_PATTERN_MESSAGES.SUGGESTION_QUERY,
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestion =
|
||||||
|
technicalToDomain[this.props.methodName as keyof typeof technicalToDomain]
|
||||||
|
|
||||||
|
return [
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.STEP_RENAME_METHOD,
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.STEP_REFLECT_BUSINESS,
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.STEP_AVOID_TECHNICAL,
|
||||||
|
"",
|
||||||
|
REPOSITORY_PATTERN_MESSAGES.EXAMPLE_PREFIX,
|
||||||
|
`❌ Bad: ${this.props.methodName || "findOne"}()`,
|
||||||
|
`✅ Good: ${suggestion || "findById() or findByEmail()"}`,
|
||||||
|
].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
public getExampleFix(): string {
|
||||||
|
switch (this.props.violationType) {
|
||||||
|
case REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE:
|
||||||
|
return this.getOrmTypeExample()
|
||||||
|
|
||||||
|
case REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE:
|
||||||
|
return this.getConcreteRepositoryExample()
|
||||||
|
|
||||||
|
case REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE:
|
||||||
|
return this.getNewRepositoryExample()
|
||||||
|
|
||||||
|
case REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME:
|
||||||
|
return this.getNonDomainMethodExample()
|
||||||
|
|
||||||
|
default:
|
||||||
|
return REPOSITORY_PATTERN_MESSAGES.NO_EXAMPLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOrmTypeExample(): string {
|
||||||
|
return `
|
||||||
|
// ❌ BAD: ORM-specific interface
|
||||||
|
// domain/repositories/IUserRepository.ts
|
||||||
|
interface IUserRepository {
|
||||||
|
findOne(query: { where: { id: string } }) // Prisma-specific
|
||||||
|
create(data: UserCreateInput) // ORM types in domain
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ GOOD: Clean domain interface
|
||||||
|
interface IUserRepository {
|
||||||
|
findById(id: UserId): Promise<User | null>
|
||||||
|
save(user: User): Promise<void>
|
||||||
|
delete(id: UserId): Promise<void>
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getConcreteRepositoryExample(): string {
|
||||||
|
return `
|
||||||
|
// ❌ BAD: Use Case with concrete implementation
|
||||||
|
class CreateUser {
|
||||||
|
constructor(private prisma: PrismaClient) {} // VIOLATION!
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ GOOD: Use Case with interface
|
||||||
|
class CreateUser {
|
||||||
|
constructor(private userRepo: IUserRepository) {} // OK
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNewRepositoryExample(): string {
|
||||||
|
return `
|
||||||
|
// ❌ BAD: Creating repository in use case
|
||||||
|
class CreateUser {
|
||||||
|
async execute(data: CreateUserRequest) {
|
||||||
|
const repo = new UserRepository() // VIOLATION!
|
||||||
|
await repo.save(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ GOOD: Inject repository via constructor
|
||||||
|
class CreateUser {
|
||||||
|
constructor(private readonly userRepo: IUserRepository) {}
|
||||||
|
|
||||||
|
async execute(data: CreateUserRequest) {
|
||||||
|
await this.userRepo.save(user) // OK
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNonDomainMethodExample(): string {
|
||||||
|
return `
|
||||||
|
// ❌ BAD: Technical method names
|
||||||
|
interface IUserRepository {
|
||||||
|
findOne(id: string) // Database terminology
|
||||||
|
insert(user: User) // SQL terminology
|
||||||
|
query(filter: any) // Technical term
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ GOOD: Domain language
|
||||||
|
interface IUserRepository {
|
||||||
|
findById(id: UserId): Promise<User | null>
|
||||||
|
save(user: User): Promise<void>
|
||||||
|
findByEmail(email: Email): Promise<User | null>
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { IDependencyDirectionDetector } from "../../domain/services/IDependencyDirectionDetector"
|
||||||
|
import { DependencyViolation } from "../../domain/value-objects/DependencyViolation"
|
||||||
|
import { LAYERS } from "../../shared/constants/rules"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects dependency direction violations between architectural layers
|
||||||
|
*
|
||||||
|
* This detector enforces Clean Architecture dependency rules:
|
||||||
|
* - Domain → should not import from Application or Infrastructure
|
||||||
|
* - Application → should not import from Infrastructure
|
||||||
|
* - Infrastructure → can import from Application and Domain (allowed)
|
||||||
|
* - Shared → can be imported by all layers (allowed)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const detector = new DependencyDirectionDetector()
|
||||||
|
*
|
||||||
|
* // Detect violations in domain file
|
||||||
|
* const code = `
|
||||||
|
* import { PrismaClient } from '@prisma/client'
|
||||||
|
* import { UserDto } from '../application/dtos/UserDto'
|
||||||
|
* `
|
||||||
|
* const violations = detector.detectViolations(code, 'src/domain/entities/User.ts', 'domain')
|
||||||
|
*
|
||||||
|
* // violations will contain 1 violation for domain importing from application
|
||||||
|
* console.log(violations.length) // 1
|
||||||
|
* console.log(violations[0].getMessage())
|
||||||
|
* // "Domain layer should not import from Application layer"
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class DependencyDirectionDetector implements IDependencyDirectionDetector {
|
||||||
|
private readonly dependencyRules: Map<string, Set<string>>
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.dependencyRules = new Map([
|
||||||
|
[LAYERS.DOMAIN, new Set([LAYERS.DOMAIN, LAYERS.SHARED])],
|
||||||
|
[LAYERS.APPLICATION, new Set([LAYERS.DOMAIN, LAYERS.APPLICATION, LAYERS.SHARED])],
|
||||||
|
[
|
||||||
|
LAYERS.INFRASTRUCTURE,
|
||||||
|
new Set([LAYERS.DOMAIN, LAYERS.APPLICATION, LAYERS.INFRASTRUCTURE, LAYERS.SHARED]),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
LAYERS.SHARED,
|
||||||
|
new Set([LAYERS.DOMAIN, LAYERS.APPLICATION, LAYERS.INFRASTRUCTURE, LAYERS.SHARED]),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects dependency direction violations in the given code
|
||||||
|
*
|
||||||
|
* Analyzes import statements to identify violations of dependency rules
|
||||||
|
* between architectural layers.
|
||||||
|
*
|
||||||
|
* @param code - Source code to analyze
|
||||||
|
* @param filePath - Path to the file being analyzed
|
||||||
|
* @param layer - The architectural layer of the file (domain, application, infrastructure, shared)
|
||||||
|
* @returns Array of detected dependency direction violations
|
||||||
|
*/
|
||||||
|
public detectViolations(
|
||||||
|
code: string,
|
||||||
|
filePath: string,
|
||||||
|
layer: string | undefined,
|
||||||
|
): DependencyViolation[] {
|
||||||
|
if (!layer || layer === LAYERS.SHARED) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const violations: DependencyViolation[] = []
|
||||||
|
const lines = code.split("\n")
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
const lineNumber = i + 1
|
||||||
|
|
||||||
|
const imports = this.extractImports(line)
|
||||||
|
for (const importPath of imports) {
|
||||||
|
const targetLayer = this.extractLayerFromImport(importPath)
|
||||||
|
|
||||||
|
if (targetLayer && this.isViolation(layer, targetLayer)) {
|
||||||
|
violations.push(
|
||||||
|
DependencyViolation.create(
|
||||||
|
layer,
|
||||||
|
targetLayer,
|
||||||
|
importPath,
|
||||||
|
filePath,
|
||||||
|
lineNumber,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an import violates dependency direction rules
|
||||||
|
*
|
||||||
|
* @param fromLayer - The layer that is importing
|
||||||
|
* @param toLayer - The layer being imported
|
||||||
|
* @returns True if the import violates dependency rules
|
||||||
|
*/
|
||||||
|
public isViolation(fromLayer: string, toLayer: string): boolean {
|
||||||
|
const allowedDependencies = this.dependencyRules.get(fromLayer)
|
||||||
|
|
||||||
|
if (!allowedDependencies) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return !allowedDependencies.has(toLayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the layer from an import path
|
||||||
|
*
|
||||||
|
* @param importPath - The import path to analyze
|
||||||
|
* @returns The layer name if detected, undefined otherwise
|
||||||
|
*/
|
||||||
|
public extractLayerFromImport(importPath: string): string | undefined {
|
||||||
|
const normalizedPath = importPath.replace(/['"]/g, "").toLowerCase()
|
||||||
|
|
||||||
|
const layerPatterns: Array<[string, string]> = [
|
||||||
|
[LAYERS.DOMAIN, "/domain/"],
|
||||||
|
[LAYERS.APPLICATION, "/application/"],
|
||||||
|
[LAYERS.INFRASTRUCTURE, "/infrastructure/"],
|
||||||
|
[LAYERS.SHARED, "/shared/"],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [layer, pattern] of layerPatterns) {
|
||||||
|
if (this.containsLayerPattern(normalizedPath, pattern)) {
|
||||||
|
return layer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the normalized path contains the layer pattern
|
||||||
|
*/
|
||||||
|
private containsLayerPattern(normalizedPath: string, pattern: string): boolean {
|
||||||
|
return (
|
||||||
|
normalizedPath.includes(pattern) ||
|
||||||
|
normalizedPath.includes(`.${pattern}`) ||
|
||||||
|
normalizedPath.includes(`..${pattern}`) ||
|
||||||
|
normalizedPath.includes(`...${pattern}`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts import paths from a line of code
|
||||||
|
*
|
||||||
|
* Handles various import statement formats:
|
||||||
|
* - import { X } from 'path'
|
||||||
|
* - import X from 'path'
|
||||||
|
* - import * as X from 'path'
|
||||||
|
* - const X = require('path')
|
||||||
|
*
|
||||||
|
* @param line - A line of code to analyze
|
||||||
|
* @returns Array of import paths found in the line
|
||||||
|
*/
|
||||||
|
private extractImports(line: string): string[] {
|
||||||
|
const imports: string[] = []
|
||||||
|
|
||||||
|
const esImportRegex =
|
||||||
|
/import\s+(?:{[^}]*}|[\w*]+(?:\s+as\s+\w+)?|\*\s+as\s+\w+)\s+from\s+['"]([^'"]+)['"]/g
|
||||||
|
let match = esImportRegex.exec(line)
|
||||||
|
while (match) {
|
||||||
|
imports.push(match[1])
|
||||||
|
match = esImportRegex.exec(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
const requireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
||||||
|
match = requireRegex.exec(line)
|
||||||
|
while (match) {
|
||||||
|
imports.push(match[1])
|
||||||
|
match = requireRegex.exec(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return imports
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import { IRepositoryPatternDetector } from "../../domain/services/RepositoryPatternDetectorService"
|
||||||
|
import { RepositoryViolation } from "../../domain/value-objects/RepositoryViolation"
|
||||||
|
import { LAYERS, REPOSITORY_VIOLATION_TYPES } from "../../shared/constants/rules"
|
||||||
|
import { ORM_QUERY_METHODS } from "../constants/orm-methods"
|
||||||
|
import { REPOSITORY_PATTERN_MESSAGES } from "../../domain/constants/Messages"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects Repository Pattern violations in the codebase
|
||||||
|
*
|
||||||
|
* This detector identifies violations where the Repository Pattern is not properly implemented:
|
||||||
|
* 1. ORM-specific types in repository interfaces (domain should be ORM-agnostic)
|
||||||
|
* 2. Concrete repository usage in use cases (violates dependency inversion)
|
||||||
|
* 3. Repository instantiation with 'new' in use cases (should use DI)
|
||||||
|
* 4. Non-domain method names in repositories (should use ubiquitous language)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const detector = new RepositoryPatternDetector()
|
||||||
|
*
|
||||||
|
* // Detect violations in a repository interface
|
||||||
|
* const code = `
|
||||||
|
* interface IUserRepository {
|
||||||
|
* findOne(query: Prisma.UserWhereInput): Promise<User>
|
||||||
|
* }
|
||||||
|
* `
|
||||||
|
* const violations = detector.detectViolations(
|
||||||
|
* code,
|
||||||
|
* 'src/domain/repositories/IUserRepository.ts',
|
||||||
|
* 'domain'
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* // violations will contain ORM type violation
|
||||||
|
* console.log(violations.length) // 1
|
||||||
|
* console.log(violations[0].violationType) // 'orm-type-in-interface'
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class RepositoryPatternDetector implements IRepositoryPatternDetector {
|
||||||
|
private readonly ormTypePatterns = [
|
||||||
|
/Prisma\./,
|
||||||
|
/PrismaClient/,
|
||||||
|
/TypeORM/,
|
||||||
|
/@Entity/,
|
||||||
|
/@Column/,
|
||||||
|
/@PrimaryColumn/,
|
||||||
|
/@PrimaryGeneratedColumn/,
|
||||||
|
/@ManyToOne/,
|
||||||
|
/@OneToMany/,
|
||||||
|
/@ManyToMany/,
|
||||||
|
/@JoinColumn/,
|
||||||
|
/@JoinTable/,
|
||||||
|
/Mongoose\./,
|
||||||
|
/Schema/,
|
||||||
|
/Model</,
|
||||||
|
/Document/,
|
||||||
|
/Sequelize\./,
|
||||||
|
/DataTypes\./,
|
||||||
|
/FindOptions/,
|
||||||
|
/WhereOptions/,
|
||||||
|
/IncludeOptions/,
|
||||||
|
/QueryInterface/,
|
||||||
|
/MikroORM/,
|
||||||
|
/EntityManager/,
|
||||||
|
/EntityRepository/,
|
||||||
|
/Collection</,
|
||||||
|
]
|
||||||
|
|
||||||
|
private readonly technicalMethodNames = ORM_QUERY_METHODS
|
||||||
|
|
||||||
|
private readonly domainMethodPatterns = [
|
||||||
|
/^findBy[A-Z]/,
|
||||||
|
/^findAll/,
|
||||||
|
/^save$/,
|
||||||
|
/^create$/,
|
||||||
|
/^update$/,
|
||||||
|
/^delete$/,
|
||||||
|
/^remove$/,
|
||||||
|
/^add$/,
|
||||||
|
/^get[A-Z]/,
|
||||||
|
/^search/,
|
||||||
|
/^list/,
|
||||||
|
]
|
||||||
|
|
||||||
|
private readonly concreteRepositoryPatterns = [
|
||||||
|
/PrismaUserRepository/,
|
||||||
|
/MongoUserRepository/,
|
||||||
|
/TypeOrmUserRepository/,
|
||||||
|
/SequelizeUserRepository/,
|
||||||
|
/InMemoryUserRepository/,
|
||||||
|
/PostgresUserRepository/,
|
||||||
|
/MySqlUserRepository/,
|
||||||
|
/Repository(?!Interface)/,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects all Repository Pattern violations in the given code
|
||||||
|
*/
|
||||||
|
public detectViolations(
|
||||||
|
code: string,
|
||||||
|
filePath: string,
|
||||||
|
layer: string | undefined,
|
||||||
|
): RepositoryViolation[] {
|
||||||
|
const violations: RepositoryViolation[] = []
|
||||||
|
|
||||||
|
if (this.isRepositoryInterface(filePath, layer)) {
|
||||||
|
violations.push(...this.detectOrmTypesInInterface(code, filePath, layer))
|
||||||
|
violations.push(...this.detectNonDomainMethodNames(code, filePath, layer))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isUseCase(filePath, layer)) {
|
||||||
|
violations.push(...this.detectConcreteRepositoryUsage(code, filePath, layer))
|
||||||
|
violations.push(...this.detectNewRepositoryInstantiation(code, filePath, layer))
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a type is an ORM-specific type
|
||||||
|
*/
|
||||||
|
public isOrmType(typeName: string): boolean {
|
||||||
|
return this.ormTypePatterns.some((pattern) => pattern.test(typeName))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a method name follows domain language conventions
|
||||||
|
*/
|
||||||
|
public isDomainMethodName(methodName: string): boolean {
|
||||||
|
if ((this.technicalMethodNames as readonly string[]).includes(methodName)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.domainMethodPatterns.some((pattern) => pattern.test(methodName))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a file is a repository interface
|
||||||
|
*/
|
||||||
|
public isRepositoryInterface(filePath: string, layer: string | undefined): boolean {
|
||||||
|
if (layer !== LAYERS.DOMAIN) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return /I[A-Z]\w*Repository\.ts$/.test(filePath) && /repositories?\//.test(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a file is a use case
|
||||||
|
*/
|
||||||
|
public isUseCase(filePath: string, layer: string | undefined): boolean {
|
||||||
|
if (layer !== LAYERS.APPLICATION) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return /use-cases?\//.test(filePath) && /[A-Z][a-z]+[A-Z]\w*\.ts$/.test(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects ORM-specific types in repository interfaces
|
||||||
|
*/
|
||||||
|
private detectOrmTypesInInterface(
|
||||||
|
code: string,
|
||||||
|
filePath: string,
|
||||||
|
layer: string | undefined,
|
||||||
|
): RepositoryViolation[] {
|
||||||
|
const violations: RepositoryViolation[] = []
|
||||||
|
const lines = code.split("\n")
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
const lineNumber = i + 1
|
||||||
|
|
||||||
|
const methodMatch =
|
||||||
|
/(\w+)\s*\([^)]*:\s*([^)]+)\)\s*:\s*.*?(?:Promise<([^>]+)>|([A-Z]\w+))/.exec(line)
|
||||||
|
|
||||||
|
if (methodMatch) {
|
||||||
|
const params = methodMatch[2]
|
||||||
|
const returnType = methodMatch[3] || methodMatch[4]
|
||||||
|
|
||||||
|
if (this.isOrmType(params)) {
|
||||||
|
const ormType = this.extractOrmType(params)
|
||||||
|
violations.push(
|
||||||
|
RepositoryViolation.create(
|
||||||
|
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
|
||||||
|
filePath,
|
||||||
|
layer || LAYERS.DOMAIN,
|
||||||
|
lineNumber,
|
||||||
|
`Method parameter uses ORM type: ${ormType}`,
|
||||||
|
ormType,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (returnType && this.isOrmType(returnType)) {
|
||||||
|
const ormType = this.extractOrmType(returnType)
|
||||||
|
violations.push(
|
||||||
|
RepositoryViolation.create(
|
||||||
|
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
|
||||||
|
filePath,
|
||||||
|
layer || LAYERS.DOMAIN,
|
||||||
|
lineNumber,
|
||||||
|
`Method return type uses ORM type: ${ormType}`,
|
||||||
|
ormType,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pattern of this.ormTypePatterns) {
|
||||||
|
if (pattern.test(line) && !line.trim().startsWith("//")) {
|
||||||
|
const ormType = this.extractOrmType(line)
|
||||||
|
violations.push(
|
||||||
|
RepositoryViolation.create(
|
||||||
|
REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
|
||||||
|
filePath,
|
||||||
|
layer || LAYERS.DOMAIN,
|
||||||
|
lineNumber,
|
||||||
|
`Repository interface contains ORM-specific type: ${ormType}`,
|
||||||
|
ormType,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects non-domain method names in repository interfaces
|
||||||
|
*/
|
||||||
|
private detectNonDomainMethodNames(
|
||||||
|
code: string,
|
||||||
|
filePath: string,
|
||||||
|
layer: string | undefined,
|
||||||
|
): RepositoryViolation[] {
|
||||||
|
const violations: RepositoryViolation[] = []
|
||||||
|
const lines = code.split("\n")
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
const lineNumber = i + 1
|
||||||
|
|
||||||
|
const methodMatch = /^\s*(\w+)\s*\(/.exec(line)
|
||||||
|
|
||||||
|
if (methodMatch) {
|
||||||
|
const methodName = methodMatch[1]
|
||||||
|
|
||||||
|
if (!this.isDomainMethodName(methodName) && !line.trim().startsWith("//")) {
|
||||||
|
violations.push(
|
||||||
|
RepositoryViolation.create(
|
||||||
|
REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
|
||||||
|
filePath,
|
||||||
|
layer || LAYERS.DOMAIN,
|
||||||
|
lineNumber,
|
||||||
|
`Method '${methodName}' uses technical name instead of domain language`,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
methodName,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects concrete repository usage in use cases
|
||||||
|
*/
|
||||||
|
private detectConcreteRepositoryUsage(
|
||||||
|
code: string,
|
||||||
|
filePath: string,
|
||||||
|
layer: string | undefined,
|
||||||
|
): RepositoryViolation[] {
|
||||||
|
const violations: RepositoryViolation[] = []
|
||||||
|
const lines = code.split("\n")
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
const lineNumber = i + 1
|
||||||
|
|
||||||
|
const constructorParamMatch =
|
||||||
|
/constructor\s*\([^)]*(?:private|public|protected)\s+(?:readonly\s+)?(\w+)\s*:\s*([A-Z]\w*Repository)/.exec(
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (constructorParamMatch) {
|
||||||
|
const repositoryType = constructorParamMatch[2]
|
||||||
|
|
||||||
|
if (!repositoryType.startsWith("I")) {
|
||||||
|
violations.push(
|
||||||
|
RepositoryViolation.create(
|
||||||
|
REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
|
||||||
|
filePath,
|
||||||
|
layer || LAYERS.APPLICATION,
|
||||||
|
lineNumber,
|
||||||
|
`Use case depends on concrete repository '${repositoryType}'`,
|
||||||
|
undefined,
|
||||||
|
repositoryType,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldMatch =
|
||||||
|
/(?:private|public|protected)\s+(?:readonly\s+)?(\w+)\s*:\s*([A-Z]\w*Repository)/.exec(
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (fieldMatch) {
|
||||||
|
const repositoryType = fieldMatch[2]
|
||||||
|
|
||||||
|
if (
|
||||||
|
!repositoryType.startsWith("I") &&
|
||||||
|
!line.includes(REPOSITORY_PATTERN_MESSAGES.CONSTRUCTOR)
|
||||||
|
) {
|
||||||
|
violations.push(
|
||||||
|
RepositoryViolation.create(
|
||||||
|
REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
|
||||||
|
filePath,
|
||||||
|
layer || LAYERS.APPLICATION,
|
||||||
|
lineNumber,
|
||||||
|
`Use case field uses concrete repository '${repositoryType}'`,
|
||||||
|
undefined,
|
||||||
|
repositoryType,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects 'new Repository()' instantiation in use cases
|
||||||
|
*/
|
||||||
|
private detectNewRepositoryInstantiation(
|
||||||
|
code: string,
|
||||||
|
filePath: string,
|
||||||
|
layer: string | undefined,
|
||||||
|
): RepositoryViolation[] {
|
||||||
|
const violations: RepositoryViolation[] = []
|
||||||
|
const lines = code.split("\n")
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
const lineNumber = i + 1
|
||||||
|
|
||||||
|
const newRepositoryMatch = /new\s+([A-Z]\w*Repository)\s*\(/.exec(line)
|
||||||
|
|
||||||
|
if (newRepositoryMatch && !line.trim().startsWith("//")) {
|
||||||
|
const repositoryName = newRepositoryMatch[1]
|
||||||
|
violations.push(
|
||||||
|
RepositoryViolation.create(
|
||||||
|
REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE,
|
||||||
|
filePath,
|
||||||
|
layer || LAYERS.APPLICATION,
|
||||||
|
lineNumber,
|
||||||
|
`Use case creates repository with 'new ${repositoryName}()'`,
|
||||||
|
undefined,
|
||||||
|
repositoryName,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts ORM type name from a code line
|
||||||
|
*/
|
||||||
|
private extractOrmType(line: string): string {
|
||||||
|
for (const pattern of this.ormTypePatterns) {
|
||||||
|
const match = line.match(pattern)
|
||||||
|
if (match) {
|
||||||
|
const startIdx = match.index || 0
|
||||||
|
const typeMatch = /[\w.]+/.exec(line.slice(startIdx))
|
||||||
|
return typeMatch ? typeMatch[0] : REPOSITORY_PATTERN_MESSAGES.UNKNOWN_TYPE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return REPOSITORY_PATTERN_MESSAGES.UNKNOWN_TYPE
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const NAMING_SUGGESTION_DEFAULT =
|
||||||
|
"Move to application or infrastructure layer, or rename to follow domain patterns"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export const ORM_QUERY_METHODS = [
|
||||||
|
"findOne",
|
||||||
|
"findMany",
|
||||||
|
"findFirst",
|
||||||
|
"findAll",
|
||||||
|
"findAndCountAll",
|
||||||
|
"insert",
|
||||||
|
"insertMany",
|
||||||
|
"insertOne",
|
||||||
|
"updateOne",
|
||||||
|
"updateMany",
|
||||||
|
"deleteOne",
|
||||||
|
"deleteMany",
|
||||||
|
"select",
|
||||||
|
"query",
|
||||||
|
"execute",
|
||||||
|
"run",
|
||||||
|
"exec",
|
||||||
|
"aggregate",
|
||||||
|
"count",
|
||||||
|
"exists",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type OrmQueryMethod = (typeof ORM_QUERY_METHODS)[number]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const DTO_SUFFIXES = [
|
||||||
|
"Dto",
|
||||||
|
"DTO",
|
||||||
|
"Request",
|
||||||
|
"Response",
|
||||||
|
"Command",
|
||||||
|
"Query",
|
||||||
|
"Result",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const PRIMITIVE_TYPES = [
|
||||||
|
"string",
|
||||||
|
"number",
|
||||||
|
"boolean",
|
||||||
|
"void",
|
||||||
|
"any",
|
||||||
|
"unknown",
|
||||||
|
"null",
|
||||||
|
"undefined",
|
||||||
|
"object",
|
||||||
|
"never",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const NULLABLE_TYPES = ["null", "undefined"] as const
|
||||||
|
|
||||||
|
export const TEST_FILE_EXTENSIONS = [".test.", ".spec."] as const
|
||||||
|
|
||||||
|
export const TEST_FILE_SUFFIXES = [".test.ts", ".test.js", ".spec.ts", ".spec.js"] as const
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./parsers/CodeParser"
|
export * from "./parsers/CodeParser"
|
||||||
export * from "./scanners/FileScanner"
|
export * from "./scanners/FileScanner"
|
||||||
export * from "./analyzers/HardcodeDetector"
|
export * from "./analyzers/HardcodeDetector"
|
||||||
|
export * from "./analyzers/RepositoryPatternDetector"
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ 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",
|
||||||
|
DEPENDENCY_DIRECTION: "dependency-direction",
|
||||||
|
REPOSITORY_PATTERN: "repository-pattern",
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -395,4 +398,15 @@ export const FRAMEWORK_LEAK_MESSAGES = {
|
|||||||
DOMAIN_IMPORT:
|
DOMAIN_IMPORT:
|
||||||
'Domain layer imports framework-specific package "{package}". Use interfaces and dependency injection instead.',
|
'Domain layer imports framework-specific package "{package}". Use interfaces and dependency injection instead.',
|
||||||
SUGGESTION: "Create an interface in domain layer and implement it in infrastructure layer.",
|
SUGGESTION: "Create an interface in domain layer and implement it in infrastructure layer.",
|
||||||
|
PACKAGE_PLACEHOLDER: "{package}",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository pattern violation types
|
||||||
|
*/
|
||||||
|
export const REPOSITORY_VIOLATION_TYPES = {
|
||||||
|
ORM_TYPE_IN_INTERFACE: "orm-type-in-interface",
|
||||||
|
CONCRETE_REPOSITORY_IN_USE_CASE: "concrete-repository-in-use-case",
|
||||||
|
NEW_REPOSITORY_IN_USE_CASE: "new-repository-in-use-case",
|
||||||
|
NON_DOMAIN_METHOD_NAME: "non-domain-method-name",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
511
packages/guardian/tests/DependencyDirectionDetector.test.ts
Normal file
511
packages/guardian/tests/DependencyDirectionDetector.test.ts
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import { DependencyDirectionDetector } from "../src/infrastructure/analyzers/DependencyDirectionDetector"
|
||||||
|
import { LAYERS } from "../src/shared/constants/rules"
|
||||||
|
|
||||||
|
describe("DependencyDirectionDetector", () => {
|
||||||
|
const detector = new DependencyDirectionDetector()
|
||||||
|
|
||||||
|
describe("extractLayerFromImport", () => {
|
||||||
|
it("should extract domain layer from import path", () => {
|
||||||
|
expect(detector.extractLayerFromImport("../domain/entities/User")).toBe(LAYERS.DOMAIN)
|
||||||
|
expect(detector.extractLayerFromImport("../../domain/value-objects/Email")).toBe(
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
expect(detector.extractLayerFromImport("../../../domain/services/UserService")).toBe(
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should extract application layer from import path", () => {
|
||||||
|
expect(detector.extractLayerFromImport("../application/use-cases/CreateUser")).toBe(
|
||||||
|
LAYERS.APPLICATION,
|
||||||
|
)
|
||||||
|
expect(detector.extractLayerFromImport("../../application/dtos/UserDto")).toBe(
|
||||||
|
LAYERS.APPLICATION,
|
||||||
|
)
|
||||||
|
expect(detector.extractLayerFromImport("../../../application/mappers/UserMapper")).toBe(
|
||||||
|
LAYERS.APPLICATION,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should extract infrastructure layer from import path", () => {
|
||||||
|
expect(
|
||||||
|
detector.extractLayerFromImport("../infrastructure/controllers/UserController"),
|
||||||
|
).toBe(LAYERS.INFRASTRUCTURE)
|
||||||
|
expect(
|
||||||
|
detector.extractLayerFromImport("../../infrastructure/repositories/UserRepository"),
|
||||||
|
).toBe(LAYERS.INFRASTRUCTURE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should extract shared layer from import path", () => {
|
||||||
|
expect(detector.extractLayerFromImport("../shared/types/Result")).toBe(LAYERS.SHARED)
|
||||||
|
expect(detector.extractLayerFromImport("../../shared/constants/rules")).toBe(
|
||||||
|
LAYERS.SHARED,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return undefined for non-layer imports", () => {
|
||||||
|
expect(detector.extractLayerFromImport("express")).toBeUndefined()
|
||||||
|
expect(detector.extractLayerFromImport("../utils/helper")).toBeUndefined()
|
||||||
|
expect(detector.extractLayerFromImport("../../lib/logger")).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isViolation", () => {
|
||||||
|
it("should allow domain to import from domain", () => {
|
||||||
|
expect(detector.isViolation(LAYERS.DOMAIN, LAYERS.DOMAIN)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow domain to import from shared", () => {
|
||||||
|
expect(detector.isViolation(LAYERS.DOMAIN, LAYERS.SHARED)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT allow domain to import from application", () => {
|
||||||
|
expect(detector.isViolation(LAYERS.DOMAIN, LAYERS.APPLICATION)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT allow domain to import from infrastructure", () => {
|
||||||
|
expect(detector.isViolation(LAYERS.DOMAIN, LAYERS.INFRASTRUCTURE)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow application to import from domain", () => {
|
||||||
|
expect(detector.isViolation(LAYERS.APPLICATION, LAYERS.DOMAIN)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow application to import from application", () => {
|
||||||
|
expect(detector.isViolation(LAYERS.APPLICATION, LAYERS.APPLICATION)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow application to import from shared", () => {
|
||||||
|
expect(detector.isViolation(LAYERS.APPLICATION, LAYERS.SHARED)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT allow application to import from infrastructure", () => {
|
||||||
|
expect(detector.isViolation(LAYERS.APPLICATION, LAYERS.INFRASTRUCTURE)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow infrastructure to import from domain", () => {
|
||||||
|
expect(detector.isViolation(LAYERS.INFRASTRUCTURE, LAYERS.DOMAIN)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow infrastructure to import from application", () => {
|
||||||
|
expect(detector.isViolation(LAYERS.INFRASTRUCTURE, LAYERS.APPLICATION)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow infrastructure to import from infrastructure", () => {
|
||||||
|
expect(detector.isViolation(LAYERS.INFRASTRUCTURE, LAYERS.INFRASTRUCTURE)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow infrastructure to import from shared", () => {
|
||||||
|
expect(detector.isViolation(LAYERS.INFRASTRUCTURE, LAYERS.SHARED)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow shared to import from any layer", () => {
|
||||||
|
expect(detector.isViolation(LAYERS.SHARED, LAYERS.DOMAIN)).toBe(false)
|
||||||
|
expect(detector.isViolation(LAYERS.SHARED, LAYERS.APPLICATION)).toBe(false)
|
||||||
|
expect(detector.isViolation(LAYERS.SHARED, LAYERS.INFRASTRUCTURE)).toBe(false)
|
||||||
|
expect(detector.isViolation(LAYERS.SHARED, LAYERS.SHARED)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("detectViolations", () => {
|
||||||
|
describe("Domain layer violations", () => {
|
||||||
|
it("should detect domain importing from application", () => {
|
||||||
|
const code = `
|
||||||
|
import { UserDto } from '../../application/dtos/UserDto'
|
||||||
|
|
||||||
|
export class User {
|
||||||
|
constructor(private id: string) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/User.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
expect(violations[0].fromLayer).toBe(LAYERS.DOMAIN)
|
||||||
|
expect(violations[0].toLayer).toBe(LAYERS.APPLICATION)
|
||||||
|
expect(violations[0].importPath).toBe("../../application/dtos/UserDto")
|
||||||
|
expect(violations[0].line).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect domain importing from infrastructure", () => {
|
||||||
|
const code = `
|
||||||
|
import { PrismaClient } from '../../infrastructure/database/PrismaClient'
|
||||||
|
|
||||||
|
export class UserRepository {
|
||||||
|
constructor(private prisma: PrismaClient) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/repositories/UserRepository.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
expect(violations[0].fromLayer).toBe(LAYERS.DOMAIN)
|
||||||
|
expect(violations[0].toLayer).toBe(LAYERS.INFRASTRUCTURE)
|
||||||
|
expect(violations[0].importPath).toBe("../../infrastructure/database/PrismaClient")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect domain importing from domain", () => {
|
||||||
|
const code = `
|
||||||
|
import { Email } from '../value-objects/Email'
|
||||||
|
import { UserId } from '../value-objects/UserId'
|
||||||
|
|
||||||
|
export class User {
|
||||||
|
constructor(
|
||||||
|
private id: UserId,
|
||||||
|
private email: Email
|
||||||
|
) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/User.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect domain importing from shared", () => {
|
||||||
|
const code = `
|
||||||
|
import { Result } from '../../shared/types/Result'
|
||||||
|
|
||||||
|
export class User {
|
||||||
|
static create(id: string): Result<User> {
|
||||||
|
return Result.ok(new User(id))
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/User.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Application layer violations", () => {
|
||||||
|
it("should detect application importing from infrastructure", () => {
|
||||||
|
const code = `
|
||||||
|
import { SmtpEmailService } from '../../infrastructure/email/SmtpEmailService'
|
||||||
|
|
||||||
|
export class SendWelcomeEmail {
|
||||||
|
constructor(private emailService: SmtpEmailService) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/SendWelcomeEmail.ts",
|
||||||
|
LAYERS.APPLICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
expect(violations[0].fromLayer).toBe(LAYERS.APPLICATION)
|
||||||
|
expect(violations[0].toLayer).toBe(LAYERS.INFRASTRUCTURE)
|
||||||
|
expect(violations[0].getMessage()).toContain(
|
||||||
|
"Application layer should not import from Infrastructure layer",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect application importing from domain", () => {
|
||||||
|
const code = `
|
||||||
|
import { User } from '../../domain/entities/User'
|
||||||
|
import { IUserRepository } from '../../domain/repositories/IUserRepository'
|
||||||
|
|
||||||
|
export class CreateUser {
|
||||||
|
constructor(private userRepo: IUserRepository) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/CreateUser.ts",
|
||||||
|
LAYERS.APPLICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect application importing from application", () => {
|
||||||
|
const code = `
|
||||||
|
import { UserResponseDto } from '../dtos/UserResponseDto'
|
||||||
|
import { UserMapper } from '../mappers/UserMapper'
|
||||||
|
|
||||||
|
export class GetUser {
|
||||||
|
execute(id: string): UserResponseDto {
|
||||||
|
return UserMapper.toDto(user)
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/GetUser.ts",
|
||||||
|
LAYERS.APPLICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect application importing from shared", () => {
|
||||||
|
const code = `
|
||||||
|
import { Result } from '../../shared/types/Result'
|
||||||
|
|
||||||
|
export class CreateUser {
|
||||||
|
execute(): Result<User> {
|
||||||
|
return Result.ok(user)
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/CreateUser.ts",
|
||||||
|
LAYERS.APPLICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Infrastructure layer", () => {
|
||||||
|
it("should NOT detect infrastructure importing from domain", () => {
|
||||||
|
const code = `
|
||||||
|
import { User } from '../../domain/entities/User'
|
||||||
|
import { IUserRepository } from '../../domain/repositories/IUserRepository'
|
||||||
|
|
||||||
|
export class PrismaUserRepository implements IUserRepository {
|
||||||
|
async save(user: User): Promise<void> {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/infrastructure/repositories/PrismaUserRepository.ts",
|
||||||
|
LAYERS.INFRASTRUCTURE,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect infrastructure importing from application", () => {
|
||||||
|
const code = `
|
||||||
|
import { CreateUser } from '../../application/use-cases/CreateUser'
|
||||||
|
import { UserResponseDto } from '../../application/dtos/UserResponseDto'
|
||||||
|
|
||||||
|
export class UserController {
|
||||||
|
constructor(private createUser: CreateUser) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/infrastructure/controllers/UserController.ts",
|
||||||
|
LAYERS.INFRASTRUCTURE,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should NOT detect infrastructure importing from infrastructure", () => {
|
||||||
|
const code = `
|
||||||
|
import { DatabaseConnection } from '../database/DatabaseConnection'
|
||||||
|
|
||||||
|
export class PrismaUserRepository {
|
||||||
|
constructor(private db: DatabaseConnection) {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/infrastructure/repositories/PrismaUserRepository.ts",
|
||||||
|
LAYERS.INFRASTRUCTURE,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Multiple violations", () => {
|
||||||
|
it("should detect multiple violations in same file", () => {
|
||||||
|
const code = `
|
||||||
|
import { UserDto } from '../../application/dtos/UserDto'
|
||||||
|
import { EmailService } from '../../infrastructure/email/EmailService'
|
||||||
|
import { Logger } from '../../infrastructure/logging/Logger'
|
||||||
|
|
||||||
|
export class User {
|
||||||
|
constructor() {}
|
||||||
|
}`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/User.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(3)
|
||||||
|
expect(violations[0].toLayer).toBe(LAYERS.APPLICATION)
|
||||||
|
expect(violations[1].toLayer).toBe(LAYERS.INFRASTRUCTURE)
|
||||||
|
expect(violations[2].toLayer).toBe(LAYERS.INFRASTRUCTURE)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Import statement formats", () => {
|
||||||
|
it("should detect violations in named imports", () => {
|
||||||
|
const code = `import { UserDto, UserRequest } from '../../application/dtos/UserDto'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/User.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect violations in default imports", () => {
|
||||||
|
const code = `import UserDto from '../../application/dtos/UserDto'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/User.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect violations in namespace imports", () => {
|
||||||
|
const code = `import * as Dtos from '../../application/dtos'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/User.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect violations in require statements", () => {
|
||||||
|
const code = `const UserDto = require('../../application/dtos/UserDto')`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/User.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Edge cases", () => {
|
||||||
|
it("should return empty array for shared layer", () => {
|
||||||
|
const code = `
|
||||||
|
import { User } from '../../domain/entities/User'
|
||||||
|
import { CreateUser } from '../../application/use-cases/CreateUser'
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/shared/types/Result.ts",
|
||||||
|
LAYERS.SHARED,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty array for undefined layer", () => {
|
||||||
|
const code = `import { UserDto } from '../../application/dtos/UserDto'`
|
||||||
|
const violations = detector.detectViolations(code, "src/utils/helper.ts", undefined)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle empty code", () => {
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
"",
|
||||||
|
"src/domain/entities/User.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getMessage", () => {
|
||||||
|
it("should return correct message for domain -> application violation", () => {
|
||||||
|
const code = `import { UserDto } from '../../application/dtos/UserDto'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/entities/User.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations[0].getMessage()).toBe(
|
||||||
|
"Domain layer should not import from Application layer",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return correct message for application -> infrastructure violation", () => {
|
||||||
|
const code = `import { SmtpEmailService } from '../../infrastructure/email/SmtpEmailService'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/SendEmail.ts",
|
||||||
|
LAYERS.APPLICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations[0].getMessage()).toBe(
|
||||||
|
"Application layer should not import from Infrastructure layer",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getSuggestion", () => {
|
||||||
|
it("should return suggestions for domain layer violations", () => {
|
||||||
|
const code = `import { PrismaClient } from '../../infrastructure/database'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/services/UserService.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
const suggestion = violations[0].getSuggestion()
|
||||||
|
expect(suggestion).toContain("Domain layer should be independent")
|
||||||
|
expect(suggestion).toContain("dependency inversion")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return suggestions for application layer violations", () => {
|
||||||
|
const code = `import { SmtpEmailService } from '../../infrastructure/email/SmtpEmailService'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/SendEmail.ts",
|
||||||
|
LAYERS.APPLICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
const suggestion = violations[0].getSuggestion()
|
||||||
|
expect(suggestion).toContain(
|
||||||
|
"Application layer should not depend on infrastructure",
|
||||||
|
)
|
||||||
|
expect(suggestion).toContain("Port")
|
||||||
|
expect(suggestion).toContain("Adapter")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getExampleFix", () => {
|
||||||
|
it("should return example fix for domain -> infrastructure violation", () => {
|
||||||
|
const code = `import { PrismaClient } from '../../infrastructure/database'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/services/UserService.ts",
|
||||||
|
LAYERS.DOMAIN,
|
||||||
|
)
|
||||||
|
|
||||||
|
const example = violations[0].getExampleFix()
|
||||||
|
expect(example).toContain("// ❌ Bad")
|
||||||
|
expect(example).toContain("// ✅ Good")
|
||||||
|
expect(example).toContain("IUserRepository")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return example fix for application -> infrastructure violation", () => {
|
||||||
|
const code = `import { SmtpEmailService } from '../../infrastructure/email/SmtpEmailService'`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/SendEmail.ts",
|
||||||
|
LAYERS.APPLICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
const example = violations[0].getExampleFix()
|
||||||
|
expect(example).toContain("// ❌ Bad")
|
||||||
|
expect(example).toContain("// ✅ Good")
|
||||||
|
expect(example).toContain("IEmailService")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
515
packages/guardian/tests/RepositoryPatternDetector.test.ts
Normal file
515
packages/guardian/tests/RepositoryPatternDetector.test.ts
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest"
|
||||||
|
import { RepositoryPatternDetector } from "../src/infrastructure/analyzers/RepositoryPatternDetector"
|
||||||
|
import { REPOSITORY_VIOLATION_TYPES } from "../src/shared/constants/rules"
|
||||||
|
|
||||||
|
describe("RepositoryPatternDetector", () => {
|
||||||
|
let detector: RepositoryPatternDetector
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
detector = new RepositoryPatternDetector()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("detectViolations - ORM Types in Interface", () => {
|
||||||
|
it("should detect Prisma types in repository interface", () => {
|
||||||
|
const code = `
|
||||||
|
interface IUserRepository {
|
||||||
|
findOne(query: Prisma.UserWhereInput): Promise<User>
|
||||||
|
create(data: Prisma.UserCreateInput): Promise<User>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/repositories/IUserRepository.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations.length).toBeGreaterThan(0)
|
||||||
|
const ormViolations = violations.filter(
|
||||||
|
(v) => v.violationType === REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
|
||||||
|
)
|
||||||
|
expect(ormViolations.length).toBeGreaterThan(0)
|
||||||
|
expect(ormViolations[0].getMessage()).toContain("ORM-specific type")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect TypeORM decorators in repository interface", () => {
|
||||||
|
const code = `
|
||||||
|
interface IUserRepository {
|
||||||
|
@Column()
|
||||||
|
findById(id: string): Promise<User>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/repositories/IUserRepository.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
const ormViolations = violations.filter(
|
||||||
|
(v) => v.violationType === REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
|
||||||
|
)
|
||||||
|
expect(ormViolations.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect Mongoose types in repository interface", () => {
|
||||||
|
const code = `
|
||||||
|
interface IUserRepository {
|
||||||
|
find(query: Model<User>): Promise<User[]>
|
||||||
|
findOne(query: Schema): Promise<User>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/repositories/IUserRepository.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
const ormViolations = violations.filter(
|
||||||
|
(v) => v.violationType === REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
|
||||||
|
)
|
||||||
|
expect(ormViolations.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not detect ORM types in clean interface", () => {
|
||||||
|
const code = `
|
||||||
|
interface IUserRepository {
|
||||||
|
findById(id: UserId): Promise<User | null>
|
||||||
|
save(user: User): Promise<void>
|
||||||
|
delete(id: UserId): Promise<void>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/repositories/IUserRepository.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
const ormViolations = violations.filter(
|
||||||
|
(v) => v.violationType === REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
|
||||||
|
)
|
||||||
|
expect(ormViolations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("detectViolations - Concrete Repository in Use Case", () => {
|
||||||
|
it("should detect concrete repository in constructor", () => {
|
||||||
|
const code = `
|
||||||
|
class CreateUser {
|
||||||
|
constructor(private userRepo: PrismaUserRepository) {}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/CreateUser.ts",
|
||||||
|
"application",
|
||||||
|
)
|
||||||
|
|
||||||
|
const concreteViolations = violations.filter(
|
||||||
|
(v) =>
|
||||||
|
v.violationType === REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
|
||||||
|
)
|
||||||
|
expect(concreteViolations).toHaveLength(1)
|
||||||
|
expect(concreteViolations[0].repositoryName).toBe("PrismaUserRepository")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect concrete repository as field", () => {
|
||||||
|
const code = `
|
||||||
|
class CreateUser {
|
||||||
|
private userRepo: MongoUserRepository
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/CreateUser.ts",
|
||||||
|
"application",
|
||||||
|
)
|
||||||
|
|
||||||
|
const concreteViolations = violations.filter(
|
||||||
|
(v) =>
|
||||||
|
v.violationType === REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
|
||||||
|
)
|
||||||
|
expect(concreteViolations).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not detect interface in constructor", () => {
|
||||||
|
const code = `
|
||||||
|
class CreateUser {
|
||||||
|
constructor(private userRepo: IUserRepository) {}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/CreateUser.ts",
|
||||||
|
"application",
|
||||||
|
)
|
||||||
|
|
||||||
|
const concreteViolations = violations.filter(
|
||||||
|
(v) =>
|
||||||
|
v.violationType === REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
|
||||||
|
)
|
||||||
|
expect(concreteViolations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("detectViolations - new Repository() in Use Case", () => {
|
||||||
|
it("should detect repository instantiation with new", () => {
|
||||||
|
const code = `
|
||||||
|
class CreateUser {
|
||||||
|
async execute(data: CreateUserRequest) {
|
||||||
|
const repo = new UserRepository()
|
||||||
|
await repo.save(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/CreateUser.ts",
|
||||||
|
"application",
|
||||||
|
)
|
||||||
|
|
||||||
|
const newRepoViolations = violations.filter(
|
||||||
|
(v) => v.violationType === REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE,
|
||||||
|
)
|
||||||
|
expect(newRepoViolations).toHaveLength(1)
|
||||||
|
expect(newRepoViolations[0].repositoryName).toBe("UserRepository")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect multiple repository instantiations", () => {
|
||||||
|
const code = `
|
||||||
|
class ComplexUseCase {
|
||||||
|
async execute() {
|
||||||
|
const userRepo = new UserRepository()
|
||||||
|
const orderRepo = new OrderRepository()
|
||||||
|
await userRepo.save(user)
|
||||||
|
await orderRepo.save(order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/ComplexUseCase.ts",
|
||||||
|
"application",
|
||||||
|
)
|
||||||
|
|
||||||
|
const newRepoViolations = violations.filter(
|
||||||
|
(v) => v.violationType === REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE,
|
||||||
|
)
|
||||||
|
expect(newRepoViolations).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not detect commented out new Repository()", () => {
|
||||||
|
const code = `
|
||||||
|
class CreateUser {
|
||||||
|
async execute(data: CreateUserRequest) {
|
||||||
|
// const repo = new UserRepository()
|
||||||
|
await this.userRepo.save(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/CreateUser.ts",
|
||||||
|
"application",
|
||||||
|
)
|
||||||
|
|
||||||
|
const newRepoViolations = violations.filter(
|
||||||
|
(v) => v.violationType === REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE,
|
||||||
|
)
|
||||||
|
expect(newRepoViolations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("detectViolations - Non-Domain Method Names", () => {
|
||||||
|
it("should detect technical method names", () => {
|
||||||
|
const code = `
|
||||||
|
interface IUserRepository {
|
||||||
|
findOne(id: string): Promise<User>
|
||||||
|
findMany(filter: any): Promise<User[]>
|
||||||
|
insert(user: User): Promise<void>
|
||||||
|
updateOne(id: string, data: any): Promise<void>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/repositories/IUserRepository.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
const methodViolations = violations.filter(
|
||||||
|
(v) => v.violationType === REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
|
||||||
|
)
|
||||||
|
expect(methodViolations.length).toBeGreaterThan(0)
|
||||||
|
expect(methodViolations.some((v) => v.methodName === "findOne")).toBe(true)
|
||||||
|
expect(methodViolations.some((v) => v.methodName === "insert")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not detect domain language method names", () => {
|
||||||
|
const code = `
|
||||||
|
interface IUserRepository {
|
||||||
|
findById(id: UserId): Promise<User | null>
|
||||||
|
findByEmail(email: Email): Promise<User | null>
|
||||||
|
save(user: User): Promise<void>
|
||||||
|
delete(id: UserId): Promise<void>
|
||||||
|
search(criteria: SearchCriteria): Promise<User[]>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/repositories/IUserRepository.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
const methodViolations = violations.filter(
|
||||||
|
(v) => v.violationType === REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
|
||||||
|
)
|
||||||
|
expect(methodViolations).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect SQL terminology", () => {
|
||||||
|
const code = `
|
||||||
|
interface IUserRepository {
|
||||||
|
select(id: string): Promise<User>
|
||||||
|
query(filter: any): Promise<User[]>
|
||||||
|
execute(sql: string): Promise<any>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/repositories/IUserRepository.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
const methodViolations = violations.filter(
|
||||||
|
(v) => v.violationType === REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
|
||||||
|
)
|
||||||
|
expect(methodViolations.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isOrmType", () => {
|
||||||
|
it("should identify Prisma types", () => {
|
||||||
|
expect(detector.isOrmType("Prisma.UserWhereInput")).toBe(true)
|
||||||
|
expect(detector.isOrmType("PrismaClient")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should identify TypeORM decorators", () => {
|
||||||
|
expect(detector.isOrmType("@Entity")).toBe(true)
|
||||||
|
expect(detector.isOrmType("@Column")).toBe(true)
|
||||||
|
expect(detector.isOrmType("@ManyToOne")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should identify Mongoose types", () => {
|
||||||
|
expect(detector.isOrmType("Schema")).toBe(true)
|
||||||
|
expect(detector.isOrmType("Model<User>")).toBe(true)
|
||||||
|
expect(detector.isOrmType("Document")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not identify domain types", () => {
|
||||||
|
expect(detector.isOrmType("User")).toBe(false)
|
||||||
|
expect(detector.isOrmType("UserId")).toBe(false)
|
||||||
|
expect(detector.isOrmType("Email")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isDomainMethodName", () => {
|
||||||
|
it("should identify domain method names", () => {
|
||||||
|
expect(detector.isDomainMethodName("findById")).toBe(true)
|
||||||
|
expect(detector.isDomainMethodName("findByEmail")).toBe(true)
|
||||||
|
expect(detector.isDomainMethodName("save")).toBe(true)
|
||||||
|
expect(detector.isDomainMethodName("delete")).toBe(true)
|
||||||
|
expect(detector.isDomainMethodName("create")).toBe(true)
|
||||||
|
expect(detector.isDomainMethodName("search")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject technical method names", () => {
|
||||||
|
expect(detector.isDomainMethodName("findOne")).toBe(false)
|
||||||
|
expect(detector.isDomainMethodName("findMany")).toBe(false)
|
||||||
|
expect(detector.isDomainMethodName("insert")).toBe(false)
|
||||||
|
expect(detector.isDomainMethodName("updateOne")).toBe(false)
|
||||||
|
expect(detector.isDomainMethodName("query")).toBe(false)
|
||||||
|
expect(detector.isDomainMethodName("execute")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isRepositoryInterface", () => {
|
||||||
|
it("should identify repository interfaces in domain", () => {
|
||||||
|
expect(
|
||||||
|
detector.isRepositoryInterface(
|
||||||
|
"src/domain/repositories/IUserRepository.ts",
|
||||||
|
"domain",
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
detector.isRepositoryInterface(
|
||||||
|
"src/domain/repositories/IOrderRepository.ts",
|
||||||
|
"domain",
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not identify repository implementations in infrastructure", () => {
|
||||||
|
expect(
|
||||||
|
detector.isRepositoryInterface(
|
||||||
|
"src/infrastructure/repositories/UserRepository.ts",
|
||||||
|
"infrastructure",
|
||||||
|
),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not identify non-repository files", () => {
|
||||||
|
expect(detector.isRepositoryInterface("src/domain/entities/User.ts", "domain")).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isUseCase", () => {
|
||||||
|
it("should identify use cases", () => {
|
||||||
|
expect(
|
||||||
|
detector.isUseCase("src/application/use-cases/CreateUser.ts", "application"),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
detector.isUseCase("src/application/use-cases/UpdateProfile.ts", "application"),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
detector.isUseCase("src/application/use-cases/DeleteOrder.ts", "application"),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not identify DTOs as use cases", () => {
|
||||||
|
expect(
|
||||||
|
detector.isUseCase("src/application/dtos/UserResponseDto.ts", "application"),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should not identify use cases in wrong layer", () => {
|
||||||
|
expect(detector.isUseCase("src/domain/use-cases/CreateUser.ts", "domain")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getMessage and getSuggestion", () => {
|
||||||
|
it("should provide helpful message for ORM type violations", () => {
|
||||||
|
const code = `
|
||||||
|
interface IUserRepository {
|
||||||
|
findOne(query: Prisma.UserWhereInput): Promise<User>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/repositories/IUserRepository.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
const ormViolations = violations.filter(
|
||||||
|
(v) => v.violationType === REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE,
|
||||||
|
)
|
||||||
|
expect(ormViolations[0].getMessage()).toContain("ORM-specific type")
|
||||||
|
expect(ormViolations[0].getSuggestion()).toContain("domain types")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should provide helpful message for concrete repository violations", () => {
|
||||||
|
const code = `
|
||||||
|
class CreateUser {
|
||||||
|
constructor(private userRepo: PrismaUserRepository) {}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/CreateUser.ts",
|
||||||
|
"application",
|
||||||
|
)
|
||||||
|
|
||||||
|
const concreteViolations = violations.filter(
|
||||||
|
(v) =>
|
||||||
|
v.violationType === REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE,
|
||||||
|
)
|
||||||
|
expect(concreteViolations[0].getMessage()).toContain("concrete repository")
|
||||||
|
expect(concreteViolations[0].getSuggestion()).toContain("interface")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should provide helpful message for new repository violations", () => {
|
||||||
|
const code = `
|
||||||
|
class CreateUser {
|
||||||
|
async execute() {
|
||||||
|
const repo = new UserRepository()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/CreateUser.ts",
|
||||||
|
"application",
|
||||||
|
)
|
||||||
|
|
||||||
|
const newRepoViolations = violations.filter(
|
||||||
|
(v) => v.violationType === REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE,
|
||||||
|
)
|
||||||
|
expect(newRepoViolations[0].getMessage()).toContain("new")
|
||||||
|
expect(newRepoViolations[0].getSuggestion()).toContain("dependency injection")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should provide helpful message for non-domain method violations", () => {
|
||||||
|
const code = `
|
||||||
|
interface IUserRepository {
|
||||||
|
findOne(id: string): Promise<User>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/repositories/IUserRepository.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
const methodViolations = violations.filter(
|
||||||
|
(v) => v.violationType === REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME,
|
||||||
|
)
|
||||||
|
expect(methodViolations[0].getMessage()).toContain("technical name")
|
||||||
|
expect(methodViolations[0].getSuggestion()).toContain("domain language")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Integration tests", () => {
|
||||||
|
it("should detect multiple violation types in same file", () => {
|
||||||
|
const code = `
|
||||||
|
interface IUserRepository {
|
||||||
|
findOne(query: Prisma.UserWhereInput): Promise<User>
|
||||||
|
insert(user: User): Promise<void>
|
||||||
|
findById(id: UserId): Promise<User | null>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/domain/repositories/IUserRepository.ts",
|
||||||
|
"domain",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations.length).toBeGreaterThan(1)
|
||||||
|
const types = violations.map((v) => v.violationType)
|
||||||
|
expect(types).toContain(REPOSITORY_VIOLATION_TYPES.ORM_TYPE_IN_INTERFACE)
|
||||||
|
expect(types).toContain(REPOSITORY_VIOLATION_TYPES.NON_DOMAIN_METHOD_NAME)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should detect all violations in complex use case", () => {
|
||||||
|
const code = `
|
||||||
|
class CreateUser {
|
||||||
|
constructor(private userRepo: PrismaUserRepository) {}
|
||||||
|
|
||||||
|
async execute(data: CreateUserRequest) {
|
||||||
|
const repo = new OrderRepository()
|
||||||
|
await this.userRepo.save(user)
|
||||||
|
await repo.save(order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
const violations = detector.detectViolations(
|
||||||
|
code,
|
||||||
|
"src/application/use-cases/CreateUser.ts",
|
||||||
|
"application",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(violations.length).toBeGreaterThanOrEqual(2)
|
||||||
|
const types = violations.map((v) => v.violationType)
|
||||||
|
expect(types).toContain(REPOSITORY_VIOLATION_TYPES.CONCRETE_REPOSITORY_IN_USE_CASE)
|
||||||
|
expect(types).toContain(REPOSITORY_VIOLATION_TYPES.NEW_REPOSITORY_IN_USE_CASE)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user