mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat: add repository pattern validation (v0.5.0)
Add comprehensive Repository Pattern validation to detect violations and ensure proper domain-infrastructure separation. Features: - ORM type detection in repository interfaces (25+ patterns) - Concrete repository usage detection in use cases - Repository instantiation detection (new Repository()) - Domain language validation for repository methods - Smart violation reporting with fix suggestions Tests: - 31 new tests for repository pattern detection - 292 total tests passing (100% pass rate) - 96.77% statement coverage, 83.82% branch coverage Examples: - 8 example files (4 bad patterns, 4 good patterns) - Demonstrates Clean Architecture and SOLID principles
This commit is contained in:
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",
|
||||
"version": "0.4.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.",
|
||||
"keywords": [
|
||||
"puaros",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { INamingConventionDetector } from "./domain/services/INamingConventionDe
|
||||
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 { CodeParser } from "./infrastructure/parsers/CodeParser"
|
||||
import { HardcodeDetector } from "./infrastructure/analyzers/HardcodeDetector"
|
||||
@@ -17,6 +18,7 @@ import { NamingConventionDetector } from "./infrastructure/analyzers/NamingConve
|
||||
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"
|
||||
|
||||
/**
|
||||
@@ -73,6 +75,7 @@ export async function analyzeProject(
|
||||
const entityExposureDetector: IEntityExposureDetector = new EntityExposureDetector()
|
||||
const dependencyDirectionDetector: IDependencyDirectionDetector =
|
||||
new DependencyDirectionDetector()
|
||||
const repositoryPatternDetector: IRepositoryPatternDetector = new RepositoryPatternDetector()
|
||||
const useCase = new AnalyzeProject(
|
||||
fileScanner,
|
||||
codeParser,
|
||||
@@ -81,6 +84,7 @@ export async function analyzeProject(
|
||||
frameworkLeakDetector,
|
||||
entityExposureDetector,
|
||||
dependencyDirectionDetector,
|
||||
repositoryPatternDetector,
|
||||
)
|
||||
|
||||
const result = await useCase.execute(options)
|
||||
@@ -102,5 +106,6 @@ export type {
|
||||
FrameworkLeakViolation,
|
||||
EntityExposureViolation,
|
||||
DependencyDirectionViolation,
|
||||
RepositoryPatternViolation,
|
||||
ProjectMetrics,
|
||||
} from "./application/use-cases/AnalyzeProject"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { INamingConventionDetector } from "../../domain/services/INamingConventi
|
||||
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 { DependencyGraph } from "../../domain/entities/DependencyGraph"
|
||||
import { ProjectPath } from "../../domain/value-objects/ProjectPath"
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
LAYERS,
|
||||
NAMING_VIOLATION_TYPES,
|
||||
REGEX_PATTERNS,
|
||||
REPOSITORY_VIOLATION_TYPES,
|
||||
RULES,
|
||||
SEVERITY_LEVELS,
|
||||
} from "../../shared/constants"
|
||||
@@ -36,6 +38,7 @@ export interface AnalyzeProjectResponse {
|
||||
frameworkLeakViolations: FrameworkLeakViolation[]
|
||||
entityExposureViolations: EntityExposureViolation[]
|
||||
dependencyDirectionViolations: DependencyDirectionViolation[]
|
||||
repositoryPatternViolations: RepositoryPatternViolation[]
|
||||
metrics: ProjectMetrics
|
||||
}
|
||||
|
||||
@@ -122,6 +125,21 @@ export interface DependencyDirectionViolation {
|
||||
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 {
|
||||
totalFiles: number
|
||||
totalFunctions: number
|
||||
@@ -144,6 +162,7 @@ export class AnalyzeProject extends UseCase<
|
||||
private readonly frameworkLeakDetector: IFrameworkLeakDetector,
|
||||
private readonly entityExposureDetector: IEntityExposureDetector,
|
||||
private readonly dependencyDirectionDetector: IDependencyDirectionDetector,
|
||||
private readonly repositoryPatternDetector: IRepositoryPatternDetector,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@@ -195,6 +214,7 @@ export class AnalyzeProject extends UseCase<
|
||||
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)
|
||||
|
||||
return ResponseDto.ok({
|
||||
@@ -207,6 +227,7 @@ export class AnalyzeProject extends UseCase<
|
||||
frameworkLeakViolations,
|
||||
entityExposureViolations,
|
||||
dependencyDirectionViolations,
|
||||
repositoryPatternViolations,
|
||||
metrics,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -452,6 +473,39 @@ export class AnalyzeProject extends UseCase<
|
||||
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(
|
||||
sourceFiles: SourceFile[],
|
||||
totalFunctions: number,
|
||||
|
||||
@@ -33,7 +33,20 @@ export const CLI_ARGUMENTS = {
|
||||
PATH: "<path>",
|
||||
} 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 = {
|
||||
ANALYZING: "\n🛡️ Guardian - Analyzing your code...\n",
|
||||
|
||||
@@ -5,9 +5,11 @@ export * from "./value-objects/ValueObject"
|
||||
export * from "./value-objects/ProjectPath"
|
||||
export * from "./value-objects/HardcodedValue"
|
||||
export * from "./value-objects/NamingViolation"
|
||||
export * from "./value-objects/RepositoryViolation"
|
||||
export * from "./repositories/IBaseRepository"
|
||||
export * from "./services/IFileScanner"
|
||||
export * from "./services/ICodeParser"
|
||||
export * from "./services/IHardcodeDetector"
|
||||
export * from "./services/INamingConventionDetector"
|
||||
export * from "./services/RepositoryPatternDetectorService"
|
||||
export * from "./events/DomainEvent"
|
||||
|
||||
@@ -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,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,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 "./scanners/FileScanner"
|
||||
export * from "./analyzers/HardcodeDetector"
|
||||
export * from "./analyzers/RepositoryPatternDetector"
|
||||
|
||||
@@ -9,6 +9,7 @@ export const RULES = {
|
||||
FRAMEWORK_LEAK: "framework-leak",
|
||||
ENTITY_EXPOSURE: "entity-exposure",
|
||||
DEPENDENCY_DIRECTION: "dependency-direction",
|
||||
REPOSITORY_PATTERN: "repository-pattern",
|
||||
} as const
|
||||
|
||||
/**
|
||||
@@ -397,4 +398,15 @@ export const FRAMEWORK_LEAK_MESSAGES = {
|
||||
DOMAIN_IMPORT:
|
||||
'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.",
|
||||
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
|
||||
|
||||
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