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:
imfozilbek
2025-11-24 20:11:33 +05:00
parent 3fecc98676
commit 0534fdf1bd
23 changed files with 2149 additions and 2 deletions

View File

@@ -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"

View File

@@ -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,

View File

@@ -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",

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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>
}`
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,2 @@
export const NAMING_SUGGESTION_DEFAULT =
"Move to application or infrastructure layer, or rename to follow domain patterns"

View File

@@ -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]

View File

@@ -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

View File

@@ -1,3 +1,4 @@
export * from "./parsers/CodeParser"
export * from "./scanners/FileScanner"
export * from "./analyzers/HardcodeDetector"
export * from "./analyzers/RepositoryPatternDetector"

View File

@@ -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