From 8dd445995d1cb1765f2dd3190fb339f0a5d23c4a Mon Sep 17 00:00:00 2001 From: imfozilbek Date: Tue, 25 Nov 2025 00:23:06 +0500 Subject: [PATCH] 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 --- packages/guardian/CHANGELOG.md | 73 +++++++++++++++++++ .../domain/aggregates/order/Order.ts | 16 ++++ .../domain/aggregates/product/Product.ts | 7 ++ .../domain/aggregates/user/User.ts | 7 ++ packages/guardian/package.json | 2 +- .../guardian/src/domain/constants/Messages.ts | 4 + .../value-objects/RepositoryViolation.ts | 4 +- .../analyzers/AggregateBoundaryDetector.ts | 68 ++++++++++++----- .../analyzers/RepositoryPatternDetector.ts | 61 ++++++++++++---- .../constants/detectorPatterns.ts | 42 +++++++++++ .../infrastructure/constants/orm-methods.ts | 3 - 11 files changed, 251 insertions(+), 36 deletions(-) create mode 100644 packages/guardian/examples/aggregate-boundary/domain/aggregates/order/Order.ts create mode 100644 packages/guardian/examples/aggregate-boundary/domain/aggregates/product/Product.ts create mode 100644 packages/guardian/examples/aggregate-boundary/domain/aggregates/user/User.ts diff --git a/packages/guardian/CHANGELOG.md b/packages/guardian/CHANGELOG.md index 946aeeb..2382750 100644 --- a/packages/guardian/CHANGELOG.md +++ b/packages/guardian/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/packages/guardian/examples/aggregate-boundary/domain/aggregates/order/Order.ts b/packages/guardian/examples/aggregate-boundary/domain/aggregates/order/Order.ts new file mode 100644 index 0000000..88cc320 --- /dev/null +++ b/packages/guardian/examples/aggregate-boundary/domain/aggregates/order/Order.ts @@ -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 + } +} diff --git a/packages/guardian/examples/aggregate-boundary/domain/aggregates/product/Product.ts b/packages/guardian/examples/aggregate-boundary/domain/aggregates/product/Product.ts new file mode 100644 index 0000000..e8f0d4a --- /dev/null +++ b/packages/guardian/examples/aggregate-boundary/domain/aggregates/product/Product.ts @@ -0,0 +1,7 @@ +export class Product { + public price: number + + constructor(price: number) { + this.price = price + } +} diff --git a/packages/guardian/examples/aggregate-boundary/domain/aggregates/user/User.ts b/packages/guardian/examples/aggregate-boundary/domain/aggregates/user/User.ts new file mode 100644 index 0000000..703ac89 --- /dev/null +++ b/packages/guardian/examples/aggregate-boundary/domain/aggregates/user/User.ts @@ -0,0 +1,7 @@ +export class User { + public email: string + + constructor(email: string) { + this.email = email + } +} diff --git a/packages/guardian/package.json b/packages/guardian/package.json index 12ad8de..a51bd63 100644 --- a/packages/guardian/package.json +++ b/packages/guardian/package.json @@ -1,6 +1,6 @@ { "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.", "keywords": [ "puaros", diff --git a/packages/guardian/src/domain/constants/Messages.ts b/packages/guardian/src/domain/constants/Messages.ts index 86afcbf..d30cdd5 100644 --- a/packages/guardian/src/domain/constants/Messages.ts +++ b/packages/guardian/src/domain/constants/Messages.ts @@ -49,6 +49,10 @@ export const REPOSITORY_PATTERN_MESSAGES = { SUGGESTION_QUERY: "find or search", } +export const REPOSITORY_FALLBACK_SUGGESTIONS = { + DEFAULT: "findById() or findByEmail()", +} + export const AGGREGATE_VIOLATION_MESSAGES = { USE_ID_REFERENCE: "1. Reference other aggregates by ID (UserId, OrderId) instead of entity", USE_VALUE_OBJECT: diff --git a/packages/guardian/src/domain/value-objects/RepositoryViolation.ts b/packages/guardian/src/domain/value-objects/RepositoryViolation.ts index e7758d5..628f80f 100644 --- a/packages/guardian/src/domain/value-objects/RepositoryViolation.ts +++ b/packages/guardian/src/domain/value-objects/RepositoryViolation.ts @@ -1,6 +1,6 @@ import { ValueObject } from "./ValueObject" 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 { readonly violationType: @@ -192,7 +192,7 @@ export class RepositoryViolation extends ValueObject { const fallbackSuggestion = technicalToDomain[this.props.methodName as keyof typeof technicalToDomain] const finalSuggestion = - smartSuggestion || fallbackSuggestion || "findById() or findByEmail()" + smartSuggestion || fallbackSuggestion || REPOSITORY_FALLBACK_SUGGESTIONS.DEFAULT return [ REPOSITORY_PATTERN_MESSAGES.STEP_RENAME_METHOD, diff --git a/packages/guardian/src/infrastructure/analyzers/AggregateBoundaryDetector.ts b/packages/guardian/src/infrastructure/analyzers/AggregateBoundaryDetector.ts index 6c93072..e49aa9e 100644 --- a/packages/guardian/src/infrastructure/analyzers/AggregateBoundaryDetector.ts +++ b/packages/guardian/src/infrastructure/analyzers/AggregateBoundaryDetector.ts @@ -2,6 +2,7 @@ import { IAggregateBoundaryDetector } from "../../domain/services/IAggregateBoun import { AggregateBoundaryViolation } from "../../domain/value-objects/AggregateBoundaryViolation" import { LAYERS } from "../../shared/constants/rules" import { IMPORT_PATTERNS } from "../constants/paths" +import { DDD_FOLDER_NAMES } from "../constants/detectorPatterns" /** * Detects aggregate boundary violations in Domain-Driven Design @@ -37,16 +38,37 @@ import { IMPORT_PATTERNS } from "../constants/paths" * ``` */ export class AggregateBoundaryDetector implements IAggregateBoundaryDetector { - private readonly entityFolderNames = new Set(["entities", "aggregates"]) - private readonly valueObjectFolderNames = new Set(["value-objects", "vo"]) - private readonly allowedFolderNames = new Set([ - "value-objects", - "vo", - "events", - "domain-events", - "repositories", - "services", - "specifications", + private readonly entityFolderNames = new Set([ + DDD_FOLDER_NAMES.ENTITIES, + DDD_FOLDER_NAMES.AGGREGATES, + ]) + private readonly valueObjectFolderNames = new Set([ + DDD_FOLDER_NAMES.VALUE_OBJECTS, + DDD_FOLDER_NAMES.VO, + ]) + private readonly allowedFolderNames = new Set([ + 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([ + 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 { const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/") - const domainMatch = /\/domain\//.exec(normalizedPath) + const domainMatch = /(?:^|\/)(domain)\//.exec(normalizedPath) if (!domainMatch) { 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) if (segments.length < 2) { @@ -136,10 +159,18 @@ export class AggregateBoundaryDetector implements IAggregateBoundaryDetector { if (segments.length < 3) { 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++) { - 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 ( this.entityFolderNames.has(segments[i + 1]) || - segments[i + 1] === "aggregates" + segments[i + 1] === DDD_FOLDER_NAMES.AGGREGATES ) { if (i + 2 < segments.length) { return segments[i + 2] @@ -248,7 +282,7 @@ export class AggregateBoundaryDetector implements IAggregateBoundaryDetector { !this.entityFolderNames.has(secondLastSegment) && !this.valueObjectFolderNames.has(secondLastSegment) && !this.allowedFolderNames.has(secondLastSegment) && - secondLastSegment !== "domain" + secondLastSegment !== DDD_FOLDER_NAMES.DOMAIN ) { return secondLastSegment } diff --git a/packages/guardian/src/infrastructure/analyzers/RepositoryPatternDetector.ts b/packages/guardian/src/infrastructure/analyzers/RepositoryPatternDetector.ts index 8ef0dd0..497225b 100644 --- a/packages/guardian/src/infrastructure/analyzers/RepositoryPatternDetector.ts +++ b/packages/guardian/src/infrastructure/analyzers/RepositoryPatternDetector.ts @@ -3,6 +3,7 @@ import { RepositoryViolation } from "../../domain/value-objects/RepositoryViolat 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" +import { REPOSITORY_METHOD_SUGGESTIONS } from "../constants/detectorPatterns" /** * Detects Repository Pattern violations in the codebase @@ -68,7 +69,7 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector { private readonly domainMethodPatterns = [ /^findBy[A-Z]/, - /^findAll/, + /^findAll$/, /^find[A-Z]/, /^save$/, /^saveAll$/, @@ -83,11 +84,12 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector { /^add$/, /^add[A-Z]/, /^get[A-Z]/, - /^getAll/, + /^getAll$/, /^search/, /^list/, /^has[A-Z]/, /^is[A-Z]/, + /^exists$/, /^exists[A-Z]/, /^existsBy[A-Z]/, /^clear[A-Z]/, @@ -98,6 +100,8 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector { /^close$/, /^connect$/, /^disconnect$/, + /^count$/, + /^countBy[A-Z]/, ] private readonly concreteRepositoryPatterns = [ @@ -254,15 +258,43 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector { const suggestions: string[] = [] const suggestionMap: Record = { - query: ["search", "findBy[Property]"], - select: ["findBy[Property]", "get[Entity]"], - insert: ["create", "add[Entity]", "store[Entity]"], - update: ["update", "modify[Entity]"], - upsert: ["save", "store[Entity]"], - remove: ["delete", "removeBy[Property]"], - fetch: ["findBy[Property]", "get[Entity]"], - retrieve: ["findBy[Property]", "get[Entity]"], - load: ["findBy[Property]", "get[Entity]"], + query: [ + REPOSITORY_METHOD_SUGGESTIONS.SEARCH, + REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY, + ], + select: [ + REPOSITORY_METHOD_SUGGESTIONS.FIND_BY_PROPERTY, + REPOSITORY_METHOD_SUGGESTIONS.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)) { @@ -272,11 +304,14 @@ export class RepositoryPatternDetector implements IRepositoryPatternDetector { } 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) { - 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(", ")}` diff --git a/packages/guardian/src/infrastructure/constants/detectorPatterns.ts b/packages/guardian/src/infrastructure/constants/detectorPatterns.ts index 794f6b6..30253f5 100644 --- a/packages/guardian/src/infrastructure/constants/detectorPatterns.ts +++ b/packages/guardian/src/infrastructure/constants/detectorPatterns.ts @@ -64,3 +64,45 @@ export const NAMING_ERROR_MESSAGES = { 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", } 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 diff --git a/packages/guardian/src/infrastructure/constants/orm-methods.ts b/packages/guardian/src/infrastructure/constants/orm-methods.ts index fbc0baf..ea48fd8 100644 --- a/packages/guardian/src/infrastructure/constants/orm-methods.ts +++ b/packages/guardian/src/infrastructure/constants/orm-methods.ts @@ -2,7 +2,6 @@ export const ORM_QUERY_METHODS = [ "findOne", "findMany", "findFirst", - "findAll", "findAndCountAll", "insert", "insertMany", @@ -17,8 +16,6 @@ export const ORM_QUERY_METHODS = [ "run", "exec", "aggregate", - "count", - "exists", ] as const export type OrmQueryMethod = (typeof ORM_QUERY_METHODS)[number]