From 04a2306eefe7b1107bccb97e3edfb9927663d48f Mon Sep 17 00:00:00 2001 From: Ahmad Bilal Date: Mon, 30 Mar 2026 10:17:27 +0500 Subject: [PATCH 1/2] feat: add hook based context injection for ai agents --- .claude/commands/pr-review.md | 126 +++++ .claude/settings.json | 37 ++ AGENTS.md | 171 ++++++ docs/agent-hooks-guide.md | 603 +++++++++++++++++++++ docs/cross-cutting/exceptions.md | 79 +++ docs/cross-cutting/file-placement.md | 71 +++ docs/layers/controller.md | 62 +++ docs/layers/domain.md | 50 ++ docs/layers/dto.md | 50 ++ docs/layers/index.md | 12 + docs/layers/mapper.md | 57 ++ docs/layers/repository.md | 65 +++ docs/layers/service.md | 69 +++ scripts/arch-validate.sh | 159 ++++++ scripts/hooks/base.mjs | 89 +++ scripts/hooks/inject-code-context.mjs | 107 ++++ scripts/hooks/inject-structure-context.mjs | 120 ++++ scripts/inject-context.mjs | 38 ++ 18 files changed, 1965 insertions(+) create mode 100644 .claude/commands/pr-review.md create mode 100644 .claude/settings.json create mode 100644 AGENTS.md create mode 100644 docs/agent-hooks-guide.md create mode 100644 docs/cross-cutting/exceptions.md create mode 100644 docs/cross-cutting/file-placement.md create mode 100644 docs/layers/controller.md create mode 100644 docs/layers/domain.md create mode 100644 docs/layers/dto.md create mode 100644 docs/layers/index.md create mode 100644 docs/layers/mapper.md create mode 100644 docs/layers/repository.md create mode 100644 docs/layers/service.md create mode 100755 scripts/arch-validate.sh create mode 100644 scripts/hooks/base.mjs create mode 100644 scripts/hooks/inject-code-context.mjs create mode 100644 scripts/hooks/inject-structure-context.mjs create mode 100644 scripts/inject-context.mjs diff --git a/.claude/commands/pr-review.md b/.claude/commands/pr-review.md new file mode 100644 index 0000000..42e9f83 --- /dev/null +++ b/.claude/commands/pr-review.md @@ -0,0 +1,126 @@ +You are an expert code reviewer evaluating pull requests across architecture, business logic correctness, performance, security, and style. Your goal is to surface high-impact issues that could cause bugs, scalability problems, security risks, or long-term maintainability concerns, while respecting existing patterns and author intent. Assume good intent. Do not nitpick. Comment only when confident. Focus on systemic issues and keep feedback concise, clear, and actionable. + +## Step 1: Check for Previous Reviews + +Before anything else, check if a prior review exists: +``` +cat reviews/pr-$ARGUMENTS.md 2>/dev/null +``` + +If a previous review file exists: +- Load it as full context. +- Identify every issue that was flagged (Critical, Major, Minor). +- In your new review, explicitly track each prior issue: was it **Fixed**, **Partially Fixed**, **Not Addressed**, or **No Longer Applicable**? +- Do not re-flag issues that are fully resolved. Focus new comments on remaining issues and any newly introduced problems. + +If no previous review exists, this is a first review — proceed normally. + +## Step 2: Fetch PR Details + +``` +gh pr view $ARGUMENTS --json title,body,author,baseRefName,headRefName,additions,deletions,changedFiles +gh pr diff $ARGUMENTS +gh pr view $ARGUMENTS --json files --jq '.files[].path' +``` + +## Phase 1: Build Context +- Review architecture and contribution docs (README.md, CONTRIBUTING.md, AGENTS.md, docs/). +- Identify project type, language, framework, runtime. +- Understand module/package/workspace structure, architectural layers, and data flow. + +## Phase 2: Architecture & Structure +Evaluate fit with existing architecture: +- Project structure: repository organization, directory layout, separation of concerns (frontend/backend/shared/domain), naming conventions, proper code placement (models, services, controllers, components, utilities). +- Patterns: layered architecture (presentation/business/data), DDD (entities/services/repositories/adapters), MVC/MVVM, modular or microservice boundaries. +Ask: +- Are boundaries respected? +- Do layers communicate appropriately? +- Do dependencies flow the right direction? +- Is business logic isolated from infrastructure/framework code? +Flag: +- Layer violations, skipping layers, circular dependencies, tight coupling. +- Mixed concerns (business logic in UI, UI logic in data layer). +- Incorrect file placement or naming. +- Introduced, undocumented, or conflicting patterns. + +## Phase 3: Business Logic & Correctness +Review intended behavior and logic: +- Check alignment between PR description and implementation. +- Identify incorrect algorithms/data structures, race conditions, concurrency issues. +- Watch for incorrect assumptions about mutability or side effects. +- Detect misuse of APIs beyond their intended design. +Use context clues (filenames, variables, comments, docs). Comment only when you fully understand the intended behavior. Do not guess requirements. Focus on clear sources of incorrect behavior. Avoid nitpicking tradeoffs the author likely understands. + +## Phase 4: Performance +Assess scalability and efficiency: +- Time/space complexity concerns. +- N+1 queries, waterfall or unbatched network requests. +- Unnecessary recomputation, re-renders, duplicated state. +- Memory leaks or unbounded growth. +Consider expected production data size. Avoid performance flags for trivially small data. Align advice with the language, framework, and libraries. Balance performance with readability and maintainability. + +## Phase 5: Security +Evaluate security impact: +- Injection risks (SQL, XSS, command, etc.). +- Input validation/sanitization at trust boundaries. +- AuthN/AuthZ bypasses. +- Unsafe defaults (randomness, hashing, crypto). +- Secret leakage or misuse. +- Multi-tenant isolation issues. +Understand how auth and permissions work in the codebase. Distinguish frontend vs backend responsibilities. Follow env variable and secret management patterns. Do not flag public keys that are intentionally public. + +## Phase 6: Style & Readability +Focus on major issues: +- Egregious naming problems. +- Inconsistent formatting or indentation. +- Redundant or unnecessary code. +- Misleading, redundant, or missing comments. +- Typos and obvious polish issues. +Only flag style patterns that are clearly established elsewhere in the repo. If unsure, do not comment. + +## Feedback Guidelines +- Prioritize structural and systemic issues over minor implementation details. +- Be consistent across the PR. +- When flagging an issue, suggest a clear, practical alternative. +- Consider long-term maintainability and scale. +- Keep comments concise and actionable. + +## Output Format + +Structure your review using the template below, then save it by running: +``` +mkdir -p reviews && cat >> reviews/pr-$ARGUMENTS.md << 'REVIEW_EOF' +[your full review content here] +REVIEW_EOF +``` + +Use this template: + +## Review — [DATE] + +**PR #$ARGUMENTS — [Title]** +**Author:** [author] | **Base:** [base branch] | **Changes:** +[additions] -[deletions] across [N] files +**Round:** [1st Review / 2nd Review / Nth Review] + +### Summary +Brief 2-3 sentence overview of what the PR does (or what changed since last review). + +### Previous Issues Status *(skip on first review)* +| Issue | Status | +|-------|--------| +| [issue description from prior review] | ✅ Fixed / ⚠️ Partially Fixed / ❌ Not Addressed / N/A | + +### Critical Issues 🔴 +Issues that must be fixed before merging (bugs, security, data loss risk). + +### Major Issues 🟠 +Significant concerns around architecture, performance, or correctness. + +### Minor Issues 🟡 +Style, readability, and low-risk improvements. + +### Positives ✅ +What was done well (optional but encouraged). + +### Verdict +**Approve / Request Changes / Needs Discussion** — one sentence rationale. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..7c40bd8 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "allow": [ + "Bash(npm run *)", + "Bash(gh pr view:*)", + "Bash(gh pr diff:*)", + "Bash(gh pr comment:*)", + "Bash(gh pr review:*)", + "Bash(gh auth status:*)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "node $CLAUDE_PROJECT_DIR/scripts/inject-context.mjs" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "bash $CLAUDE_PROJECT_DIR/scripts/arch-validate.sh" + } + ] + } + ] + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f301dd8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,171 @@ +# AGENTS.md — booking-cms-apis + +NestJS hexagonal (Ports & Adapters) architecture. 30+ feature modules, each with identical internal structure. This file is loaded every session — keep it loaded and trust it. + +--- + +## Layer Map + +``` +src// +├── domain/ ← Pure TS classes, NO DB decorators +│ ├── .ts ← camelCase props, no imports from infra +│ └── queries/ ← Query result shapes (no DB deps) +├── dto/ ← class-validator decorated DTOs +│ ├── create-.dto.ts +│ ├── find-all-.dto.ts +│ └── update-.dto.ts +├── enums/ ← TypeScript enums only +├── infrastructure/ +│ └── persistence/ +│ ├── .abstract.repository.ts ← PORT (abstract class, interface) +│ └── relational/ +│ ├── entities/ ← TypeORM entities, snake_case columns +│ ├── mappers/ ← Static toDomain() / toPersistence() +│ ├── repositories/ ← ADAPTER (implements abstract repo) +│ ├── queries/ ← Raw SQL constants + query mappers +│ └── relational-persistence.module.ts +├── .controller.ts ← HTTP layer only +├── .service.ts ← Business logic, uses abstract repo +└── .module.ts +``` + +--- + +## Dependency Rules (NEVER violate) + +| Layer | Can import from | NEVER import from | +|---|---|---| +| `domain/` | `@src/utils/types/` | `infrastructure/`, `typeorm`, `@nestjs/typeorm`, other module's infra | +| `service.ts` | `domain/`, abstract repo, `@src/common/`, `@src/utils/` | TypeORM `Repository<>`, concrete repos, DB entities | +| Abstract repo | `domain/`, `@src/utils/types/` | TypeORM, DB entities, concrete repos | +| Concrete repo | DB entities, mappers, abstract repo, `@src/utils/` | Other modules' concrete repos | +| `mapper.ts` | domain entity, DB entity | services, controllers, other mappers | +| `controller.ts` | service, domain (for types), DTOs | repos, mappers, DB entities | + +--- + +## Import Alias + +All absolute imports use `@src/`: +```typescript +import { NOT_FOUND } from '@src/common/exceptions'; +import { IPaginationOptions } from '@src/utils/types/pagination-options'; +import { pagination } from '@src/utils/pagination'; +import { infinityPagination } from '@src/utils/infinity-pagination'; +import { runRawQueryOnReadReplica } from '@src/database-helpers/run-raw-query-on-read-replica'; +``` + +--- + +## Error Handling — ALWAYS use @src/common/exceptions + +```typescript +// ✅ Correct +import { NOT_FOUND, UNPROCESSABLE_ENTITY, BAD_REQUEST, FORBIDDEN } from '@src/common/exceptions'; +throw NOT_FOUND('Booking', { id }); +throw UNPROCESSABLE_ENTITY('Method not found on repository.', 'field'); +throw BAD_REQUEST('Invalid payload'); + +// ❌ Wrong — never throw raw NestJS exceptions in services +throw new NotFoundException({ ... }); +throw new BadRequestException('...'); +``` + +--- + +## Pagination — Two patterns + +```typescript +// infinityPagination: for list endpoints returning InfinityPaginationResponseDto +import { infinityPagination } from '@src/utils/infinity-pagination'; +return infinityPagination(await this.service.findAllWithPagination({ paginationOptions }), { page, limit }); + +// pagination: for raw-query endpoints returning PaginationResponseDto with total count +import { pagination } from '@src/utils/pagination'; +const { data, count } = await this.repo.findWithPagination({ paginationOptions, filter }); +return pagination(data, count, paginationOptions); +``` + +--- + +## Mapper Pattern — ALWAYS static, ALWAYS called on read + +```typescript +// ✅ Correct: static methods, always map before returning +return entity ? BookingMapper.toDomain(entity) : null; +return entities.map((e) => BookingMapper.toDomain(e)); + +// ❌ Wrong: returning raw DB entity from repo, or instantiating mapper +return entity; // never return un-mapped entity from a repo method +new BookingMapper(); // mappers are never instantiated +``` + +--- + +## Repository Pattern — Single-responsibility methods + +```typescript +// ✅ Correct: specific methods per query +async findByEmail(email: string): Promise> {} +async findByIds(ids: string[]): Promise {} + +// ❌ Wrong: universal/generic find +async find(condition: UniversalConditionInterface): Promise {} +``` + +--- + +## Raw Queries — Use runRawQueryOnReadReplica + +```typescript +import { runRawQueryOnReadReplica } from '@src/database-helpers/run-raw-query-on-read-replica'; +// Store SQL in src//infrastructure/persistence/relational/queries/-queries.const.ts +const data = await runRawQueryOnReadReplica(this.someRepository, SOME_QUERY, [param1]); +``` + +--- + +## Code Generation — Use Hygen for new modules + +```bash +npx hygen resource-entity new # generates full hexagonal scaffold +``` +See `docs/hygen/` for templates. + +--- + +## NEVER List (hard violations) + +- NEVER import TypeORM or DB entities into `domain/` files +- NEVER inject `@InjectRepository` or TypeORM `Repository<>` directly into a service +- NEVER return a raw DB entity from a repository method (always call Mapper.toDomain()) +- NEVER use `console.log/warn/error` — use NestJS Logger (`new Logger('ClassName')`) +- NEVER use `export default` anywhere +- NEVER throw raw NestJS exceptions — use helpers from `@src/common/exceptions` +- NEVER create universal/generic repository methods — one method per query shape + +--- + +## Routing Table (leaf docs for agents) + +| Working on... | Read | +|---|---| +| Layer conventions, file placement | `docs/layers/index.md` | +| Domain entities (`domain/*.ts`) | `docs/layers/domain.md` | +| Services (`*.service.ts`) | `docs/layers/service.md` | +| Repositories (abstract or concrete) | `docs/layers/repository.md` | +| Mappers (`*.mapper.ts`) | `docs/layers/mapper.md` | +| Controllers (`*.controller.ts`) | `docs/layers/controller.md` | +| DTOs (`*.dto.ts`) | `docs/layers/dto.md` | +| File placement / new files | `docs/cross-cutting/file-placement.md` | +| Exception handling | `docs/cross-cutting/exceptions.md` | +| Hook enforcement system | `docs/agent-hooks-guide.md` | + +--- + +## Enforcement Summary + +PreToolUse (before every Edit/Write): injects matching leaf docs + blocks bad file placements. +PostToolUse (after every Edit/Write): grep-based convention checks, exit 2 blocks if violated. +Agents self-correct before proceeding — violations never survive a session. diff --git a/docs/agent-hooks-guide.md b/docs/agent-hooks-guide.md new file mode 100644 index 0000000..5a0c082 --- /dev/null +++ b/docs/agent-hooks-guide.md @@ -0,0 +1,603 @@ +# Agent Hooks System — Implementation Guide + +This document covers the three-tier context injection system implemented for **booking-cms-apis**. +It shows exactly what happens without the system versus with it, using real patterns from this codebase. + +> Based on: [Hook-Based Context Injection for AI Coding Agents](https://andrewpatterson.dev/posts/agent-convention-enforcement-system/) by Andrew Patterson. + +--- + +## Table of Contents + +1. [What the System Is](#what-the-system-is) +2. [How Each Tier Works](#how-each-tier-works) +3. [The Real Problem: Convention Drift](#the-real-problem-convention-drift) +4. [Token Cost: Does This Use More Tokens?](#token-cost-does-this-use-more-tokens) +5. [Side-by-Side Examples](#side-by-side-examples) + - [Example 1: New Service Method](#example-1-new-service-method) + - [Example 2: New Domain Entity](#example-2-new-domain-entity) + - [Example 3: New Repository Method](#example-3-new-repository-method) + - [Example 4: New Controller Endpoint](#example-4-new-controller-endpoint) + - [Example 5: Wrong File Placement](#example-5-wrong-file-placement) +6. [Violation Categories](#violation-categories) +7. [Files Created by This System](#files-created-by-this-system) +7. [How to Test the System](#how-to-test-the-system) +8. [Extending the System](#extending-the-system) + +--- + +## What the System Is + +A three-tier enforcement system that keeps AI agents aligned with this project's hexagonal +architecture conventions across sessions, models, and agents. + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Tier 1: Hot Memory (AGENTS.md — loaded every session) │ +│ Layer map, dependency rules, import aliases, NEVER list │ +│ ~150 lines. Always present regardless of task. │ +├──────────────────────────────────────────────────────────────────────┤ +│ Tier 2: Cold Memory (docs/layers/ + docs/cross-cutting/) │ +│ Layer-specific conventions, canonical examples, landmines │ +│ ~30-60 lines per doc. Injected only when editing that layer. │ +├──────────────────────────────────────────────────────────────────────┤ +│ Tier 3: Runtime Enforcement (hooks — every Edit/Write) │ +│ PreToolUse: inject-context.mjs │ +│ 1. structureCheck — blocks new files in wrong dirs (Write) │ +│ 2. codeContext — injects ALL matching layer docs before edit │ +│ PostToolUse: arch-validate.sh │ +│ grep checks after edit, exit 2 blocks on violation │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +Without this system: ~40% convention compliance (documentation alone). Violations compound +across sessions — one agent's wrong pattern becomes the next agent's reference. + +With this system: violations are blocked or self-corrected before they land in git. + +--- + +## How Each Tier Works + +### Tier 1: AGENTS.md (hot memory) + +Loaded at session start via Claude Code's AGENTS.md detection. Contains: +- Complete layer map with folder structure +- Dependency rules (which layers can import from which) +- The `@src/` import alias +- Error handling (`@src/common/exceptions` — never raw NestJS exceptions) +- Pagination patterns (two helpers: `infinityPagination` vs `pagination`) +- The mapper pattern (always static, always called on read) +- NEVER list (hard blockers) +- Routing table pointing to leaf docs + +This is what the agent knows before it starts. The problem: as conversation grows, AGENTS.md +sinks into the "lost-in-the-middle" zone of the context window and loses influence. + +### Tier 2: Cold Memory (leaf docs in docs/layers/ and docs/cross-cutting/) + +Each doc has two sections: +- `## Inject` — 20-50 lines, auto-injected by the hook right before the agent edits +- `## Reference` — full detail, for humans and on-demand agent reads + +Injection fires at the highest-attention moment: just before the edit. Twenty focused lines +here outperform 200 lines read 20 minutes ago. + +Docs created for this project: +``` +docs/layers/ + index.md ← phonebook + domain.md ← pure TS entities, no DB deps + service.md ← abstract repo injection, exception helpers, pagination + repository.md ← abstract + concrete, mapper requirement, single-responsibility + mapper.md ← static toDomain/toPersistence, camelCase↔snake_case + controller.md ← decorators, pagination defaults, two response shapes + dto.md ← naming, validation decorators, FindAll pattern +docs/cross-cutting/ + file-placement.md ← module internal structure, blocked paths + exceptions.md ← all helpers with examples +``` + +### Tier 3: Runtime Enforcement + +**PreToolUse** (`scripts/inject-context.mjs`): runs before every Edit and Write. +- **structureCheck**: on Write to new files — blocks creation in wrong directories +- **codeContext**: all-matches routing — every matching doc injects, not just the first + +**PostToolUse** (`scripts/arch-validate.sh`): runs after every Edit and Write. +- grep checks on the modified file +- Exit 2 blocks the agent from proceeding until the violation is fixed + +Together: PreToolUse teaches and prevents. PostToolUse enforces. The agent cannot proceed with +a violation in place. + +--- + +## The Real Problem: Convention Drift + +This codebase has 30+ modules all following the hexagonal pattern. That pattern has non-obvious rules: + +1. Services inject the **abstract repository** (not the concrete one, not TypeORM directly) +2. Domain entities are **pure TypeScript** (zero TypeORM, zero infra imports) +3. Mappers are **always static** and always called before returning from a repo method +4. Errors use **project-specific helpers** (`NOT_FOUND`, not `throw new NotFoundException(...)`) +5. Pagination uses **two different helpers** depending on whether total count is needed +6. **`export default` is not used** — NestJS modules use named exports + +None of these are discoverable by reading one file. An agent that hasn't read the architecture +docs reaches for the generic TypeScript/NestJS pattern every time. Over 5 sessions with 3 +different agents, that's 5 opportunities to introduce drift. + +Each violation becomes precedent: the next agent sees existing code that uses the wrong pattern +and copies it. Drift compounds silently. + +--- + +## Token Cost: Does This Use More Tokens? + +Yes — but less than you'd think, and the tradeoff is heavily in your favor. + +### What the system adds per edit + +Each `## Inject` section is 20-50 lines. A service file edit injects ~4,000 characters +(service doc + exceptions doc), roughly **~1,000 tokens per edit**. + +Measured token usage across a 15-file editing session (from the original article): + +| Model | Total tokens | Tool uses | Wall time | Tokens per file | +|------------|--------------|-----------|-----------|-----------------| +| Haiku 4.5 | 136k | 41 | 2m 37s | ~9k | +| Sonnet 4.6 | 72k | 40 | 3m 23s | ~4.8k | + +Zero convention violations in both cases. + +### What the system saves + +The injection cost is **fixed and small**. The cost of drift is **compounding and unbounded**. + +| Scenario | Token cost | +|---|---| +| Injecting the right context upfront | ~1,000 tokens per edit | +| Wrong pattern → agent correction → re-attempt loop | 3–5× the injection cost | +| Violation lands in git, found in next session | Entire new session + manual fix | +| One wrong pattern becomes the reference for 5 future edits | 5× drift compounding | + +Without injection, agents also spend tokens asking clarifying questions, re-reading docs they +forgot from the start of the session, and producing attempts that get rolled back. Those tokens +are wasted. Injection tokens produce correct output on the first attempt. + +### Why injections are cheap compared to corrections + +A correction cycle looks like this: +1. Agent writes code with wrong pattern (~500 tokens) +2. PostToolUse fires, blocks with violation message (~50 tokens) +3. Agent reads the error, figures out the right pattern (~200 tokens) +4. Agent rewrites correctly (~500 tokens) + +Total: ~1,250 tokens to recover from one violation — more than just injecting upfront. +With 8 violation types enforced, the injection pays for itself on the first blocked violation. + +### The real cost of documentation-only (no hooks) + +Research measured ~40% convention compliance with documentation alone. In a 30-module +hexagonal codebase, that means roughly 6 out of every 10 cross-layer interactions have a +convention error. Each error is a silent, compounding cost — not a loud, correctable one. + +Tokens spent on drift are the most expensive kind: you pay them, get incorrect output, and +only find out later (in review, in production, or when the next agent copies the wrong pattern). + +--- + +## Side-by-Side Examples + +### Example 1: New Service Method + +**Task**: Add a `findByPropertyId` method to `BookingsService`. + +#### Without enforcement + +```typescript +// ❌ What an agent writes without context +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; + +import { BookingEntity } from './infrastructure/persistence/relational/entities/booking.entity'; + +@Injectable() +export class BookingsService { + constructor( + // ❌ Injecting TypeORM repository directly — bypasses the abstract repo port + @InjectRepository(BookingEntity) + private readonly bookingRepo: Repository, + ) {} + + async findByPropertyId(propertyId: number): Promise { + const results = await this.bookingRepo.find({ where: { property_id: propertyId } }); + + if (!results.length) { + // ❌ Raw NestJS exception — not the project helper + throw new NotFoundException(`No bookings found for property ${propertyId}`); + } + // ❌ Returning raw DB entities — exposes snake_case to the calling layer + return results; + } +} +``` + +**Problems**: +- Service directly injects TypeORM `Repository` — bypasses the hexagonal port +- Returns raw DB entity (`BookingEntity`) instead of domain entity (`Booking`) +- Uses `throw new NotFoundException(...)` instead of `NOT_FOUND('Booking', { propertyId })` +- Exposes `property_id` (snake_case) to the service layer — violates camelCase boundary + +**PostToolUse would block** on: `@InjectRepository`, `Repository`, +`throw new NotFoundException`. + +#### With enforcement + +The PreToolUse hook injects `docs/layers/service.md` (and `docs/cross-cutting/exceptions.md`) +before the agent edits. The agent now knows: +- Inject the abstract repository, not TypeORM +- Use `NOT_FOUND` from `@src/common/exceptions` +- The service operates on domain entities, not DB entities + +```typescript +// ✅ What the agent writes with context injection +import { Injectable } from '@nestjs/common'; + +import { NOT_FOUND } from '@src/common/exceptions'; + +import { Booking } from './domain/booking'; +import { BookingAbstractRepository } from './infrastructure/persistence/booking.abstract.repository'; + +@Injectable() +export class BookingsService { + constructor(private readonly bookingRepository: BookingAbstractRepository) {} + + async findByPropertyId(propertyId: Booking['propertyId']): Promise { + const bookings = await this.bookingRepository.findByPropertyId(propertyId); + if (!bookings.length) { + throw NOT_FOUND('Booking', { propertyId }); + } + return bookings; + } +} +``` + +**Violations prevented**: wrong injection pattern, raw exception, snake_case leakage. + +--- + +### Example 2: New Domain Entity + +**Task**: Add a `VendorPayoutSchedule` domain entity to the vendor-payouts module. + +#### Without enforcement + +```typescript +// ❌ What an agent writes without context +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; + +// ❌ TypeORM decorators on a domain entity — domain should be pure TypeScript +@Entity('vendor_payout_schedules') +export class VendorPayoutSchedule { + @PrimaryGeneratedColumn() + id: number; + + @Column() + vendor_id: number; // ❌ snake_case in domain entity + + @Column({ type: 'decimal' }) + payout_amount: number; // ❌ snake_case in domain entity + + @Column() + scheduled_date: Date; // ❌ snake_case in domain entity +} +``` + +**Problems**: +- Domain entity has TypeORM decorators — it's supposed to be pure TypeScript +- Uses snake_case property names — domain layer uses camelCase +- This file would make the domain layer depend on TypeORM (wrong dependency direction) + +**PostToolUse would block** on: TypeORM imports in a domain file. + +#### With enforcement + +PreToolUse injects `docs/layers/domain.md`. Agent knows: zero TypeORM, zero infra imports, +camelCase properties only. + +```typescript +// ✅ Correct domain entity +export class VendorPayoutSchedule { + id: number; + vendorId: number; // camelCase + payoutAmount: number; // camelCase + scheduledDate: Date; // camelCase + createdAt: Date; + updatedAt: Date; +} +``` + +**Violations prevented**: TypeORM in domain, snake_case properties, wrong file location. + +--- + +### Example 3: New Repository Method + +**Task**: Add `findByVendorId` to the vendor-payouts concrete repository. + +#### Without enforcement + +```typescript +// ❌ What an agent writes without context +async findByVendorId(vendorId: number) { + // ❌ Returns raw TypeORM entity — mapper is never called + return await this.vendorPayoutRepository.find({ + where: { vendor_id: vendorId }, + }); +} +``` + +**Problem**: Raw `VendorPayoutEntity[]` is returned instead of `VendorPayout[]` (domain). +The service layer receives snake_case data. The camelCase boundary is silently broken. +No error is thrown — it just works incorrectly. + +**PostToolUse catches this**: concrete repo has `async find` methods but no `.toDomain()` call. + +#### With enforcement + +PreToolUse injects `docs/layers/repository.md`. Agent knows: always call `Mapper.toDomain()` +before returning, use `NullableType` for single lookups. + +```typescript +// ✅ Correct +async findByVendorId(vendorId: VendorPayout['vendorId']): Promise { + const entities = await this.vendorPayoutRepository.find({ + where: { vendor_id: vendorId }, + }); + return entities.map((e) => VendorPayoutMapper.toDomain(e)); +} +``` + +**Violations prevented**: unmapped return, wrong return type, silent camelCase boundary break. + +--- + +### Example 4: New Controller Endpoint + +**Task**: Add a `GET /bookings/by-property/:propertyId` endpoint. + +#### Without enforcement + +```typescript +// ❌ What an agent writes without context +@Controller('bookings') +export class BookingsController { + constructor( + // ❌ Injecting service correctly, but... + private readonly bookingsService: BookingsService, + ) {} + + // ❌ Missing @ApiTags, @ApiBearerAuth, @UseGuards on the class + // ❌ Missing @ApiParam decorator + // ❌ No pagination — returns raw unbounded array + // ❌ No limit cap + @Get('by-property/:propertyId') + findByPropertyId(@Param('propertyId') propertyId: number) { + return this.bookingsService.findByPropertyId(propertyId); + } +} +``` + +**Problems**: missing Swagger decorators, no JWT guard, no pagination, no response type annotation. +The endpoint works but breaks Swagger docs and security posture. + +#### With enforcement + +PreToolUse injects `docs/layers/controller.md`. Agent knows: required decorators, pagination +defaults, limit cap of 50, `@ApiOkResponse` + `@ApiParam`. + +```typescript +// ✅ Correct +@ApiTags('Bookings') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller({ path: 'bookings', version: '1' }) +export class BookingsController { + constructor(private readonly bookingsService: BookingsService) {} + + @Get('by-property') + @ApiOkResponse({ type: InfinityPaginationResponse(Booking) }) + async findByPropertyId( + @Query() query: FindAllBookingsDto, + ): Promise> { + const page = query?.page ?? 1; + let limit = query?.limit ?? 10; + if (limit > 50) limit = 50; + + return infinityPagination( + await this.bookingsService.findAllWithPagination({ paginationOptions: { page, limit } }), + { page, limit }, + ); + } +} +``` + +**Violations prevented**: missing guards, missing Swagger annotations, unbounded array returns. + +--- + +### Example 5: Wrong File Placement + +**Task**: Add a `BookingHelpers` utility with date range validation. + +#### Without enforcement + +Agent creates: `src/bookings/helpers/booking-helpers.ts` + +This is a wrong path. The file is placed outside the hexagonal structure, creates a new +unlisted folder type, and breaks the convention that shared helpers go in `src/utils/`. + +No error is thrown. The file just sits there. Next agent copies the pattern. + +#### With enforcement + +**PreToolUse structureCheck fires on Write** (new file detection): + +``` +BLOCKED: src/bookings/helpers/booking-helpers.ts +Module-internal helpers go in src/utils/ or src/common/ — not in src//helpers/. +See docs/cross-cutting/file-placement.md for the full structure. +``` + +The file is **never created**. The agent must redirect to the correct path: +`src/utils/booking-date-utils.ts` or `src/common/booking-helpers.ts`. + +If somehow the file bypasses the PreToolUse check (e.g. via Bash), the PostToolUse +`arch-validate.sh` catches it: + +``` +Architecture violations in src/bookings/helpers/booking-helpers.ts: +❌ Wrong path: src//helpers/ — shared helpers go in src/utils/ or src/common/ +``` + +Exit 2 — agent cannot proceed until fixed. + +--- + +## Violation Categories + +| Category | What it catches | Tier | Enforcement | +|---|---|---|---| +| Domain imports TypeORM | `typeorm` in `domain/*.ts` | PostToolUse | Blocking (exit 2) | +| Domain imports infra | `infrastructure/` in `domain/*.ts` | PostToolUse | Blocking | +| Service injects TypeORM | `@InjectRepository` in `*.service.ts` | PostToolUse | Blocking | +| Service throws raw exceptions | `throw new NotFoundException` | PostToolUse | Blocking | +| Repo returns raw entities | No `toDomain()` in repo with find methods | PostToolUse | Blocking | +| Repo imports other repos | cross-repo imports | PostToolUse | Blocking | +| Wrong directory (top-level) | `src/services/`, `src/entities/`, etc. | PreToolUse + Post | Blocking | +| Wrong directory (in-module) | `src//entities/`, `src//helpers/` | PreToolUse + Post | Blocking | +| `console.log` | Any console.* in src/ | PostToolUse | Blocking | +| `export default` | Named exports only | PostToolUse | Blocking | +| `as any` / `: any` | Untyped values | PostToolUse | Warning only | + +--- + +## Files Created by This System + +``` +AGENTS.md ← Tier 1: hot memory (loaded every session) + +docs/layers/ + index.md ← Phonebook + domain.md ← Domain entity conventions + service.md ← Service layer conventions + repository.md ← Abstract + concrete repo conventions + mapper.md ← Mapper conventions + controller.md ← Controller conventions + dto.md ← DTO conventions + +docs/cross-cutting/ + file-placement.md ← Module structure, blocked paths + exceptions.md ← Exception helper reference + +scripts/ + inject-context.mjs ← PreToolUse hook (orchestrator) + hooks/ + base.mjs ← buildContext + runPipeline shared utils + inject-structure-context.mjs ← structureCheck middleware + inject-code-context.mjs ← codeContext middleware (all-matches routing) + arch-validate.sh ← PostToolUse hook (blocking grep checks) + +.claude/settings.json ← Hook wiring (committed to git) +``` + +--- + +## How to Test the System + +### Test 1: Verify inject-context routes correctly + +```bash +# Should inject service + exception docs for a service file +echo '{"tool_name":"Edit","tool_input":{"file_path":"'"$(pwd)"'/src/bookings/bookings.service.ts"}}' \ + | CLAUDE_PROJECT_DIR=$(pwd) node scripts/inject-context.mjs 2>/dev/null | \ + node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.hookSpecificOutput?.additionalContext?.substring(0,300))" + +# Should inject domain doc for a domain entity +echo '{"tool_name":"Edit","tool_input":{"file_path":"'"$(pwd)"'/src/bookings/domain/booking.ts"}}' \ + | CLAUDE_PROJECT_DIR=$(pwd) node scripts/inject-context.mjs 2>/dev/null | \ + node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.hookSpecificOutput?.additionalContext?.substring(0,300))" +``` + +### Test 2: Verify structureCheck blocks bad paths + +```bash +# Should exit 2 with BLOCKED message +echo '{"tool_name":"Write","tool_input":{"file_path":"src/services/booking.service.ts"}}' \ + | CLAUDE_PROJECT_DIR=$(pwd) node scripts/inject-context.mjs 2>&1; echo "Exit: $?" + +echo '{"tool_name":"Write","tool_input":{"file_path":"src/bookings/helpers/utils.ts"}}' \ + | CLAUDE_PROJECT_DIR=$(pwd) node scripts/inject-context.mjs 2>&1; echo "Exit: $?" +``` + +### Test 3: Verify arch-validate catches violations + +```bash +# Create a test file with a violation +echo 'import { InjectRepository } from "@nestjs/typeorm"; +@Injectable() +export class TestService { + constructor(@InjectRepository(SomeEntity) private repo) {} +}' > /tmp/test-service.ts + +echo '{"tool_input":{"file_path":"/tmp/test-service.ts"}}' \ + | CLAUDE_PROJECT_DIR=$(pwd) bash scripts/arch-validate.sh 2>&1; echo "Exit: $?" + +rm /tmp/test-service.ts +``` + +--- + +## Extending the System + +### Adding a new leaf doc + +1. Create `docs/layers/.md` with `## Inject` and `## Reference` sections +2. Add routes to `scripts/hooks/inject-code-context.mjs` ROUTES array +3. Update the routing table in `AGENTS.md` + +### Adding a new arch-validate check + +1. Count existing violations first: `grep -rl 'pattern' src/ | wc -l` +2. Only add the check if existing hits are 0-2 (don't block files with pre-existing violations) +3. Add to the appropriate section in `scripts/arch-validate.sh` +4. Test: create a temp file with the violation and verify exit 2 + +### Adding a new blocked path + +Add to `BLOCKED_PATHS` array in `scripts/hooks/inject-structure-context.mjs`: +```javascript +[/^src\/\/\//, 'Guidance message with correct path.'], +``` + +Mirror the check in `arch-validate.sh` for defense-in-depth. + +### Committing settings.json + +After any change to `.claude/settings.json`: +```bash +git add .claude/settings.json +git commit -m "chore: update hook configuration" +``` + +Worktree agents inherit hooks from branch HEAD. Uncommitted changes don't propagate. + +--- + +## References + +- [Hook-Based Context Injection for AI Coding Agents](https://andrewpatterson.dev/posts/agent-convention-enforcement-system/) — Andrew Patterson. The original article this system is based on. Covers the three-tier architecture, A/B results, gotchas, and testing protocols in depth. diff --git a/docs/cross-cutting/exceptions.md b/docs/cross-cutting/exceptions.md new file mode 100644 index 0000000..f9ae4be --- /dev/null +++ b/docs/cross-cutting/exceptions.md @@ -0,0 +1,79 @@ +# Exception Handling + +Last verified: 2026-03-30 + +## Inject + +All exceptions in services must use helpers from `@src/common/exceptions`. Never throw raw +NestJS exceptions directly in service code. + +```typescript +import { + NOT_FOUND, + UNPROCESSABLE_ENTITY, + BAD_REQUEST, + FORBIDDEN, + UNAUTHORIZED, + CustomException, +} from '@src/common/exceptions'; +``` + +**NOT_FOUND** — entity does not exist: +```typescript +throw NOT_FOUND('Booking', { id }); +// → 404: "Booking not found for id: 42" + +throw NOT_FOUND('User', { email }); +// → 404: "User not found for email: foo@bar.com" +``` + +**UNPROCESSABLE_ENTITY** — logic/validation error with a named attribute: +```typescript +throw UNPROCESSABLE_ENTITY('Method findByEmail not found on user repository.', 'email'); +// → 422: { errors: { email: 'Method...' } } +``` + +**BAD_REQUEST** — malformed input: +```typescript +throw BAD_REQUEST('Invalid date range: checkOut must be after checkIn'); +// → 400 +``` + +**FORBIDDEN** — authenticated but not authorized: +```typescript +throw FORBIDDEN('You do not own this booking.', 'bookingId'); +// → 403 +``` + +**UNAUTHORIZED** — not authenticated: +```typescript +throw UNAUTHORIZED('Token expired.', 'token'); +// → 401 +``` + +**CustomException** — conflict/business rule violation: +```typescript +throw CustomException('Booking already exists for this date range.', 'checkIn'); +// → 409 +``` + +Wrong pattern — do not throw raw NestJS exceptions in services: +```typescript +// ❌ +throw new NotFoundException({ statusCode: 404, errors: { id: '...' } }); +throw new BadRequestException('Invalid payload'); +throw new UnprocessableEntityException({ ... }); +``` + +The helpers produce consistent, structured error responses that the global exception filter +expects. Raw NestJS exceptions bypass this structure. + +Canonical example: `src/bookings/bookings.service.ts` — uses NOT_FOUND and UNPROCESSABLE_ENTITY. + +## Reference + +`src/common/exceptions.ts` defines all helpers. They are thin wrappers around the corresponding +NestJS exception classes but produce a standardized `{ statusCode, errors, stack }` body that +the frontend and API consumers depend on. + +Controllers do not throw exceptions directly — delegate to services which use these helpers. diff --git a/docs/cross-cutting/file-placement.md b/docs/cross-cutting/file-placement.md new file mode 100644 index 0000000..a2e370e --- /dev/null +++ b/docs/cross-cutting/file-placement.md @@ -0,0 +1,71 @@ +# File Placement + +Last verified: 2026-03-30 + +## Inject + +Every new file in `src/` must go inside an existing feature module folder, following the hexagonal +structure. There is no `src/utils/`, `src/helpers/`, `src/components/`, or `src/services/` at the +top level — those are wrong placements. + +**Module internal structure** (required layout): + +``` +src// +├── domain/ +│ └── .ts ← Pure TS domain entity +├── domain/queries/ +│ └── -query.ts ← Query result shape +├── dto/ +│ └── -.dto.ts ← Request/response DTOs +├── enums/ +│ └── .enum.ts ← TypeScript enums +├── infrastructure/persistence/ +│ ├── .abstract.repository.ts ← Port (abstract class) +│ └── relational/ +│ ├── entities/ +│ │ └── .entity.ts ← TypeORM entity +│ ├── mappers/ +│ │ └── .mapper.ts ← Static mapper +│ ├── repositories/ +│ │ └── .repository.ts ← Adapter (concrete) +│ ├── queries/ +│ │ ├── -queries.const.ts ← Raw SQL strings +│ │ └── .mapper.ts ← Query result mapper +│ └── relational-persistence.module.ts +├── .controller.ts +├── .service.ts +└── .module.ts +``` + +**Blocked placements** — agents often get these wrong: + +| Wrong path | Correct path | +|---|---| +| `src/services/.service.ts` | `src//.service.ts` | +| `src/repositories/.repo.ts` | `src//infrastructure/persistence/relational/repositories/` | +| `src//entities/` | `src//infrastructure/persistence/relational/entities/` | +| `src//models/` | `src//domain/` | +| `src//helpers/` | `src/utils/` or `src/common/` | +| `src//infrastructure/.ts` | `src//infrastructure/persistence/relational/entities/.entity.ts` | + +**Shared utilities** (cross-module, not domain-specific) go in: +- `src/utils/` — generic helpers, pagination, type utilities +- `src/common/` — exceptions, filters, interceptors, decorators +- `src/database-helpers/` — DB utility functions like `runRawQueryOnReadReplica` + +**New top-level module**: use `npx hygen resource-entity new` to scaffold correctly. +Do not create a new top-level `src/` directory manually unless you are adding a shared utility +module (utils, common, database-helpers pattern). + +Canonical structure reference: `src/bookings/` is the most complete example module. + +## Reference + +The `src/` directory contains 30+ modules. Each follows identical internal structure. +Consistency is what makes the codebase navigable. When a file is in the wrong place: +1. Other agents reach for it by convention and fail to find it +2. Module boundaries leak — services start depending on each other's internals +3. The hexagonal port/adapter pattern breaks down + +If you are unsure where a file belongs, check `src/bookings/` as the reference module. diff --git a/docs/layers/controller.md b/docs/layers/controller.md new file mode 100644 index 0000000..7e6e74e --- /dev/null +++ b/docs/layers/controller.md @@ -0,0 +1,62 @@ +# Controller Layer + +Last verified: 2026-03-30 + +## Inject + +Controllers are the HTTP layer. They delegate all logic to the service — zero business logic here. + +**Required decorators** on every controller class: +```typescript +@ApiTags('ResourceName') // Swagger grouping — match the URL path name, Title Case +@ApiBearerAuth() // All routes require JWT unless explicitly public +@UseGuards(AuthGuard('jwt')) // JWT guard on the class +@Controller({ path: 'resource-name', version: '1' }) +export class ResourceController { + constructor(private readonly resourceService: ResourceService) {} +} +``` + +**Pagination defaults** on list endpoints (standardized across all modules): +```typescript +const page = query?.page ?? 1; +let limit = query?.limit ?? 10; +if (limit > 50) limit = 50; +``` + +**Two response shapes** depending on endpoint type: +```typescript +// infinityPagination — standard list (no total count needed) +@ApiOkResponse({ type: InfinityPaginationResponse(Booking) }) +async findAll(...): Promise> { + return infinityPagination(await this.service.findAllWithPagination(...), { page, limit }); +} + +// PaginationResponse — when total count is needed (raw query endpoints) +@ApiOkResponse({ type: PaginationResponse(PropertyReviewQuery) }) +async findReviews(...): Promise> { + return this.service.findPropertyReviewsWithPagination(...); +} +``` + +**ID params** require `@ApiParam`: +```typescript +@Get(':id') +@ApiParam({ name: 'id', type: Number, required: true }) +findOne(@Param('id') id: number) { return this.service.findOne(id); } +``` + +No direct imports from infrastructure, mappers, or DB entities. Import only service, domain +types (for response type annotations), and DTOs. + +Canonical example: `src/bookings/bookings.controller.ts` + +## Reference + +Controllers are intentionally thin. The only logic allowed here is: +- Parsing query params with defaults (`page ?? 1`, `limit ?? 10`) +- Capping limits (`if (limit > 50) limit = 50`) +- Computing derived filter inputs (e.g. date math from enum in `FindPropertyBookingAggregatedDto`) +- Wrapping service results in the correct pagination DTO + +If you find yourself writing conditional business logic in a controller, it belongs in the service. diff --git a/docs/layers/domain.md b/docs/layers/domain.md new file mode 100644 index 0000000..065ea6c --- /dev/null +++ b/docs/layers/domain.md @@ -0,0 +1,50 @@ +# Domain Layer + +Last verified: 2026-03-30 + +## Inject + +Domain entities are pure TypeScript classes. They have zero infrastructure dependencies. + +**Naming**: `src//domain/.ts` — camelCase properties. +**Class name**: matches filename, e.g. `booking.ts` → `export class Booking {}`. +No `@Entity()`, no TypeORM decorators, no `@nestjs/typeorm`, no DB imports. + +```typescript +// ✅ Correct domain entity +export class Booking { + id: number; + userId: number; + checkIn: Date; + checkOut: Date; + totalAmount: number; + createdAt: Date; + updatedAt: Date; +} +``` + +**Query result shapes** live in `domain/queries/-query.ts` — same rules: pure TS, no DB deps. + +Never import from: +- `infrastructure/` (entities, mappers, repos) +- `typeorm` or `@nestjs/typeorm` +- Other modules' `infrastructure/` paths + +Only allowed imports: `@src/utils/types/` for shared type primitives. + +Canonical examples: +- `src/bookings/domain/booking.ts` +- `src/bookings/domain/queries/property-review-query.ts` + +## Reference + +Domain entities are the core of hexagonal architecture. The service layer works exclusively with +these. Repositories receive and return domain entities (the mapper bridges to/from DB entities). + +The camelCase ↔ snake_case split is intentional: +- Domain (and service, controller, DTO) uses camelCase +- DB entities use snake_case +- Mappers translate between them + +If a domain entity needs a computed field, derive it in the mapper's `toDomain()` method, +not by adding DB knowledge to the domain class. diff --git a/docs/layers/dto.md b/docs/layers/dto.md new file mode 100644 index 0000000..fd88321 --- /dev/null +++ b/docs/layers/dto.md @@ -0,0 +1,50 @@ +# DTO Layer + +Last verified: 2026-03-30 + +## Inject + +DTOs define request/response shapes. They live in `src//dto/`. + +**Naming convention**: +- `find-all-.dto.ts` → `FindAllDto` +- `create-.dto.ts` → `CreateDto` +- `update-.dto.ts` → `UpdateDto` +- `find-.dto.ts` → `FindDto` + +**FindAll DTOs always extend the shared pagination DTO**: +```typescript +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNumber, IsOptional } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class FindAllBookingsDto { + @ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) + page?: number; + + @ApiPropertyOptional() @IsOptional() @IsNumber() @Type(() => Number) + limit?: number; +} +``` + +**Enums in DTOs**: define enums in `src//enums/.enum.ts`, import into DTOs. +Never inline enum definitions inside DTO files. + +**@ApiPropertyOptional / @ApiProperty**: all DTO properties must have Swagger decorators. +**@IsOptional / @IsNotEmpty**: every property must have explicit validation. + +DTOs use camelCase properties (same as domain and service layer). +DTOs must not import from `infrastructure/` or domain entities — they are independent shapes. + +Canonical examples: +- `src/bookings/dto/find-all-bookings.dto.ts` +- `src/bookings/dto/find-property-booking-aggregated.dto.ts` + +## Reference + +DTOs define what comes in (from HTTP) and what goes out (to HTTP client). They are not domain +entities — do not reuse domain entities as DTO types in controller return types. + +For response shapes, use the domain entity class directly with Swagger decorators on that class +(add `@ApiProperty()` on domain entity properties as needed for Swagger docs). Do not create +separate response DTOs unless the shape differs materially from the domain entity. diff --git a/docs/layers/index.md b/docs/layers/index.md new file mode 100644 index 0000000..4058b14 --- /dev/null +++ b/docs/layers/index.md @@ -0,0 +1,12 @@ +# Layers — Phonebook + +| Working on... | Read | +| ---------------------------------------------------------------- | ------------------------ | +| Domain entities (pure TS, no DB, `domain/*.ts`) | `domain.md` | +| Services (business logic, `*.service.ts`) | `service.md` | +| Abstract + concrete repositories | `repository.md` | +| Mappers (toDomain / toPersistence, `*.mapper.ts`) | `mapper.md` | +| Controllers (HTTP layer, `*.controller.ts`) | `controller.md` | +| DTOs (request/response shapes, `*.dto.ts`) | `dto.md` | +| File placement, new module structure | `../cross-cutting/file-placement.md` | +| Exception patterns | `../cross-cutting/exceptions.md` | diff --git a/docs/layers/mapper.md b/docs/layers/mapper.md new file mode 100644 index 0000000..84533b2 --- /dev/null +++ b/docs/layers/mapper.md @@ -0,0 +1,57 @@ +# Mapper Layer + +Last verified: 2026-03-30 + +## Inject + +Mappers bridge domain entities (camelCase) and DB entities (snake_case). +Always static methods, never instantiated. + +**File location**: `infrastructure/persistence/relational/mappers/.mapper.ts` +**Class name**: `Mapper` — exported named class, no `export default`. +**Two static methods only**: `toDomain(raw: EntityClass): DomainClass` and `toPersistence(domain: DomainClass): EntityClass`. + +```typescript +// ✅ Correct mapper structure +export class BookingMapper { + static toDomain(raw: BookingEntity): Booking { + const domain = new Booking(); + domain.id = raw.id; + domain.userId = raw.user_id; // snake_case → camelCase + domain.totalAmount = raw.total_amount; + domain.createdAt = raw.created_at; + return domain; + } + + static toPersistence(domain: Booking): BookingEntity { + const entity = new BookingEntity(); + if (domain.id) entity.id = domain.id; + entity.user_id = domain.userId; // camelCase → snake_case + entity.total_amount = domain.totalAmount; + entity.created_at = domain.createdAt; + return entity; + } +} +``` + +All camelCase ↔ snake_case translation happens HERE, not in services or controllers. +Mappers must not import from other mappers (to avoid coupling at the data layer). +Mappers must not import from services or controllers. + +For query result shapes (`domain/queries/`), create a separate query mapper in +`relational/queries/-query.mapper.ts` with the same static pattern. + +Canonical examples: +- `src/bookings/infrastructure/persistence/relational/mappers/booking.mapper.ts` +- `src/bookings/infrastructure/persistence/relational/queries/property-review-query.mapper.ts` + +## Reference + +The mapper is the only place where the `snake_case` ↔ `camelCase` split is enforced. +This means the service layer receives all data in camelCase regardless of DB schema changes. + +When a DB column is renamed, only the mapper needs to change — not the service, not the domain, +not the controller, not tests that mock the service. That is the entire value of this pattern. + +`toPersistence()` is only needed for write operations (create, update). Read-only modules +can omit it, but including it makes the mapper complete. diff --git a/docs/layers/repository.md b/docs/layers/repository.md new file mode 100644 index 0000000..b9fe7b7 --- /dev/null +++ b/docs/layers/repository.md @@ -0,0 +1,65 @@ +# Repository Layer + +Last verified: 2026-03-30 + +## Inject + +Two files per module: + +1. **Abstract repository** (`infrastructure/persistence/.abstract.repository.ts`) + — defines the port (interface as abstract class). Only imports domain entities and `@src/utils/types/`. + +2. **Concrete repository** (`infrastructure/persistence/relational/repositories/.repository.ts`) + — implements the abstract repo. Uses TypeORM, DB entities, and mappers. + +**Always call Mapper.toDomain() before returning** — never return a raw DB entity. +```typescript +// ✅ +return entity ? BookingMapper.toDomain(entity) : null; +return entities.map((e) => BookingMapper.toDomain(e)); + +// ❌ Returning un-mapped entity +return entity; +``` + +**Single-responsibility methods** — one method per query shape, never universal `find(condition)`. +```typescript +// ✅ +async findByEmail(email: string): Promise> {} +async findByIds(ids: string[]): Promise {} +async findAllWithPagination({ paginationOptions }): Promise {} + +// ❌ +async find(condition: UniversalConditionInterface): Promise {} +``` + +**Raw queries** live in `relational/queries/-queries.const.ts` as exported SQL string constants. +Run via `runRawQueryOnReadReplica` from `@src/database-helpers/run-raw-query-on-read-replica`. +```typescript +import { runRawQueryOnReadReplica } from '@src/database-helpers/run-raw-query-on-read-replica'; +const data = await runRawQueryOnReadReplica(this.bookingRepository, PROPERTY_REVIEW_QUERY, [param]); +``` + +**Repo-to-repo imports are FORBIDDEN** — cross-table joins belong in services or a shared common module. +If two modules need each other, create a `src/-and-/` module (see architecture.md § Circular Deps). + +Canonical examples: +- `src/bookings/infrastructure/persistence/booking.abstract.repository.ts` +- `src/bookings/infrastructure/persistence/relational/repositories/booking.repository.ts` + +## Reference + +The abstract repository is the "port". The concrete repository is the "adapter". +NestJS DI wires them: the persistence module registers the concrete class as a provider for the +abstract class token. Services never know which concrete implementation is running. + +Pagination in concrete repos: +```typescript +const entities = await this.repo.find({ + skip: (paginationOptions.page - 1) * paginationOptions.limit, + take: paginationOptions.limit, +}); +``` + +`NullableType` (from `@src/utils/types/nullable.type`) is `T | null` — use it as the return +type whenever the entity may not exist. diff --git a/docs/layers/service.md b/docs/layers/service.md new file mode 100644 index 0000000..526ff82 --- /dev/null +++ b/docs/layers/service.md @@ -0,0 +1,69 @@ +# Service Layer + +Last verified: 2026-03-30 + +## Inject + +Services contain business logic. They talk only to the abstract repository, never to concrete repos +or TypeORM directly. + +**Constructor injection**: always inject the abstract repository class. +```typescript +// ✅ Correct +constructor(private readonly bookingRepository: BookingAbstractRepository) {} + +// ❌ Wrong — injecting concrete repo or TypeORM repo +constructor(@InjectRepository(BookingEntity) private repo: Repository) {} +``` + +**Error handling**: always use helpers from `@src/common/exceptions`, never raw NestJS exceptions. +```typescript +// ✅ +import { NOT_FOUND, UNPROCESSABLE_ENTITY } from '@src/common/exceptions'; +throw NOT_FOUND('Booking', { id }); + +// ❌ +throw new NotFoundException({ statusCode: 404, ... }); +``` + +**Pagination**: two helpers for two response shapes: +```typescript +// infinityPagination — for standard list endpoints (no total count) +return infinityPagination(await this.repo.findAllWithPagination({ paginationOptions }), { page, limit }); + +// pagination — for raw-query results that return { data, count } +const { data, count } = await this.repo.findPropertyReviewsWithPagination({ paginationOptions, filter }); +return pagination(data, count, paginationOptions); +``` + +**findAndValidate pattern**: dynamic repo method dispatch, used when the same "find or throw" +logic is reused across multiple fields: +```typescript +async findAndValidate(field, value, fetchRelations = false) { + const repoFunction = `findBy${field.charAt(0).toUpperCase()}${field.slice(1)}${fetchRelations ? 'WithRelations' : ''}`; + if (typeof this.bookingRepository[repoFunction] !== 'function') { + throw UNPROCESSABLE_ENTITY(`Method ${repoFunction} not found on booking repository.`, field); + } + const entity = await this.bookingRepository[repoFunction](value); + if (!entity) throw NOT_FOUND('Booking', { [field]: value }); + return entity; +} +``` + +Never import from: +- `infrastructure/` paths +- TypeORM entities or repositories +- Other modules' services (use NestJS module imports to inject shared abstract repos instead) + +Canonical examples: +- `src/bookings/bookings.service.ts` +- `src/properties/properties.service.ts` + +## Reference + +Services receive domain entities from the repo and return domain entities to controllers. +They never touch snake_case data. All DB layer concerns are behind the abstract repository port. + +For cross-module logic, prefer injecting another module's abstract repository (register it in the +module's `providers` + `imports`) over calling another service. This prevents circular module +dependencies and keeps the service layer thin. diff --git a/scripts/arch-validate.sh b/scripts/arch-validate.sh new file mode 100755 index 0000000..fb5db9f --- /dev/null +++ b/scripts/arch-validate.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# arch-validate.sh — PostToolUse hook +# +# Runs after every Edit and Write tool call. +# Checks the modified file for architecture violations. +# Exit 2 = blocking violation (agent must fix before proceeding). +# Exit 0 = clean. +# +# CRITICAL: Strip $CLAUDE_PROJECT_DIR from absolute paths before pattern matching. + +INPUT=$(cat) +ABS_FILE=$(echo "$INPUT" | node -e " + let d=''; process.stdin.on('data',c=>d+=c); + process.stdin.on('end',()=>{ + try { const p=JSON.parse(d); console.log(p.tool_input?.file_path||''); } + catch { console.log(''); } + }); +" 2>/dev/null || echo "$INPUT" | python3 -c " +import sys, json +try: + data = json.load(sys.stdin) + print(data.get('tool_input', {}).get('file_path', '')) +except: + print('') +" 2>/dev/null) + +# Re-parse using node for reliability +ABS_FILE=$(echo "$INPUT" | node -e " +process.stdin.resume(); +process.stdin.setEncoding('utf8'); +let data = ''; +process.stdin.on('data', (chunk) => data += chunk); +process.stdin.on('end', () => { + try { + const parsed = JSON.parse(data); + console.log(parsed.tool_input?.file_path || ''); + } catch { + console.log(''); + } +}); +") + +# Strip project root to get relative path +PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" +FILE="${ABS_FILE#"$PROJECT_ROOT/"}" + +# Skip if no file or not in src/ +if [ -z "$FILE" ] || [[ "$FILE" != src/* ]]; then + exit 0 +fi + +# Skip if file doesn't exist (was deleted, or path is wrong) +if [ ! -f "$ABS_FILE" ]; then + exit 0 +fi + +VIOLATIONS="" +WARNINGS="" + +# ── 1. Domain layer must not import from TypeORM or infrastructure ───────────── +if [[ "$FILE" == src/*/domain/*.ts ]] && [[ "$FILE" != *.spec.ts ]] && [[ "$FILE" != *.test.ts ]]; then + grep -qE "from ['\"]typeorm['\"]|from ['\"]@nestjs/typeorm['\"]" "$ABS_FILE" 2>/dev/null && \ + VIOLATIONS+="❌ Domain entity imports TypeORM — domain/ must be pure TypeScript with zero DB dependencies.\n Fix: remove typeorm and @nestjs/typeorm imports from $FILE\n\n" + + grep -qE "from ['\"].*infrastructure/" "$ABS_FILE" 2>/dev/null && \ + VIOLATIONS+="❌ Domain entity imports from infrastructure/ layer — domain/ must not depend on persistence.\n Fix: remove infrastructure/ imports from $FILE\n\n" +fi + +# ── 2. Services must not directly inject TypeORM repositories ───────────────── +if [[ "$FILE" == src/*/*.service.ts ]]; then + grep -q "@InjectRepository" "$ABS_FILE" 2>/dev/null && \ + VIOLATIONS+="❌ Service uses @InjectRepository — services must inject the abstract repository, not TypeORM Repository directly.\n Fix: inject AbstractRepository instead of Repository.\n\n" + + grep -qE "Repository<[A-Z]" "$ABS_FILE" 2>/dev/null && \ + VIOLATIONS+="❌ Service has TypeORM Repository<...> type — services must use the abstract repository interface.\n Fix: replace Repository with AbstractRepository.\n\n" +fi + +# ── 3. Services must use @src/common/exceptions (not raw NestJS exceptions) ─── +if [[ "$FILE" == src/*/*.service.ts ]]; then + grep -qE "throw new (NotFoundException|BadRequestException|UnprocessableEntityException|ForbiddenException|UnauthorizedException|ConflictException)" "$ABS_FILE" 2>/dev/null && \ + VIOLATIONS+="❌ Service throws raw NestJS exception — use helpers from @src/common/exceptions instead.\n Fix: import { NOT_FOUND, UNPROCESSABLE_ENTITY, BAD_REQUEST, FORBIDDEN } from '@src/common/exceptions'\n\n" +fi + +# ── 4. Concrete repos must not import other concrete repos ──────────────────── +if [[ "$FILE" == src/*/infrastructure/persistence/relational/repositories/*.repository.ts ]]; then + grep -qE "from ['\"].*relational/repositories/" "$ABS_FILE" 2>/dev/null && \ + VIOLATIONS+="❌ Concrete repository imports another concrete repository — cross-table joins belong in services or a shared module.\n Fix: move cross-repo logic to a service or create a src/-and-/ common module.\n\n" +fi + +# ── 5. Concrete repos must call mapper.toDomain() (not return raw entities) ─── +if [[ "$FILE" == src/*/infrastructure/persistence/relational/repositories/*.repository.ts ]]; then + # If repo has async methods that return but no toDomain call, likely missing mapper + if grep -q "async find" "$ABS_FILE" 2>/dev/null; then + if ! grep -q "\.toDomain(" "$ABS_FILE" 2>/dev/null; then + VIOLATIONS+="❌ Concrete repository has find methods but no Mapper.toDomain() calls — raw DB entities must be mapped before returning.\n Fix: call Mapper.toDomain(entity) on every returned value.\n\n" + fi + fi +fi + +# ── 6. No console.log / console.error / console.warn ───────────────────────── +if [[ "$FILE" == src/* ]] && \ + [[ "$FILE" != *.spec.ts ]] && \ + [[ "$FILE" != *.test.ts ]] && \ + [[ "$FILE" != src/*/logger*.ts ]]; then + grep -qE "console\.(log|error|warn|info|debug)" "$ABS_FILE" 2>/dev/null && \ + VIOLATIONS+="❌ console.* detected — use NestJS Logger instead.\n Fix: import { Logger } from '@nestjs/common'; private readonly logger = new Logger('ClassName');\n\n" +fi + +# ── 7. No export default ────────────────────────────────────────────────────── +if [[ "$FILE" == src/* ]] && \ + [[ "$FILE" != *.spec.ts ]] && \ + [[ "$FILE" != *.test.ts ]]; then + grep -q "^export default" "$ABS_FILE" 2>/dev/null && \ + VIOLATIONS+="❌ export default detected — this codebase uses named exports only.\n Fix: change to a named export (export class Foo / export const foo).\n\n" +fi + +# ── 8. File placement defense-in-depth (catches files that bypassed structureCheck) ── +case "$FILE" in + src/services/*) + VIOLATIONS+="❌ Wrong directory: src/services/ — services go in src//.service.ts\n\n" ;; + src/repositories/*) + VIOLATIONS+="❌ Wrong directory: src/repositories/ — repos go in src//infrastructure/persistence/relational/repositories/\n\n" ;; + src/entities/*) + VIOLATIONS+="❌ Wrong directory: src/entities/ — DB entities go in src//infrastructure/persistence/relational/entities/\n\n" ;; + src/models/*) + VIOLATIONS+="❌ Wrong directory: src/models/ — domain entities go in src//domain/\n\n" ;; + src/helpers/*) + VIOLATIONS+="❌ Wrong directory: src/helpers/ — shared helpers go in src/utils/ or src/common/\n\n" ;; + src/types/*) + VIOLATIONS+="❌ Wrong directory: src/types/ — shared types go in src/utils/types/\n\n" ;; + src/*/entities/*) + VIOLATIONS+="❌ Wrong path: src//entities/ — DB entities go in src//infrastructure/persistence/relational/entities/\n\n" ;; + src/*/models/*) + VIOLATIONS+="❌ Wrong path: src//models/ — domain entities go in src//domain/\n\n" ;; + src/*/repositories/*) + VIOLATIONS+="❌ Wrong path: src//repositories/ — repos go in src//infrastructure/persistence/relational/repositories/\n\n" ;; +esac + +# ── Non-blocking warnings ───────────────────────────────────────────────────── +# as any / : any — prefer unknown with type guards +if [[ "$FILE" == src/* ]] && \ + [[ "$FILE" != *.spec.ts ]] && \ + [[ "$FILE" != *.test.ts ]] && \ + [[ "$FILE" != *.d.ts ]]; then + grep -qE ": any[^[]|as any" "$ABS_FILE" 2>/dev/null && \ + WARNINGS+="⚠️ as any / : any detected — prefer unknown with type guards for better type safety.\n\n" +fi + +# ── Output ──────────────────────────────────────────────────────────────────── +if [ -n "$WARNINGS" ]; then + echo -e "Arch warnings in $FILE:\n$WARNINGS" >&2 +fi + +if [ -n "$VIOLATIONS" ]; then + echo -e "Architecture violations in $FILE — fix these before proceeding:\n\n$VIOLATIONS" >&2 + exit 2 +fi + +exit 0 diff --git a/scripts/hooks/base.mjs b/scripts/hooks/base.mjs new file mode 100644 index 0000000..cf7d1af --- /dev/null +++ b/scripts/hooks/base.mjs @@ -0,0 +1,89 @@ +/** + * base.mjs — shared context builder and pipeline runner + * Used by inject-context.mjs (PreToolUse hook) + */ + +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +/** + * Parse stdin JSON and resolve the target file path. + * Strips $CLAUDE_PROJECT_DIR prefix so paths match regex routes (^src/...). + */ +export function buildContext(rawInput) { + const input = JSON.parse(rawInput); + const toolName = input.tool_name || ''; + const rawPath = + input.tool_input?.file_path || + input.tool_input?.path || + ''; + + const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + + // Strip absolute project root to get relative path (e.g. src/bookings/...) + const filePath = rawPath.startsWith(projectRoot) + ? rawPath.slice(projectRoot.length).replace(/^\//, '') + : rawPath; + + // Check if the file already exists on disk (relevant for Write — new vs overwrite) + let isNewFile = false; + try { + readFileSync(rawPath.startsWith('/') ? rawPath : resolve(projectRoot, rawPath)); + isNewFile = false; + } catch { + isNewFile = true; + } + + return { toolName, filePath, rawPath, projectRoot, isNewFile, input }; +} + +/** + * Run middlewares in order. Each middleware returns { block, context } or { context }. + * If any middleware returns { block: true }, exit 2 immediately. + * Otherwise collect all context strings and output as additionalContext JSON. + */ +export function runPipeline(ctx, middlewares) { + const contextParts = []; + + for (const middleware of middlewares) { + const result = middleware(ctx); + if (result.block) { + // Exit 2 — agent is blocked. Message goes to stderr. + if (result.message) process.stderr.write(result.message + '\n'); + process.exit(2); + } + if (result.context) { + contextParts.push(result.context); + } + } + + if (contextParts.length > 0) { + const combined = contextParts.join('\n\n'); + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + additionalContext: combined, + }, + }) + ); + } + + process.exit(0); +} + +/** + * Read only the ## Inject section from a doc file. + * Falls back to the full file content if section not found. + */ +export function readInjectSection(docPath, projectRoot) { + try { + const fullPath = resolve(projectRoot, docPath); + const content = readFileSync(fullPath, 'utf8'); + const injectMatch = content.match(/## Inject\n([\s\S]*?)(?=\n## |$)/); + return injectMatch + ? `Context from ${docPath}:\n${injectMatch[1].trim()}` + : `Context from ${docPath}:\n${content.trim()}`; + } catch { + return null; + } +} diff --git a/scripts/hooks/inject-code-context.mjs b/scripts/hooks/inject-code-context.mjs new file mode 100644 index 0000000..71d2018 --- /dev/null +++ b/scripts/hooks/inject-code-context.mjs @@ -0,0 +1,107 @@ +/** + * inject-code-context.mjs — codeContext middleware + * + * All-matches routing: walks every route, injects every matching doc. + * General docs inject first (layer context), specific docs inject last (domain context). + * This means specific context is closest to recency-privileged position when the agent writes. + */ + +import { readInjectSection } from './base.mjs'; + +/** + * ROUTES — ordered general → specific. + * All matching routes inject. First-match-wins is NOT used. + * Add new routes in the right position (general layer catchalls at the bottom). + */ +const ROUTES = [ + // ── Cross-cutting: exceptions ────────────────────────────────────────────── + [/src\/common\/exceptions\.ts$/, 'docs/cross-cutting/exceptions.md'], + + // ── Cross-cutting: file-placement (for any new file pattern) ────────────── + [/docs\/cross-cutting\/file-placement\.md$/, null], // skip injecting docs into docs + + // ── Domain entities ──────────────────────────────────────────────────────── + [/\/domain\/(?!queries)[^/]+\.ts$/, 'docs/layers/domain.md'], + [/\/domain\/queries\//, 'docs/layers/domain.md'], + + // ── DTOs ────────────────────────────────────────────────────────────────── + [/\/dto\/[^/]+\.dto\.ts$/, 'docs/layers/dto.md'], + + // ── Controllers ─────────────────────────────────────────────────────────── + [/\.controller\.ts$/, 'docs/layers/controller.md'], + + // ── Services ────────────────────────────────────────────────────────────── + [/\.service\.ts$/, 'docs/layers/service.md'], + // Services also need exception context + [/\.service\.ts$/, 'docs/cross-cutting/exceptions.md'], + + // ── Abstract repositories ───────────────────────────────────────────────── + [/\.abstract\.repository\.ts$/, 'docs/layers/repository.md'], + + // ── Concrete repositories ───────────────────────────────────────────────── + [/\/relational\/repositories\/[^/]+\.repository\.ts$/, 'docs/layers/repository.md'], + + // ── Mappers ──────────────────────────────────────────────────────────────── + [/\/relational\/mappers\/[^/]+\.mapper\.ts$/, 'docs/layers/mapper.md'], + [/\/relational\/queries\/[^/]+\.mapper\.ts$/, 'docs/layers/mapper.md'], +]; + +/** + * Modules that warrant a "no matching doc" warning. + * Unmatched files under src/ that aren't modules/entities/configs get an alert. + */ +const SKIP_ALERT_PATTERNS = [ + /\.entity\.ts$/, // TypeORM entities — conventions enforced by arch-validate + /\.module\.ts$/, // NestJS modules — no specific doc needed + /\.enum\.ts$/, // Enums — no specific doc needed + /-queries\.const\.ts$/, // Raw SQL constants — no specific doc needed + /relational-persistence\.module\.ts$/, + /\/config\//, + /\/i18n\//, + /\/database\//, + /main\.ts$/, + /app\.module\.ts$/, +]; + +/** + * codeContext middleware factory + */ +export function codeContext() { + return function (ctx) { + const { filePath, projectRoot } = ctx; + + if (!filePath || !filePath.startsWith('src/')) { + return {}; + } + + // Walk ALL routes, collect every matching doc (deduplicated by path) + const seen = new Set(); + const docs = []; + + for (const [pattern, docPath] of ROUTES) { + if (!pattern.test(filePath)) continue; + if (!docPath) continue; + if (seen.has(docPath)) continue; + seen.add(docPath); + + const content = readInjectSection(docPath, projectRoot); + if (content) { + docs.push(content); + } + } + + if (docs.length > 0) { + return { context: docs.join('\n\n---\n\n') }; + } + + // No doc matched — warn if this is a non-trivial src/ file + const shouldAlert = !SKIP_ALERT_PATTERNS.some((p) => p.test(filePath)); + if (shouldAlert) { + return { + context: `⚠️ No context doc matched for: ${filePath}\nStop and check AGENTS.md routing table before editing. This file type may need a new leaf doc.`, + }; + } + + return {}; + }; +} diff --git a/scripts/hooks/inject-structure-context.mjs b/scripts/hooks/inject-structure-context.mjs new file mode 100644 index 0000000..34d4562 --- /dev/null +++ b/scripts/hooks/inject-structure-context.mjs @@ -0,0 +1,120 @@ +/** + * inject-structure-context.mjs — structureCheck middleware + * + * Fires only on Write to NEW files under src/. + * Blocks known-wrong paths before the file is created. + * Injects file-placement.md context for valid new files. + */ + +import { readInjectSection } from './base.mjs'; + +/** + * Paths that agents frequently get wrong. + * Each entry: [regex, redirect message] + */ +const BLOCKED_PATHS = [ + // Wrong top-level src directories (no standalone services/, repositories/, etc.) + [ + /^src\/services\//, + 'Services live inside their feature module: src//.service.ts — not in a top-level src/services/ folder.', + ], + [ + /^src\/repositories\//, + 'Repositories live inside their feature module: src//infrastructure/persistence/relational/repositories/ — not in a top-level src/repositories/ folder.', + ], + [ + /^src\/entities\//, + 'DB entities live inside their feature module: src//infrastructure/persistence/relational/entities/ — not in a top-level src/entities/ folder.', + ], + [ + /^src\/models\//, + 'Use src//domain/.ts for domain models — not a top-level src/models/ folder.', + ], + [ + /^src\/helpers\//, + 'Shared helpers go in src/utils/ or src/common/ — not in a top-level src/helpers/ folder.', + ], + [ + /^src\/types\//, + 'Shared types live in src/utils/types/ — not in a top-level src/types/ folder.', + ], + // Wrong paths inside a module + [ + /^src\/[^/]+\/entities\//, + 'DB entities go in src//infrastructure/persistence/relational/entities/ — not directly in src//entities/.', + ], + [ + /^src\/[^/]+\/models\//, + 'Domain entities go in src//domain/ — not in src//models/.', + ], + [ + /^src\/[^/]+\/helpers\//, + 'Module-internal helpers go in src/utils/ or src/common/ — not in src//helpers/.', + ], + [ + /^src\/[^/]+\/repositories\//, + 'Repositories go in src//infrastructure/persistence/relational/repositories/ — not in src//repositories/.', + ], + [ + /^src\/[^/]+\/infrastructure\/[^/]+\.ts$/, + 'Files directly inside infrastructure/ are wrong. Use infrastructure/persistence/.abstract.repository.ts or infrastructure/persistence/relational/entities|mappers|repositories/.', + ], + // Singular typos + [ + /^src\/[^/]+\/domain\/queries\/[^/]+(?-query.ts in src//domain/queries/.', + ], +]; + +/** + * Valid path patterns within a module (regex must match the full relative path) + */ +const VALID_MODULE_PATTERNS = [ + /^src\/[^/]+\/domain\//, + /^src\/[^/]+\/dto\//, + /^src\/[^/]+\/enums\//, + /^src\/[^/]+\/infrastructure\/persistence\//, + /^src\/[^/]+\/[^/]+\.(controller|service|module)\.ts$/, + // shared cross-cutting paths + /^src\/utils\//, + /^src\/common\//, + /^src\/config\//, + /^src\/database\//, + /^src\/database-helpers\//, + /^src\/i18n\//, +]; + +/** + * structureCheck middleware factory + */ +export function structureCheck() { + return function (ctx) { + const { toolName, filePath, isNewFile, projectRoot } = ctx; + + // Only fire on Write to new files under src/ + if (toolName !== 'Write' || !isNewFile || !filePath.startsWith('src/')) { + return {}; + } + + // Check blocked paths first + for (const [pattern, guidance] of BLOCKED_PATHS) { + if (pattern.test(filePath)) { + return { + block: true, + message: `BLOCKED: ${filePath}\n${guidance}\nSee docs/cross-cutting/file-placement.md for the full structure.`, + }; + } + } + + // Inject file-placement context for any valid new file in src/ + const placementDoc = readInjectSection( + 'docs/cross-cutting/file-placement.md', + projectRoot + ); + if (placementDoc) { + return { context: placementDoc }; + } + + return {}; + }; +} diff --git a/scripts/inject-context.mjs b/scripts/inject-context.mjs new file mode 100644 index 0000000..dcf3f37 --- /dev/null +++ b/scripts/inject-context.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/** + * inject-context.mjs — PreToolUse hook orchestrator + * + * Wired in .claude/settings.json to run before every Edit and Write tool call. + * Runs two middlewares in sequence: + * 1. structureCheck — blocks new files in wrong directories + * 2. codeContext — injects all matching leaf docs before the edit + * + * Exit codes: + * 0 = proceed (with optional additionalContext) + * 2 = BLOCKED (stderr message sent to agent, tool call cancelled) + */ + +import { buildContext, runPipeline } from './hooks/base.mjs'; +import { structureCheck } from './hooks/inject-structure-context.mjs'; +import { codeContext } from './hooks/inject-code-context.mjs'; + +const PIPELINE = [ + structureCheck(), // runs first — block bad paths before any context is injected + codeContext(), // runs second — inject all matching domain + layer docs +]; + +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; +}); +process.stdin.on('end', () => { + let ctx; + try { + ctx = buildContext(input); + } catch { + // Malformed input — let the tool proceed without injection + process.exit(0); + } + runPipeline(ctx, PIPELINE); +}); From 2e51516a8f188c3adfdc67990708b43a98181938 Mon Sep 17 00:00:00 2001 From: Ahmad Bilal Date: Mon, 30 Mar 2026 10:29:51 +0500 Subject: [PATCH 2/2] fix: typos --- AGENTS.md | 4 ++-- docs/agent-hooks-guide.md | 4 ++-- docs/cross-cutting/file-placement.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f301dd8..8a64f79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ -# AGENTS.md — booking-cms-apis +# AGENTS.md — NestForge -NestJS hexagonal (Ports & Adapters) architecture. 30+ feature modules, each with identical internal structure. This file is loaded every session — keep it loaded and trust it. +NestJS hexagonal (Ports & Adapters) architecture. 20+ feature modules, each with identical internal structure. This file is loaded every session — keep it loaded and trust it. --- diff --git a/docs/agent-hooks-guide.md b/docs/agent-hooks-guide.md index 5a0c082..74624e3 100644 --- a/docs/agent-hooks-guide.md +++ b/docs/agent-hooks-guide.md @@ -1,6 +1,6 @@ # Agent Hooks System — Implementation Guide -This document covers the three-tier context injection system implemented for **booking-cms-apis**. +This document covers the three-tier context injection system implemented for **NestForge**. It shows exactly what happens without the system versus with it, using real patterns from this codebase. > Based on: [Hook-Based Context Injection for AI Coding Agents](https://andrewpatterson.dev/posts/agent-convention-enforcement-system/) by Andrew Patterson. @@ -115,7 +115,7 @@ a violation in place. ## The Real Problem: Convention Drift -This codebase has 30+ modules all following the hexagonal pattern. That pattern has non-obvious rules: +This codebase has 20+ modules all following the hexagonal pattern. That pattern has non-obvious rules: 1. Services inject the **abstract repository** (not the concrete one, not TypeORM directly) 2. Domain entities are **pure TypeScript** (zero TypeORM, zero infra imports) diff --git a/docs/cross-cutting/file-placement.md b/docs/cross-cutting/file-placement.md index a2e370e..8af93e3 100644 --- a/docs/cross-cutting/file-placement.md +++ b/docs/cross-cutting/file-placement.md @@ -62,7 +62,7 @@ Canonical structure reference: `src/bookings/` is the most complete example modu ## Reference -The `src/` directory contains 30+ modules. Each follows identical internal structure. +The `src/` directory contains 20+ modules. Each follows identical internal structure. Consistency is what makes the codebase navigable. When a file is in the wrong place: 1. Other agents reach for it by convention and fail to find it 2. Module boundaries leak — services start depending on each other's internals