fix: eliminate magic strings and fix aggregate boundary detection

- Extract DDD folder names and repository method suggestions to constants
- Fix regex pattern to support relative paths (domain/... without leading /)
- Add non-aggregate folder exclusions (constants, shared, factories, etc.)
- Remove findAll, exists, count from ORM_QUERY_METHODS (valid domain methods)
- Add exists, count, countBy patterns to domainMethodPatterns
- Add aggregate boundary test examples
This commit is contained in:
imfozilbek
2025-11-25 00:23:06 +05:00
parent c75738ba51
commit 8dd445995d
11 changed files with 251 additions and 36 deletions

View File

@@ -5,6 +5,79 @@ All notable changes to @samiyev/guardian will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.7.1] - 2025-11-25
### Fixed
- 🐛 **Aggregate Boundary Detection for relative paths:**
- Fixed regex pattern to support paths starting with `domain/` (without leading `/`)
- Now correctly detects violations in projects scanned from parent directories
- 🐛 **Reduced false positives in Repository Pattern detection:**
- Removed `findAll`, `exists`, `count` from ORM technical methods blacklist
- These are now correctly recognized as valid domain method names
- Added `exists`, `count`, `countBy[A-Z]` to domain method patterns
- 🐛 **Non-aggregate folder exclusions:**
- Added exclusions for standard DDD folders: `constants`, `shared`, `factories`, `ports`, `interfaces`
- Prevents false positives when domain layer has shared utilities
### Changed
- ♻️ **Extracted magic strings to constants:**
- DDD folder names (`entities`, `aggregates`, `value-objects`, etc.) moved to `DDD_FOLDER_NAMES`
- Repository method suggestions moved to `REPOSITORY_METHOD_SUGGESTIONS`
- Fallback suggestions moved to `REPOSITORY_FALLBACK_SUGGESTIONS`
### Added
- 📁 **Aggregate boundary test examples:**
- Added `examples/aggregate-boundary/domain/` with Order, User, Product aggregates
- Demonstrates cross-aggregate entity reference violations
## [0.7.0] - 2025-11-25
### Added
**🔒 Aggregate Boundary Validation**
New DDD feature to enforce aggregate boundaries and prevent tight coupling between aggregates.
-**Aggregate Boundary Detector:**
- Detects direct entity references across aggregate boundaries
- Validates that aggregates reference each other only by ID or Value Objects
- Supports multiple folder structure patterns:
- `domain/aggregates/order/Order.ts`
- `domain/order/Order.ts`
- `domain/entities/order/Order.ts`
-**Smart Import Analysis:**
- Parses ES6 imports and CommonJS require statements
- Identifies entity imports from other aggregates
- Allows imports from value-objects, events, services, specifications folders
-**Actionable Suggestions:**
- Reference by ID instead of entity
- Use Value Objects to store needed data from other aggregates
- Maintain aggregate independence
-**CLI Integration:**
- `--architecture` flag includes aggregate boundary checks
- CRITICAL severity for violations
- Detailed violation messages with file:line references
-**Test Coverage:**
- 41 new tests for aggregate boundary detection
- 333 total tests passing (100% pass rate)
- Examples in `examples/aggregate-boundary/`
### Technical
- New `AggregateBoundaryDetector` in infrastructure layer
- New `AggregateBoundaryViolation` value object in domain layer
- New `IAggregateBoundaryDetector` interface for dependency inversion
- Integrated into `AnalyzeProject` use case
## [0.6.4] - 2025-11-24 ## [0.6.4] - 2025-11-24
### Added ### Added

View File

@@ -0,0 +1,16 @@
import { User } from "../user/User"
import { Product } from "../product/Product"
export class Order {
private id: string
private user: User
private product: Product
private quantity: number
constructor(id: string, user: User, product: Product, quantity: number) {
this.id = id
this.user = user
this.product = product
this.quantity = quantity
}
}

View File

@@ -0,0 +1,7 @@
export class Product {
public price: number
constructor(price: number) {
this.price = price
}
}

View File

@@ -0,0 +1,7 @@
export class User {
public email: string
constructor(email: string) {
this.email = email
}
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@samiyev/guardian", "name": "@samiyev/guardian",
"version": "0.7.0", "version": "0.7.1",
"description": "Research-backed code quality guardian for AI-assisted development. Detects hardcodes, circular deps, framework leaks, entity exposure, and 8 architecture violations. Enforces Clean Architecture/DDD principles. Works with GitHub Copilot, Cursor, Windsurf, Claude, ChatGPT, Cline, and any AI coding tool.", "description": "Research-backed code quality guardian for AI-assisted development. Detects hardcodes, circular deps, framework leaks, entity exposure, and 8 architecture violations. Enforces Clean Architecture/DDD principles. Works with GitHub Copilot, Cursor, Windsurf, Claude, ChatGPT, Cline, and any AI coding tool.",
"keywords": [ "keywords": [
"puaros", "puaros",

View File

@@ -49,6 +49,10 @@ export const REPOSITORY_PATTERN_MESSAGES = {
SUGGESTION_QUERY: "find or search", SUGGESTION_QUERY: "find or search",
} }
export const REPOSITORY_FALLBACK_SUGGESTIONS = {
DEFAULT: "findById() or findByEmail()",
}
export const AGGREGATE_VIOLATION_MESSAGES = { export const AGGREGATE_VIOLATION_MESSAGES = {
USE_ID_REFERENCE: "1. Reference other aggregates by ID (UserId, OrderId) instead of entity", USE_ID_REFERENCE: "1. Reference other aggregates by ID (UserId, OrderId) instead of entity",
USE_VALUE_OBJECT: USE_VALUE_OBJECT:

View File

@@ -1,6 +1,6 @@
import { ValueObject } from "./ValueObject" import { ValueObject } from "./ValueObject"
import { REPOSITORY_VIOLATION_TYPES } from "../../shared/constants/rules" import { REPOSITORY_VIOLATION_TYPES } from "../../shared/constants/rules"
import { REPOSITORY_PATTERN_MESSAGES } from "../constants/Messages" import { REPOSITORY_FALLBACK_SUGGESTIONS, REPOSITORY_PATTERN_MESSAGES } from "../constants/Messages"
interface RepositoryViolationProps { interface RepositoryViolationProps {
readonly violationType: readonly violationType:
@@ -192,7 +192,7 @@ export class RepositoryViolation extends ValueObject<RepositoryViolationProps> {
const fallbackSuggestion = const fallbackSuggestion =
technicalToDomain[this.props.methodName as keyof typeof technicalToDomain] technicalToDomain[this.props.methodName as keyof typeof technicalToDomain]
const finalSuggestion = const finalSuggestion =
smartSuggestion || fallbackSuggestion || "findById() or findByEmail()" smartSuggestion || fallbackSuggestion || REPOSITORY_FALLBACK_SUGGESTIONS.DEFAULT
return [ return [
REPOSITORY_PATTERN_MESSAGES.STEP_RENAME_METHOD, REPOSITORY_PATTERN_MESSAGES.STEP_RENAME_METHOD,

View File

@@ -2,6 +2,7 @@ import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoun
import { AggregateBoundaryViolation } from "../../domain/value-objects/AggregateBoundaryViolation" import { AggregateBoundaryViolation } from "../../domain/value-objects/AggregateBoundaryViolation"
import { LAYERS } from "../../shared/constants/rules" import { LAYERS } from "../../shared/constants/rules"
import { IMPORT_PATTERNS } from "../constants/paths" import { IMPORT_PATTERNS } from "../constants/paths"
import { DDD_FOLDER_NAMES } from "../constants/detectorPatterns"
/** /**
* Detects aggregate boundary violations in Domain-Driven Design * Detects aggregate boundary violations in Domain-Driven Design
@@ -37,16 +38,37 @@ import { IMPORT_PATTERNS } from "../constants/paths"
* ``` * ```
*/ */
export class AggregateBoundaryDetector implements IAggregateBoundaryDetector { export class AggregateBoundaryDetector implements IAggregateBoundaryDetector {
private readonly entityFolderNames = new Set(["entities", "aggregates"]) private readonly entityFolderNames = new Set<string>([
private readonly valueObjectFolderNames = new Set(["value-objects", "vo"]) DDD_FOLDER_NAMES.ENTITIES,
private readonly allowedFolderNames = new Set([ DDD_FOLDER_NAMES.AGGREGATES,
"value-objects", ])
"vo", private readonly valueObjectFolderNames = new Set<string>([
"events", DDD_FOLDER_NAMES.VALUE_OBJECTS,
"domain-events", DDD_FOLDER_NAMES.VO,
"repositories", ])
"services", private readonly allowedFolderNames = new Set<string>([
"specifications", DDD_FOLDER_NAMES.VALUE_OBJECTS,
DDD_FOLDER_NAMES.VO,
DDD_FOLDER_NAMES.EVENTS,
DDD_FOLDER_NAMES.DOMAIN_EVENTS,
DDD_FOLDER_NAMES.REPOSITORIES,
DDD_FOLDER_NAMES.SERVICES,
DDD_FOLDER_NAMES.SPECIFICATIONS,
])
private readonly nonAggregateFolderNames = new Set<string>([
DDD_FOLDER_NAMES.VALUE_OBJECTS,
DDD_FOLDER_NAMES.VO,
DDD_FOLDER_NAMES.EVENTS,
DDD_FOLDER_NAMES.DOMAIN_EVENTS,
DDD_FOLDER_NAMES.REPOSITORIES,
DDD_FOLDER_NAMES.SERVICES,
DDD_FOLDER_NAMES.SPECIFICATIONS,
DDD_FOLDER_NAMES.ENTITIES,
DDD_FOLDER_NAMES.CONSTANTS,
DDD_FOLDER_NAMES.SHARED,
DDD_FOLDER_NAMES.FACTORIES,
DDD_FOLDER_NAMES.PORTS,
DDD_FOLDER_NAMES.INTERFACES,
]) ])
/** /**
@@ -120,12 +142,13 @@ export class AggregateBoundaryDetector implements IAggregateBoundaryDetector {
public extractAggregateFromPath(filePath: string): string | undefined { public extractAggregateFromPath(filePath: string): string | undefined {
const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/") const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/")
const domainMatch = /\/domain\//.exec(normalizedPath) const domainMatch = /(?:^|\/)(domain)\//.exec(normalizedPath)
if (!domainMatch) { if (!domainMatch) {
return undefined return undefined
} }
const pathAfterDomain = normalizedPath.substring(domainMatch.index + domainMatch[0].length) const domainEndIndex = domainMatch.index + domainMatch[0].length
const pathAfterDomain = normalizedPath.substring(domainEndIndex)
const segments = pathAfterDomain.split("/").filter(Boolean) const segments = pathAfterDomain.split("/").filter(Boolean)
if (segments.length < 2) { if (segments.length < 2) {
@@ -136,10 +159,18 @@ export class AggregateBoundaryDetector implements IAggregateBoundaryDetector {
if (segments.length < 3) { if (segments.length < 3) {
return undefined return undefined
} }
return segments[1] const aggregate = segments[1]
if (this.nonAggregateFolderNames.has(aggregate)) {
return undefined
}
return aggregate
} }
return segments[0] const aggregate = segments[0]
if (this.nonAggregateFolderNames.has(aggregate)) {
return undefined
}
return aggregate
} }
/** /**
@@ -225,11 +256,14 @@ export class AggregateBoundaryDetector implements IAggregateBoundaryDetector {
} }
for (let i = 0; i < segments.length; i++) { for (let i = 0; i < segments.length; i++) {
if (segments[i] === "domain" || segments[i] === "aggregates") { if (
segments[i] === DDD_FOLDER_NAMES.DOMAIN ||
segments[i] === DDD_FOLDER_NAMES.AGGREGATES
) {
if (i + 1 < segments.length) { if (i + 1 < segments.length) {
if ( if (
this.entityFolderNames.has(segments[i + 1]) || this.entityFolderNames.has(segments[i + 1]) ||
segments[i + 1] === "aggregates" segments[i + 1] === DDD_FOLDER_NAMES.AGGREGATES
) { ) {
if (i + 2 < segments.length) { if (i + 2 < segments.length) {
return segments[i + 2] return segments[i + 2]
@@ -248,7 +282,7 @@ export class AggregateBoundaryDetector implements IAggregateBoundaryDetector {
!this.entityFolderNames.has(secondLastSegment) && !this.entityFolderNames.has(secondLastSegment) &&
!this.valueObjectFolderNames.has(secondLastSegment) && !this.valueObjectFolderNames.has(secondLastSegment) &&
!this.allowedFolderNames.has(secondLastSegment) && !this.allowedFolderNames.has(secondLastSegment) &&
secondLastSegment !== "domain" secondLastSegment !== DDD_FOLDER_NAMES.DOMAIN
) { ) {
return secondLastSegment return secondLastSegment
} }

View File

@@ -3,6 +3,7 @@ import { RepositoryViolation } from "../../domain/value-objects/RepositoryViolat
import { LAYERS, REPOSITORY_VIOLATION_TYPES } from "../../shared/constants/rules" import { LAYERS, REPOSITORY_VIOLATION_TYPES } from "../../shared/constants/rules"
import { ORM_QUERY_METHODS } from "../constants/orm-methods" import { ORM_QUERY_METHODS } from "../constants/orm-methods"
import { REPOSITORY_PATTERN_MESSAGES } from "../../domain/constants/Messages" import { REPOSITORY_PATTERN_MESSAGES } from "../../domain/constants/Messages"
import { REPOSITORY_METHOD_SUGGESTIONS } from "../constants/detectorPatterns"
/** /**
* Detects Repository Pattern violations in the codebase * Detects Repository Pattern violations in the codebase
@@ -68,7 +69,7 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector {
private readonly domainMethodPatterns = [ private readonly domainMethodPatterns = [
/^findBy[A-Z]/, /^findBy[A-Z]/,
/^findAll/, /^findAll$/,
/^find[A-Z]/, /^find[A-Z]/,
/^save$/, /^save$/,
/^saveAll$/, /^saveAll$/,
@@ -83,11 +84,12 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector {
/^add$/, /^add$/,
/^add[A-Z]/, /^add[A-Z]/,
/^get[A-Z]/, /^get[A-Z]/,
/^getAll/, /^getAll$/,
/^search/, /^search/,
/^list/, /^list/,
/^has[A-Z]/, /^has[A-Z]/,
/^is[A-Z]/, /^is[A-Z]/,
/^exists$/,
/^exists[A-Z]/, /^exists[A-Z]/,
/^existsBy[A-Z]/, /^existsBy[A-Z]/,
/^clear[A-Z]/, /^clear[A-Z]/,
@@ -98,6 +100,8 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector {
/^close$/, /^close$/,
/^connect$/, /^connect$/,
/^disconnect$/, /^disconnect$/,
/^count$/,
/^countBy[A-Z]/,
] ]
private readonly concreteRepositoryPatterns = [ private readonly concreteRepositoryPatterns = [
@@ -254,15 +258,43 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector {
const suggestions: string[] = [] const suggestions: string[] = []
const suggestionMap: Record<string, string[]> = { const suggestionMap: Record<string, string[]> = {
query: ["search", "findBy[Property]"], query: [
select: ["findBy[Property]", "get[Entity]"], REPOSITORY_METHOD_SUGGESTIONS.SEARCH,
insert: ["create", "add[Entity]", "store[Entity]"], REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
update: ["update", "modify[Entity]"], ],
upsert: ["save", "store[Entity]"], select: [
remove: ["delete", "removeBy[Property]"], REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
fetch: ["findBy[Property]", "get[Entity]"], REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
retrieve: ["findBy[Property]", "get[Entity]"], ],
load: ["findBy[Property]", "get[Entity]"], insert: [
REPOSITORY_METHOD_SUGGESTIONS.CREATE,
REPOSITORY_METHOD_SUGGESTIONS.ADD_ENTITY,
REPOSITORY_METHOD_SUGGESTIONS.STORE_ENTITY,
],
update: [
REPOSITORY_METHOD_SUGGESTIONS.UPDATE,
REPOSITORY_METHOD_SUGGESTIONS.MODIFY_ENTITY,
],
upsert: [
REPOSITORY_METHOD_SUGGESTIONS.SAVE,
REPOSITORY_METHOD_SUGGESTIONS.STORE_ENTITY,
],
remove: [
REPOSITORY_METHOD_SUGGESTIONS.DELETE,
REPOSITORY_METHOD_SUGGESTIONS.REMOVE_BY_PROPERTY,
],
fetch: [
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
],
retrieve: [
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
],
load: [
REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY,
REPOSITORY_METHOD_SUGGESTIONS.GET_ENTITY,
],
} }
for (const [keyword, keywords] of Object.entries(suggestionMap)) { for (const [keyword, keywords] of Object.entries(suggestionMap)) {
@@ -272,11 +304,14 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector {
} }
if (lowerName.includes("get") && lowerName.includes("all")) { if (lowerName.includes("get") && lowerName.includes("all")) {
suggestions.push("findAll", "listAll") suggestions.push(
REPOSITORY_METHOD_SUGGESTIONS.FIND_ALL,
REPOSITORY_METHOD_SUGGESTIONS.LIST_ALL,
)
} }
if (suggestions.length === 0) { if (suggestions.length === 0) {
return "Use domain-specific names like: findBy[Property], save, create, delete, update, add[Entity]" return REPOSITORY_METHOD_SUGGESTIONS.DEFAULT_SUGGESTION
} }
return `Consider: ${suggestions.slice(0, 3).join(", ")}` return `Consider: ${suggestions.slice(0, 3).join(", ")}`

View File

@@ -64,3 +64,45 @@ export const NAMING_ERROR_MESSAGES = {
USE_VERB_NOUN: "Use verb + noun in PascalCase (e.g., CreateUser.ts, UpdateProfile.ts)", USE_VERB_NOUN: "Use verb + noun in PascalCase (e.g., CreateUser.ts, UpdateProfile.ts)",
USE_CASE_START_VERB: "Use cases should start with a verb", USE_CASE_START_VERB: "Use cases should start with a verb",
} as const } as const
/**
* DDD folder names for aggregate boundary detection
*/
export const DDD_FOLDER_NAMES = {
ENTITIES: "entities",
AGGREGATES: "aggregates",
VALUE_OBJECTS: "value-objects",
VO: "vo",
EVENTS: "events",
DOMAIN_EVENTS: "domain-events",
REPOSITORIES: "repositories",
SERVICES: "services",
SPECIFICATIONS: "specifications",
DOMAIN: "domain",
CONSTANTS: "constants",
SHARED: "shared",
FACTORIES: "factories",
PORTS: "ports",
INTERFACES: "interfaces",
} as const
/**
* Repository method suggestions for domain language
*/
export const REPOSITORY_METHOD_SUGGESTIONS = {
SEARCH: "search",
FIND_BY_PROPERTY: "findBy[Property]",
GET_ENTITY: "get[Entity]",
CREATE: "create",
ADD_ENTITY: "add[Entity]",
STORE_ENTITY: "store[Entity]",
UPDATE: "update",
MODIFY_ENTITY: "modify[Entity]",
SAVE: "save",
DELETE: "delete",
REMOVE_BY_PROPERTY: "removeBy[Property]",
FIND_ALL: "findAll",
LIST_ALL: "listAll",
DEFAULT_SUGGESTION:
"Use domain-specific names like: findBy[Property], save, create, delete, update, add[Entity]",
} as const

View File

@@ -2,7 +2,6 @@ export const ORM_QUERY_METHODS = [
"findOne", "findOne",
"findMany", "findMany",
"findFirst", "findFirst",
"findAll",
"findAndCountAll", "findAndCountAll",
"insert", "insert",
"insertMany", "insertMany",
@@ -17,8 +16,6 @@ export const ORM_QUERY_METHODS = [
"run", "run",
"exec", "exec",
"aggregate", "aggregate",
"count",
"exists",
] as const ] as const
export type OrmQueryMethod = (typeof ORM_QUERY_METHODS)[number] export type OrmQueryMethod = (typeof ORM_QUERY_METHODS)[number]