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/),
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

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

View File

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

View File

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

View File

@@ -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<string>([
DDD_FOLDER_NAMES.ENTITIES,
DDD_FOLDER_NAMES.AGGREGATES,
])
private readonly valueObjectFolderNames = new Set<string>([
DDD_FOLDER_NAMES.VALUE_OBJECTS,
DDD_FOLDER_NAMES.VO,
])
private readonly allowedFolderNames = 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,
])
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 {
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
}

View File

@@ -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<string, string[]> = {
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(", ")}`

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

View File

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