mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class Product {
|
||||
public price: number
|
||||
|
||||
constructor(price: number) {
|
||||
this.price = price
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class User {
|
||||
public email: string
|
||||
|
||||
constructor(email: string) {
|
||||
this.email = email
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(", ")}`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user