From 0534fdf1bd72da98ee9ab7c5f5c50467fc0e32eb Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Mon, 24 Nov 2025 20:11:33 +0500 Subject: [PATCH] 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 --- .../examples/repository-pattern/README.md | 220 ++++++++ .../bad-concrete-repository-in-use-case.ts | 67 +++ .../repository-pattern/bad-new-repository.ts | 69 +++ .../bad-orm-types-in-interface.ts | 26 + .../bad-technical-method-names.ts | 32 ++ .../good-clean-interface.ts | 61 +++ .../good-dependency-injection.ts | 98 ++++ .../good-domain-language.ts | 81 +++ .../good-interface-in-use-case.ts | 74 +++ packages/guardian/package.json | 2 +- packages/guardian/src/api.ts | 5 + .../application/use-cases/AnalyzeProject.ts | 54 ++ packages/guardian/src/cli/constants.ts | 15 +- packages/guardian/src/domain/index.ts | 2 + .../RepositoryPatternDetectorService.ts | 88 +++ .../value-objects/RepositoryViolation.ts | 288 ++++++++++ .../analyzers/RepositoryPatternDetector.ts | 387 +++++++++++++ .../constants/naming-patterns.ts | 2 + .../infrastructure/constants/orm-methods.ts | 24 + .../infrastructure/constants/type-patterns.ts | 28 + packages/guardian/src/infrastructure/index.ts | 1 + .../guardian/src/shared/constants/rules.ts | 12 + .../tests/RepositoryPatternDetector.test.ts | 515 ++++++++++++++++++ 23 files changed, 2149 insertions(+), 2 deletions(-) create mode 100644 packages/guardian/examples/repository-pattern/README.md create mode 100644 packages/guardian/examples/repository-pattern/bad-concrete-repository-in-use-case.ts create mode 100644 packages/guardian/examples/repository-pattern/bad-new-repository.ts create mode 100644 packages/guardian/examples/repository-pattern/bad-orm-types-in-interface.ts create mode 100644 packages/guardian/examples/repository-pattern/bad-technical-method-names.ts create mode 100644 packages/guardian/examples/repository-pattern/good-clean-interface.ts create mode 100644 packages/guardian/examples/repository-pattern/good-dependency-injection.ts create mode 100644 packages/guardian/examples/repository-pattern/good-domain-language.ts create mode 100644 packages/guardian/examples/repository-pattern/good-interface-in-use-case.ts create mode 100644 packages/guardian/src/domain/services/RepositoryPatternDetectorService.ts create mode 100644 packages/guardian/src/domain/value-objects/RepositoryViolation.ts create mode 100644 packages/guardian/src/infrastructure/analyzers/RepositoryPatternDetector.ts create mode 100644 packages/guardian/src/infrastructure/constants/naming-patterns.ts create mode 100644 packages/guardian/src/infrastructure/constants/orm-methods.ts create mode 100644 packages/guardian/src/infrastructure/constants/type-patterns.ts create mode 100644 packages/guardian/tests/RepositoryPatternDetector.test.ts diff --git a/packages/guardian/examples/repository-pattern/README.md b/packages/guardian/examples/repository-pattern/README.md new file mode 100644 index 0000000..fbfab04 --- /dev/null +++ b/packages/guardian/examples/repository-pattern/README.md @@ -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 + findByEmail(email: Email): Promise + save(user: User): Promise + delete(id: UserId): Promise +} +``` + +#### 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 { + // 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 + findByEmail(email: Email): Promise + findActiveUsers(): Promise + save(user: User): Promise + search(criteria: UserSearchCriteria): Promise +} +``` + +## 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 +} + +// ✅ Good: Domain uses own types +interface IUserRepository { + findById(id: UserId): Promise +} +``` + +### 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 + insert(user: User): Promise +} + +// ✅ Good: Domain language +interface IUserRepository { + findById(id: UserId): Promise + save(user: User): Promise +} +``` + +## 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) diff --git a/packages/guardian/examples/repository-pattern/bad-concrete-repository-in-use-case.ts b/packages/guardian/examples/repository-pattern/bad-concrete-repository-in-use-case.ts new file mode 100644 index 0000000..e5a5e88 --- /dev/null +++ b/packages/guardian/examples/repository-pattern/bad-concrete-repository-in-use-case.ts @@ -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 { + 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 { + 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(), + } + } +} diff --git a/packages/guardian/examples/repository-pattern/bad-new-repository.ts b/packages/guardian/examples/repository-pattern/bad-new-repository.ts new file mode 100644 index 0000000..5f30a9e --- /dev/null +++ b/packages/guardian/examples/repository-pattern/bad-new-repository.ts @@ -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 { + 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 { + console.warn("Saving user to database") + } +} + +class EmailService { + async sendWelcomeEmail(email: string): Promise { + 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(), + } + } +} diff --git a/packages/guardian/examples/repository-pattern/bad-orm-types-in-interface.ts b/packages/guardian/examples/repository-pattern/bad-orm-types-in-interface.ts new file mode 100644 index 0000000..d932095 --- /dev/null +++ b/packages/guardian/examples/repository-pattern/bad-orm-types-in-interface.ts @@ -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 + + findMany(query: Prisma.UserFindManyArgs): Promise + + create(data: Prisma.UserCreateInput): Promise + + update(id: string, data: Prisma.UserUpdateInput): Promise +} + +class User { + constructor( + public id: string, + public email: string, + public name: string, + ) {} +} diff --git a/packages/guardian/examples/repository-pattern/bad-technical-method-names.ts b/packages/guardian/examples/repository-pattern/bad-technical-method-names.ts new file mode 100644 index 0000000..af84996 --- /dev/null +++ b/packages/guardian/examples/repository-pattern/bad-technical-method-names.ts @@ -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 + + findMany(filter: any): Promise + + insert(user: User): Promise + + updateOne(id: string, data: any): Promise + + deleteOne(id: string): Promise + + query(sql: string): Promise + + execute(command: string): Promise + + select(fields: string[]): Promise +} + +class User { + constructor( + public id: string, + public email: string, + public name: string, + ) {} +} diff --git a/packages/guardian/examples/repository-pattern/good-clean-interface.ts b/packages/guardian/examples/repository-pattern/good-clean-interface.ts new file mode 100644 index 0000000..15658fb --- /dev/null +++ b/packages/guardian/examples/repository-pattern/good-clean-interface.ts @@ -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 + + findByEmail(email: Email): Promise + + save(user: User): Promise + + delete(id: UserId): Promise + + findAll(criteria: UserSearchCriteria): Promise +} + +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 + } +} diff --git a/packages/guardian/examples/repository-pattern/good-dependency-injection.ts b/packages/guardian/examples/repository-pattern/good-dependency-injection.ts new file mode 100644 index 0000000..e45fae9 --- /dev/null +++ b/packages/guardian/examples/repository-pattern/good-dependency-injection.ts @@ -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 { + 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 + findByEmail(email: Email): Promise +} + +interface IEmailService { + sendWelcomeEmail(email: Email): Promise +} + +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 { + console.warn("Saving user to database") + } + + async findByEmail(_email: Email): Promise { + return null + } +} + +class EmailService implements IEmailService { + async sendWelcomeEmail(email: Email): Promise { + console.warn(`Sending welcome email to ${email.getValue()}`) + } +} diff --git a/packages/guardian/examples/repository-pattern/good-domain-language.ts b/packages/guardian/examples/repository-pattern/good-domain-language.ts new file mode 100644 index 0000000..28db687 --- /dev/null +++ b/packages/guardian/examples/repository-pattern/good-domain-language.ts @@ -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 + + findByEmail(email: Email): Promise + + findActiveUsers(): Promise + + save(user: User): Promise + + delete(id: UserId): Promise + + search(criteria: UserSearchCriteria): Promise + + countActiveUsers(): Promise + + existsByEmail(email: Email): Promise +} + +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 + } +} diff --git a/packages/guardian/examples/repository-pattern/good-interface-in-use-case.ts b/packages/guardian/examples/repository-pattern/good-interface-in-use-case.ts new file mode 100644 index 0000000..7fbaf42 --- /dev/null +++ b/packages/guardian/examples/repository-pattern/good-interface-in-use-case.ts @@ -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 { + 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 + findByEmail(email: Email): Promise +} + +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(), + } + } +} diff --git a/packages/guardian/package.json b/packages/guardian/package.json index 6fd1d4d..3444e5d 100644 --- a/packages/guardian/package.json +++ b/packages/guardian/package.json @@ -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", diff --git a/packages/guardian/src/api.ts b/packages/guardian/src/api.ts index d6ded8e..b9b31c1 100644 --- a/packages/guardian/src/api.ts +++ b/packages/guardian/src/api.ts @@ -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" diff --git a/packages/guardian/src/application/use-cases/AnalyzeProject.ts b/packages/guardian/src/application/use-cases/AnalyzeProject.ts index 9c55fe2..4e69ed6 100644 --- a/packages/guardian/src/application/use-cases/AnalyzeProject.ts +++ b/packages/guardian/src/application/use-cases/AnalyzeProject.ts @@ -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, diff --git a/packages/guardian/src/cli/constants.ts b/packages/guardian/src/cli/constants.ts index 05e0665..64384a1 100644 --- a/packages/guardian/src/cli/constants.ts +++ b/packages/guardian/src/cli/constants.ts @@ -33,7 +33,20 @@ export const CLI_ARGUMENTS = { 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", diff --git a/packages/guardian/src/domain/index.ts b/packages/guardian/src/domain/index.ts index 64f8fa2..da46886 100644 --- a/packages/guardian/src/domain/index.ts +++ b/packages/guardian/src/domain/index.ts @@ -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" diff --git a/packages/guardian/src/domain/services/RepositoryPatternDetectorService.ts b/packages/guardian/src/domain/services/RepositoryPatternDetectorService.ts new file mode 100644 index 0000000..20657e4 --- /dev/null +++ b/packages/guardian/src/domain/services/RepositoryPatternDetectorService.ts @@ -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 +} diff --git a/packages/guardian/src/domain/value-objects/RepositoryViolation.ts b/packages/guardian/src/domain/value-objects/RepositoryViolation.ts new file mode 100644 index 0000000..ec2e467 --- /dev/null +++ b/packages/guardian/src/domain/value-objects/RepositoryViolation.ts @@ -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 { + 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 + save(user: User): Promise + delete(id: UserId): Promise +}` + } + + 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 + save(user: User): Promise + findByEmail(email: Email): Promise +}` + } +} diff --git a/packages/guardian/src/infrastructure/analyzers/RepositoryPatternDetector.ts b/packages/guardian/src/infrastructure/analyzers/RepositoryPatternDetector.ts new file mode 100644 index 0000000..1a1ac68 --- /dev/null +++ b/packages/guardian/src/infrastructure/analyzers/RepositoryPatternDetector.ts @@ -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 + * } + * ` + * 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 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 + } +} diff --git a/packages/guardian/src/infrastructure/constants/naming-patterns.ts b/packages/guardian/src/infrastructure/constants/naming-patterns.ts new file mode 100644 index 0000000..e6f6cbd --- /dev/null +++ b/packages/guardian/src/infrastructure/constants/naming-patterns.ts @@ -0,0 +1,2 @@ +export const NAMING_SUGGESTION_DEFAULT = + "Move to application or infrastructure layer, or rename to follow domain patterns" diff --git a/packages/guardian/src/infrastructure/constants/orm-methods.ts b/packages/guardian/src/infrastructure/constants/orm-methods.ts new file mode 100644 index 0000000..fbc0baf --- /dev/null +++ b/packages/guardian/src/infrastructure/constants/orm-methods.ts @@ -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] diff --git a/packages/guardian/src/infrastructure/constants/type-patterns.ts b/packages/guardian/src/infrastructure/constants/type-patterns.ts new file mode 100644 index 0000000..26161d5 --- /dev/null +++ b/packages/guardian/src/infrastructure/constants/type-patterns.ts @@ -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 diff --git a/packages/guardian/src/infrastructure/index.ts b/packages/guardian/src/infrastructure/index.ts index 1f7f592..aa309ce 100644 --- a/packages/guardian/src/infrastructure/index.ts +++ b/packages/guardian/src/infrastructure/index.ts @@ -1,3 +1,4 @@ export * from "./parsers/CodeParser" export * from "./scanners/FileScanner" export * from "./analyzers/HardcodeDetector" +export * from "./analyzers/RepositoryPatternDetector" diff --git a/packages/guardian/src/shared/constants/rules.ts b/packages/guardian/src/shared/constants/rules.ts index 8656383..9856cf3 100644 --- a/packages/guardian/src/shared/constants/rules.ts +++ b/packages/guardian/src/shared/constants/rules.ts @@ -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 diff --git a/packages/guardian/tests/RepositoryPatternDetector.test.ts b/packages/guardian/tests/RepositoryPatternDetector.test.ts new file mode 100644 index 0000000..68a8762 --- /dev/null +++ b/packages/guardian/tests/RepositoryPatternDetector.test.ts @@ -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 + create(data: Prisma.UserCreateInput): Promise +} +` + 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 +} +` + 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): Promise + findOne(query: Schema): Promise +} +` + 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 + save(user: User): Promise + delete(id: UserId): Promise +} +` + 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 + findMany(filter: any): Promise + insert(user: User): Promise + updateOne(id: string, data: any): Promise +} +` + 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 + findByEmail(email: Email): Promise + save(user: User): Promise + delete(id: UserId): Promise + search(criteria: SearchCriteria): Promise +} +` + 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 + query(filter: any): Promise + execute(sql: string): Promise +} +` + 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")).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 +} +` + 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 +} +` + 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 + insert(user: User): Promise + findById(id: UserId): Promise +} +` + 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) + }) + }) +})