diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 12a2785..344f06d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ "description": "Claude Code plugin for building NestJS bounded contexts with Hexagonal Architecture, DDD, CQRS, and event-driven patterns. 10 skills, 8 agents (Opus 5 + Sonnet 5), TDD workflow, GSD compatible.", "source": "./", "category": "development", - "version": "1.1.0", + "version": "1.2.0-dev.0", "homepage": "https://github.com/Softtor/nestjs-hexagonal" } ] diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1efacfa..963d32c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "nestjs-hexagonal", "description": "Skills for building NestJS bounded contexts with Hexagonal Architecture, DDD, and CQRS patterns. Covers domain modeling, application layer, infrastructure wiring, presentation, full TDD workflow, and architecture review.", - "version": "1.1.0", + "version": "1.2.0-dev.0", "author": { "name": "Softtor", "url": "https://github.com/softtor" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e7ebb0b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + unit: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Version parity between package.json and plugin.json + run: | + pkg=$(bun -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).version)") + plugin=$(bun -e "console.log(JSON.parse(require('fs').readFileSync('.claude-plugin/plugin.json','utf8')).version)") + marketplace=$(bun -e "console.log(JSON.parse(require('fs').readFileSync('.claude-plugin/marketplace.json','utf8')).plugins[0].version)") + echo "package.json=$pkg plugin.json=$plugin marketplace.json=$marketplace" + if [ "$pkg" != "$plugin" ] || [ "$pkg" != "$marketplace" ]; then + echo "version mismatch between package.json, .claude-plugin/plugin.json and .claude-plugin/marketplace.json" >&2 + exit 1 + fi + + - name: Type-check scripts + run: bunx --package typescript@5.9.3 tsc -p tsconfig.json + + - name: Unit tests (no network) + run: bun test ./scripts + + - name: Examples pass the hexagonal rulebook + run: bun scripts/check.ts --rulebook hexagonal --files 'examples/**/*.ts' --classes static --format text --strict diff --git a/CLAUDE.md b/CLAUDE.md index 773eac1..195db14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,36 @@ Compatible with GSD workflow. 7. **Write operations return void or `{ id: string }`** — CQRS strict 8. **No over-engineering** — no use case for simple `findById`, no abstraction for single use, no generic relay patterns +## Rulebook (machine-readable rules) + +`rulebooks/hexagonal.rulebook.yaml` encodes the rules above; `scripts/check.ts` (entry `scripts/run.sh`, bin `nestjs-hexagonal-check`) runs the static ones. Semantic and runtime rules are declared but inert in this version; nothing is sent over the network. Projects opt in with `.claude/rulebook.yaml` (`extends` with sha256 stamps, own rules, overrides by id); `NESTJS_HEXAGONAL_DISABLE=1` turns everything off. + +| Rule id | Class | Severity | Source | +|---|---|---|---| +| `hex/domain-no-nest-decorators` | static | FAIL | review D1, D3, D6, M3 | +| `hex/entity-unique-id` | static | FAIL | review D5 | +| `hex/vo-immutable` | static | FAIL | harness value-object-immutable | +| `hex/repo-interface-in-domain` | static | FAIL | review D6 | +| `hex/module-exports-ports-only` | static | FAIL | review I1 | +| `hex/vo-no-class-validator` | static | FAIL | review D2, P1 | +| `hex/no-circular-import` | static | FAIL | review M3, reviewer circular dependency | +| `hex/event-payload-sufficient` | static | FAIL | reviewer insufficient event payload | +| `hex/handler-max-lines` | static | WARN | reviewer god handler | +| `hex/tests-use-builders` | static | WARN | review D7, T5 | +| `hex/pattern-consistent` | static (external) | WARN | reviewer inconsistent pattern | +| `hex/no-overengineering-static` | static (external) | WARN | reviewer over-engineering audit | +| `hex/handler-no-business-rules` | semantic | FAIL | reviewer god handler | +| `hex/port-no-infra-leak` | semantic | FAIL | review A6 | +| `hex/entity-not-anemic` | semantic | WARN | reviewer anemic model | +| `hex/controller-thin` | semantic | WARN | review P5 | +| `hex/no-overengineering` | semantic (choice) | WARN | reviewer over-engineering audit | +| `hex/tests-coverage` | runtime | WARN | review T1-T5 | +| `softtor/tenant-scoped-query` | static | FAIL | review I7 (`softtor-conventions`) | +| `softtor/no-emoji` | static | FAIL | Softtor style (`softtor-conventions`) | +| `softtor/identifiers-english` | static | WARN | Softtor style (`softtor-conventions`) | + +Adding a static rule requires `calibration/golden//{good,bad}/` fixtures (at least 2 each); `bun test ./scripts` enforces it. Keep `package.json`, `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` on the same version. + ## Skills | Skill | When | diff --git a/README.md b/README.md index 26355e2..36f17e2 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,73 @@ One pattern only: `@EventsHandler` -> enrich if needed -> `WsGatewayPort.emit()` No generic relay, no event maps, no custom broadcast events. Each event that needs to reach the frontend has its own explicit handler. +## Rulebook & CLI + +The architecture rules above also exist as a machine-readable **rulebook** (`rulebooks/hexagonal.rulebook.yaml`) and a checker CLI, `nestjs-hexagonal-check`, that runs the static rules over a set of files. Semantic rules (answered by a typed-judgment model) and runtime rules (package tests) are declared in the rulebook but are not executed by this version: the CLI reports them as skipped and never opens a network connection. + +### Running the checker + +```bash +# inside this repository +bun scripts/check.ts --rulebook hexagonal --files 'src/**/*.ts' --classes static --format text + +# from a project that installed the plugin as a dev dependency +bun add -d github:Softtor/nestjs-hexagonal#v1.2.0 +bunx nestjs-hexagonal-check --files 'apps/api/src/**/*.ts' --strict +bunx nestjs-hexagonal-check --diff origin/main --format json +``` + +| Flag | Meaning | +|---|---| +| `--rulebook ` | rulebook to run; an id resolves to `rulebooks/.rulebook.yaml` in the plugin (`hexagonal`, `softtor-conventions`) | +| `--project-rulebook ` | project rulebook; defaults to `$NESTJS_HEXAGONAL_RULEBOOK`, then `$CLAUDE_PROJECT_DIR/.claude/rulebook.yaml` | +| `--files ` / `--diff ` | files to check (globs relative to the current directory) or `git diff --name-only ` | +| `--classes static[,semantic,runtime]` | rule classes to run (`static` only in this version) | +| `--format json\|text` | output format | +| `--strict` | exit 1 when any FAIL finding exists | +| `--explain` | list the rules applied to each file | + +Each finding carries the rule id, severity (`FAIL`/`WARN`), path, line, evidence and the rule's `fix` text. + +### Project rulebook + +A project opts in by creating `.claude/rulebook.yaml` (or pointing `NESTJS_HEXAGONAL_RULEBOOK` at a file). It extends one or more plugin rulebooks, adds rules under its own namespace and overrides inherited rules by id (`disabled`, `severity`, `scope`, `thresholds`). `rulebooks/project.example.rulebook.yaml` is a complete example. + +```yaml +$schema: nestjs-hexagonal/rulebook@1 +id: acme-crm +version: 0.1.0 +extends: + - { id: hexagonal, version: 1.2.0, sha256: } +model: { provider: typesafe, pin: jev-1.13.0 } +rules: [] +overrides: + - { id: softtor/identifiers-english, scope: { exclude: ['src/legacy/**'] } } +``` + +The `sha256` stamp pins the content of the base rulebook the project was calibrated against. When the installed copy differs, the CLI still runs but reports `rulebook-mismatch` and marks the run `uncalibrated`; a stale stamp never blocks. + +### Opt-in gate and kill switch + +`scripts/run.sh` is the single entry point for the CLI and for the plugin hooks (hooks ship in a later version). In hook mode (`--hook`) it decides in pure shell, before starting any runtime: + +1. no `.claude/rulebook.yaml` in `$CLAUDE_PROJECT_DIR` and no `NESTJS_HEXAGONAL_RULEBOOK` pointing at an existing file: exit 0 with no output (the plugin is inert for projects that did not opt in); +2. `NESTJS_HEXAGONAL_DISABLE=1`: exit 0 (kill switch, also honoured by the CLI); +3. `file_path` resolving outside the project directory: exit 0; +4. the project's own `node_modules/.bin/nestjs-hexagonal-check` is preferred when present, so the version pinned in the project's lockfile is the one that runs; otherwise the plugin's `scripts/check.ts`; +5. missing `node_modules` (plugin loaded in place, or a failed install): an actionable message on stderr and exit 0 in hook mode, exit 1 in CLI mode. + +The runtime is `bun`; when it is absent the script falls back to `node --experimental-strip-types`. + +### Rulebooks shipped + +| Rulebook | Scope | +|---|---| +| `hexagonal` | project-agnostic hexagonal + DDD + CQRS rules (`hex/*`) | +| `softtor-conventions` | multi-tenant scoping, no emoji, English identifiers (`softtor/*`); extend it only if those conventions apply | + +Static rules have golden fixtures under `calibration/golden//{good,bad}/`; `bun test` fails if a static rule lacks fixtures or a fixture stops behaving as labelled. + ## Shared Examples The `shared/` directory contains `.ts.example` reference implementations for projects that don't yet have base classes. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..ffc6856 --- /dev/null +++ b/bun.lock @@ -0,0 +1,29 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "nestjs-hexagonal", + "dependencies": { + "yaml": "^2.8.1", + "zod": "^4.1.11", + }, + "devDependencies": { + "@types/bun": "^1.4.2", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + } +} diff --git a/calibration/golden/hex/domain-no-nest-decorators/bad/domain/entities/order.entity.ts b/calibration/golden/hex/domain-no-nest-decorators/bad/domain/entities/order.entity.ts new file mode 100644 index 0000000..c22f333 --- /dev/null +++ b/calibration/golden/hex/domain-no-nest-decorators/bad/domain/entities/order.entity.ts @@ -0,0 +1,12 @@ +import { Entity } from '@/shared/base-classes/entity'; +import { PrismaService } from '../../infrastructure/prisma/prisma.service'; + +interface OrderProps { + total: number; +} + +export class OrderEntity extends Entity { + constructor(props: OrderProps, private readonly prisma: PrismaService) { + super(props); + } +} diff --git a/calibration/golden/hex/domain-no-nest-decorators/bad/domain/services/order-pricing.service.ts b/calibration/golden/hex/domain-no-nest-decorators/bad/domain/services/order-pricing.service.ts new file mode 100644 index 0000000..6621fc8 --- /dev/null +++ b/calibration/golden/hex/domain-no-nest-decorators/bad/domain/services/order-pricing.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class OrderPricingService { + price(quantity: number, unitPrice: number): number { + return quantity * unitPrice; + } +} diff --git a/calibration/golden/hex/domain-no-nest-decorators/good/domain/entities/order.entity.ts b/calibration/golden/hex/domain-no-nest-decorators/good/domain/entities/order.entity.ts new file mode 100644 index 0000000..11a0949 --- /dev/null +++ b/calibration/golden/hex/domain-no-nest-decorators/good/domain/entities/order.entity.ts @@ -0,0 +1,18 @@ +import { AggregateRoot } from '@nestjs/cqrs'; +import { OrderCreatedEvent } from '../events/order-created.event'; + +interface OrderProps { + total: number; +} + +export class OrderEntity extends AggregateRoot { + private constructor(private readonly props: OrderProps) { + super(); + } + + static create(props: OrderProps): OrderEntity { + const entity = new OrderEntity(props); + entity.apply(new OrderCreatedEvent(props.total)); + return entity; + } +} diff --git a/calibration/golden/hex/domain-no-nest-decorators/good/domain/events/order-created.event.ts b/calibration/golden/hex/domain-no-nest-decorators/good/domain/events/order-created.event.ts new file mode 100644 index 0000000..5db39b8 --- /dev/null +++ b/calibration/golden/hex/domain-no-nest-decorators/good/domain/events/order-created.event.ts @@ -0,0 +1,5 @@ +import { IEvent } from '@nestjs/cqrs'; + +export class OrderCreatedEvent implements IEvent { + constructor(public readonly total: number, public readonly occurredOn: Date = new Date()) {} +} diff --git a/calibration/golden/hex/entity-unique-id/bad/domain/entities/customer.entity.ts b/calibration/golden/hex/entity-unique-id/bad/domain/entities/customer.entity.ts new file mode 100644 index 0000000..6eb04ad --- /dev/null +++ b/calibration/golden/hex/entity-unique-id/bad/domain/entities/customer.entity.ts @@ -0,0 +1,3 @@ +export class CustomerEntity { + constructor(public readonly id: string, public readonly name: string) {} +} diff --git a/calibration/golden/hex/entity-unique-id/bad/domain/entities/invoice.entity.ts b/calibration/golden/hex/entity-unique-id/bad/domain/entities/invoice.entity.ts new file mode 100644 index 0000000..d30d5fa --- /dev/null +++ b/calibration/golden/hex/entity-unique-id/bad/domain/entities/invoice.entity.ts @@ -0,0 +1,11 @@ +export class InvoiceEntity { + private readonly id: number; + + constructor(id: number, private readonly amount: number) { + this.id = id; + } + + get total(): number { + return this.amount; + } +} diff --git a/calibration/golden/hex/entity-unique-id/good/domain/entities/customer.entity.ts b/calibration/golden/hex/entity-unique-id/good/domain/entities/customer.entity.ts new file mode 100644 index 0000000..62d743e --- /dev/null +++ b/calibration/golden/hex/entity-unique-id/good/domain/entities/customer.entity.ts @@ -0,0 +1,15 @@ +import { Entity } from '@/shared/base-classes/entity'; + +interface CustomerProps { + name: string; +} + +export class CustomerEntity extends Entity { + private constructor(props: CustomerProps, id?: string) { + super(props, id); + } + + static create(props: CustomerProps): CustomerEntity { + return new CustomerEntity(props); + } +} diff --git a/calibration/golden/hex/entity-unique-id/good/domain/entities/invoice.entity.ts b/calibration/golden/hex/entity-unique-id/good/domain/entities/invoice.entity.ts new file mode 100644 index 0000000..8528ad7 --- /dev/null +++ b/calibration/golden/hex/entity-unique-id/good/domain/entities/invoice.entity.ts @@ -0,0 +1,8 @@ +import { AggregateRoot } from '@nestjs/cqrs'; +import { UniqueEntityID } from '@/shared/base-classes/unique-entity-id'; + +export class InvoiceEntity extends AggregateRoot { + constructor(public readonly id: UniqueEntityID, private readonly amount: number) { + super(); + } +} diff --git a/calibration/golden/hex/event-payload-sufficient/bad/infrastructure/listeners/order-created-broadcast.handler.ts b/calibration/golden/hex/event-payload-sufficient/bad/infrastructure/listeners/order-created-broadcast.handler.ts new file mode 100644 index 0000000..6668571 --- /dev/null +++ b/calibration/golden/hex/event-payload-sufficient/bad/infrastructure/listeners/order-created-broadcast.handler.ts @@ -0,0 +1,12 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { OrderCreatedEvent } from '../../domain/events/order-created.event'; + +@EventsHandler(OrderCreatedEvent) +export class OrderCreatedBroadcastHandler implements IEventHandler { + constructor(private readonly repo: { get(id: string): Promise }) {} + + async handle(event: OrderCreatedEvent): Promise { + const fresh = await this.repo.get(event.aggregateId); + void fresh; + } +} diff --git a/calibration/golden/hex/event-payload-sufficient/bad/infrastructure/listeners/order-paid-invoice.handler.ts b/calibration/golden/hex/event-payload-sufficient/bad/infrastructure/listeners/order-paid-invoice.handler.ts new file mode 100644 index 0000000..f3f6dc8 --- /dev/null +++ b/calibration/golden/hex/event-payload-sufficient/bad/infrastructure/listeners/order-paid-invoice.handler.ts @@ -0,0 +1,16 @@ +import { Inject } from '@nestjs/common'; +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { OrderPaidEvent } from '../../domain/events/order-paid.event'; +import { ORDER_REPOSITORY_TOKEN, OrderRepository } from '../../domain/repositories/order.repository'; + +@EventsHandler(OrderPaidEvent) +export class OrderPaidInvoiceHandler implements IEventHandler { + constructor(@Inject(ORDER_REPOSITORY_TOKEN) private readonly orderRepository: OrderRepository.Repository) {} + + async handle(event: OrderPaidEvent): Promise { + const order = await this.orderRepository.findById(event.aggregateId); + if (!order) { + return; + } + } +} diff --git a/calibration/golden/hex/event-payload-sufficient/good/infrastructure/adapters/order-lookup.adapter.ts b/calibration/golden/hex/event-payload-sufficient/good/infrastructure/adapters/order-lookup.adapter.ts new file mode 100644 index 0000000..f4b2d6e --- /dev/null +++ b/calibration/golden/hex/event-payload-sufficient/good/infrastructure/adapters/order-lookup.adapter.ts @@ -0,0 +1,11 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ORDER_REPOSITORY_TOKEN, OrderRepository } from '../../domain/repositories/order.repository'; + +@Injectable() +export class OrderLookupAdapter { + constructor(@Inject(ORDER_REPOSITORY_TOKEN) private readonly orderRepository: OrderRepository.Repository) {} + + async exists(id: string): Promise { + return (await this.orderRepository.findById(id)) !== null; + } +} diff --git a/calibration/golden/hex/event-payload-sufficient/good/infrastructure/listeners/order-paid-invoice.handler.ts b/calibration/golden/hex/event-payload-sufficient/good/infrastructure/listeners/order-paid-invoice.handler.ts new file mode 100644 index 0000000..e332b80 --- /dev/null +++ b/calibration/golden/hex/event-payload-sufficient/good/infrastructure/listeners/order-paid-invoice.handler.ts @@ -0,0 +1,17 @@ +import { Inject } from '@nestjs/common'; +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { OrderPaidEvent } from '../../domain/events/order-paid.event'; +import { INVOICING_PORT, InvoicingPort } from '../../application/ports/invoicing.port'; + +@EventsHandler(OrderPaidEvent) +export class OrderPaidInvoiceHandler implements IEventHandler { + constructor(@Inject(INVOICING_PORT) private readonly invoicing: InvoicingPort) {} + + async handle(event: OrderPaidEvent): Promise { + try { + await this.invoicing.issue({ orderId: event.aggregateId, total: event.total, currency: event.currency }); + } catch (error) { + void error; + } + } +} diff --git a/calibration/golden/hex/handler-max-lines/bad/application/commands/place.handler.ts b/calibration/golden/hex/handler-max-lines/bad/application/commands/place.handler.ts new file mode 100644 index 0000000..b1f9d49 --- /dev/null +++ b/calibration/golden/hex/handler-max-lines/bad/application/commands/place.handler.ts @@ -0,0 +1,41 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { PlaceCommand } from './place.command'; + +@CommandHandler(PlaceCommand) +export class PlaceHandler implements ICommandHandler { + async execute(command: PlaceCommand): Promise<{ id: string }> { + const step1 = command.value + 1; + const step2 = command.value + 2; + const step3 = command.value + 3; + const step4 = command.value + 4; + const step5 = command.value + 5; + const step6 = command.value + 6; + const step7 = command.value + 7; + const step8 = command.value + 8; + const step9 = command.value + 9; + const step10 = command.value + 10; + const step11 = command.value + 11; + const step12 = command.value + 12; + const step13 = command.value + 13; + const step14 = command.value + 14; + const step15 = command.value + 15; + const step16 = command.value + 16; + const step17 = command.value + 17; + const step18 = command.value + 18; + const step19 = command.value + 19; + const step20 = command.value + 20; + const step21 = command.value + 21; + const step22 = command.value + 22; + const step23 = command.value + 23; + const step24 = command.value + 24; + const step25 = command.value + 25; + const step26 = command.value + 26; + const step27 = command.value + 27; + const step28 = command.value + 28; + const step29 = command.value + 29; + const step30 = command.value + 30; + const step31 = command.value + 31; + const step32 = command.value + 32; + return { id: String(command.value) }; + } +} diff --git a/calibration/golden/hex/handler-max-lines/bad/application/commands/recompute.handler.ts b/calibration/golden/hex/handler-max-lines/bad/application/commands/recompute.handler.ts new file mode 100644 index 0000000..dc0ce48 --- /dev/null +++ b/calibration/golden/hex/handler-max-lines/bad/application/commands/recompute.handler.ts @@ -0,0 +1,41 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { RecomputeCommand } from './recompute.command'; + +@CommandHandler(RecomputeCommand) +export class RecomputeHandler implements ICommandHandler { + async execute(command: RecomputeCommand): Promise { + const step1 = command.value + 1; + const step2 = command.value + 2; + const step3 = command.value + 3; + const step4 = command.value + 4; + const step5 = command.value + 5; + const step6 = command.value + 6; + const step7 = command.value + 7; + const step8 = command.value + 8; + const step9 = command.value + 9; + const step10 = command.value + 10; + const step11 = command.value + 11; + const step12 = command.value + 12; + const step13 = command.value + 13; + const step14 = command.value + 14; + const step15 = command.value + 15; + const step16 = command.value + 16; + const step17 = command.value + 17; + const step18 = command.value + 18; + const step19 = command.value + 19; + const step20 = command.value + 20; + const step21 = command.value + 21; + const step22 = command.value + 22; + const step23 = command.value + 23; + const step24 = command.value + 24; + const step25 = command.value + 25; + const step26 = command.value + 26; + const step27 = command.value + 27; + const step28 = command.value + 28; + const step29 = command.value + 29; + const step30 = command.value + 30; + const step31 = command.value + 31; + const step32 = command.value + 32; + void command; + } +} diff --git a/calibration/golden/hex/handler-max-lines/bad/application/commands/settle.handler.ts b/calibration/golden/hex/handler-max-lines/bad/application/commands/settle.handler.ts new file mode 100644 index 0000000..dc592f9 --- /dev/null +++ b/calibration/golden/hex/handler-max-lines/bad/application/commands/settle.handler.ts @@ -0,0 +1,53 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { SettleCommand } from './settle.command'; + +@CommandHandler(SettleCommand) +export class SettleHandler implements ICommandHandler { + constructor(private readonly repository: { save(value: number): Promise }) {} + + async execute( + command: SettleCommand, + ): Promise { + const step1 = command.value + 1; + const step2 = command.value + 2; + const step3 = command.value + 3; + const step4 = command.value + 4; + const step5 = command.value + 5; + const step6 = command.value + 6; + const step7 = command.value + 7; + const step8 = command.value + 8; + const step9 = command.value + 9; + const step10 = command.value + 10; + const step11 = command.value + 11; + const step12 = command.value + 12; + const step13 = command.value + 13; + const step14 = command.value + 14; + const step15 = command.value + 15; + const step16 = command.value + 16; + const step17 = command.value + 17; + const step18 = command.value + 18; + const step19 = command.value + 19; + const step20 = command.value + 20; + const step21 = command.value + 21; + const step22 = command.value + 22; + const step23 = command.value + 23; + const step24 = command.value + 24; + const step25 = command.value + 25; + const step26 = command.value + 26; + const step27 = command.value + 27; + const step28 = command.value + 28; + const step29 = command.value + 29; + const step30 = command.value + 30; + const step31 = command.value + 31; + const step32 = command.value + 32; + const step33 = command.value + 33; + const step34 = command.value + 34; + const step35 = command.value + 35; + const step36 = command.value + 36; + const step37 = command.value + 37; + const step38 = command.value + 38; + const step39 = command.value + 39; + const step40 = command.value + 40; + await this.repository.save(command.value); + } +} diff --git a/calibration/golden/hex/handler-max-lines/good/application/commands/create-order.handler.ts b/calibration/golden/hex/handler-max-lines/good/application/commands/create-order.handler.ts new file mode 100644 index 0000000..73ce457 --- /dev/null +++ b/calibration/golden/hex/handler-max-lines/good/application/commands/create-order.handler.ts @@ -0,0 +1,21 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; +import { OrderEntity } from '../../domain/entities/order.entity'; +import { ORDER_REPOSITORY_TOKEN, OrderRepository } from '../../domain/repositories/order.repository'; +import { CreateOrderCommand } from './create-order.command'; + +@CommandHandler(CreateOrderCommand) +export class CreateOrderHandler implements ICommandHandler { + constructor( + @Inject(ORDER_REPOSITORY_TOKEN) private readonly repository: OrderRepository.Repository, + private readonly publisher: EventPublisher, + ) {} + + async execute(command: CreateOrderCommand): Promise<{ id: string }> { + const order = OrderEntity.create({ organizationId: command.organizationId, items: command.items }); + this.publisher.mergeObjectContext(order); + await this.repository.save(order); + order.commit(); + return { id: order.id }; + } +} diff --git a/calibration/golden/hex/handler-max-lines/good/application/queries/get-order.handler.ts b/calibration/golden/hex/handler-max-lines/good/application/queries/get-order.handler.ts new file mode 100644 index 0000000..fe8c4ce --- /dev/null +++ b/calibration/golden/hex/handler-max-lines/good/application/queries/get-order.handler.ts @@ -0,0 +1,18 @@ +import { Inject } from '@nestjs/common'; +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { ORDER_REPOSITORY_TOKEN, OrderRepository } from '../../domain/repositories/order.repository'; +import { OrderNotFoundError } from '../../domain/errors/order-not-found.error'; +import { GetOrderQuery } from './get-order.query'; + +@QueryHandler(GetOrderQuery) +export class GetOrderHandler implements IQueryHandler { + constructor(@Inject(ORDER_REPOSITORY_TOKEN) private readonly repository: OrderRepository.Repository) {} + + async execute(query: GetOrderQuery): Promise { + const order = await this.repository.findById(query.orderId); + if (!order) { + throw new OrderNotFoundError(query.orderId); + } + return order.toJSON(); + } +} diff --git a/calibration/golden/hex/module-exports-ports-only/bad/billing.module.ts b/calibration/golden/hex/module-exports-ports-only/bad/billing.module.ts new file mode 100644 index 0000000..8a2fbdf --- /dev/null +++ b/calibration/golden/hex/module-exports-ports-only/bad/billing.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { INVOICE_REPOSITORY_TOKEN } from '../domain/repositories/invoice.repository'; +import { PrismaInvoiceRepository } from './database/prisma/repositories/prisma-invoice.repository'; + +@Module({ + providers: [PrismaInvoiceRepository, { provide: INVOICE_REPOSITORY_TOKEN, useExisting: PrismaInvoiceRepository }], + exports: [ + INVOICE_REPOSITORY_TOKEN, + PrismaInvoiceRepository, + ], +}) +export class BillingModule {} diff --git a/calibration/golden/hex/module-exports-ports-only/bad/orders.module.ts b/calibration/golden/hex/module-exports-ports-only/bad/orders.module.ts new file mode 100644 index 0000000..868de80 --- /dev/null +++ b/calibration/golden/hex/module-exports-ports-only/bad/orders.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CreateOrderUseCase } from '../application/usecases/create-order.usecase'; + +@Module({ + providers: [CreateOrderUseCase], + exports: [CreateOrderUseCase], +}) +export class OrdersModule {} diff --git a/calibration/golden/hex/module-exports-ports-only/good/billing.module.ts b/calibration/golden/hex/module-exports-ports-only/good/billing.module.ts new file mode 100644 index 0000000..b7890b6 --- /dev/null +++ b/calibration/golden/hex/module-exports-ports-only/good/billing.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { INVOICE_REPOSITORY_TOKEN } from '../domain/repositories/invoice.repository'; +import { PAYMENT_GATEWAY_PORT } from '../application/ports/payment-gateway.port'; + +@Module({ + providers: [], + exports: [ + INVOICE_REPOSITORY_TOKEN, + PAYMENT_GATEWAY_PORT, + ], +}) +export class BillingModule {} diff --git a/calibration/golden/hex/module-exports-ports-only/good/orders.module.ts b/calibration/golden/hex/module-exports-ports-only/good/orders.module.ts new file mode 100644 index 0000000..b8561ce --- /dev/null +++ b/calibration/golden/hex/module-exports-ports-only/good/orders.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ORDER_REPOSITORY_TOKEN } from '../domain/repositories/order.repository'; +import { PrismaOrderRepository } from './database/prisma/repositories/prisma-order.repository'; + +@Module({ + providers: [PrismaOrderRepository, { provide: ORDER_REPOSITORY_TOKEN, useExisting: PrismaOrderRepository }], + exports: [ORDER_REPOSITORY_TOKEN], +}) +export class OrdersModule {} diff --git a/calibration/golden/hex/no-circular-import/bad/billing.module.ts b/calibration/golden/hex/no-circular-import/bad/billing.module.ts new file mode 100644 index 0000000..ad3e39e --- /dev/null +++ b/calibration/golden/hex/no-circular-import/bad/billing.module.ts @@ -0,0 +1,9 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { OrdersModule } from '../orders/infrastructure/orders.module'; + +@Module({ + imports: [ + forwardRef(() => OrdersModule), + ], +}) +export class BillingModule {} diff --git a/calibration/golden/hex/no-circular-import/bad/orders.module.ts b/calibration/golden/hex/no-circular-import/bad/orders.module.ts new file mode 100644 index 0000000..21a5c62 --- /dev/null +++ b/calibration/golden/hex/no-circular-import/bad/orders.module.ts @@ -0,0 +1,7 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { BillingModule } from '../billing/infrastructure/billing.module'; + +@Module({ + imports: [forwardRef(() => BillingModule)], +}) +export class OrdersModule {} diff --git a/calibration/golden/hex/no-circular-import/good/billing.module.ts b/calibration/golden/hex/no-circular-import/good/billing.module.ts new file mode 100644 index 0000000..f8bef15 --- /dev/null +++ b/calibration/golden/hex/no-circular-import/good/billing.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ORDER_INTEGRATION_EVENTS_TOKEN } from '../orders/infrastructure/listeners/order-paid-invoice.handler'; +import { InvoiceFromOrderAdapter } from './adapters/invoice-from-order.adapter'; + +@Module({ + providers: [{ provide: ORDER_INTEGRATION_EVENTS_TOKEN, useClass: InvoiceFromOrderAdapter }], +}) +export class BillingModule {} diff --git a/calibration/golden/hex/no-circular-import/good/orders.module.ts b/calibration/golden/hex/no-circular-import/good/orders.module.ts new file mode 100644 index 0000000..94bfa02 --- /dev/null +++ b/calibration/golden/hex/no-circular-import/good/orders.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; + +@Module({ + imports: [CqrsModule], +}) +export class OrdersModule {} diff --git a/calibration/golden/hex/no-overengineering-static/bad/application/commands/rename-customer.handler.ts b/calibration/golden/hex/no-overengineering-static/bad/application/commands/rename-customer.handler.ts new file mode 100644 index 0000000..f05c0a9 --- /dev/null +++ b/calibration/golden/hex/no-overengineering-static/bad/application/commands/rename-customer.handler.ts @@ -0,0 +1,10 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { normalizeName } from '../helpers/normalize-name'; +import { RenameCustomerCommand } from './rename-customer.command'; + +@CommandHandler(RenameCustomerCommand) +export class RenameCustomerHandler implements ICommandHandler { + async execute(command: RenameCustomerCommand): Promise { + void normalizeName(command.name); + } +} diff --git a/calibration/golden/hex/no-overengineering-static/bad/application/helpers/normalize-name.ts b/calibration/golden/hex/no-overengineering-static/bad/application/helpers/normalize-name.ts new file mode 100644 index 0000000..934cf8d --- /dev/null +++ b/calibration/golden/hex/no-overengineering-static/bad/application/helpers/normalize-name.ts @@ -0,0 +1,3 @@ +export function normalizeName(name: string): string { + return name.trim().toLowerCase(); +} diff --git a/calibration/golden/hex/no-overengineering-static/bad/application/ports/mail.port.ts b/calibration/golden/hex/no-overengineering-static/bad/application/ports/mail.port.ts new file mode 100644 index 0000000..9c8d10c --- /dev/null +++ b/calibration/golden/hex/no-overengineering-static/bad/application/ports/mail.port.ts @@ -0,0 +1,5 @@ +export interface MailPort { + send(to: string, body: string): Promise; +} + +export const MAIL_PORT = Symbol('MailPort'); diff --git a/calibration/golden/hex/no-overengineering-static/good/application/commands/send-welcome.handler.ts b/calibration/golden/hex/no-overengineering-static/good/application/commands/send-welcome.handler.ts new file mode 100644 index 0000000..44f80e7 --- /dev/null +++ b/calibration/golden/hex/no-overengineering-static/good/application/commands/send-welcome.handler.ts @@ -0,0 +1,13 @@ +import { Inject } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { MAIL_PORT, MailPort } from '../ports/mail.port'; +import { SendWelcomeCommand } from './send-welcome.command'; + +@CommandHandler(SendWelcomeCommand) +export class SendWelcomeHandler implements ICommandHandler { + constructor(@Inject(MAIL_PORT) private readonly mail: MailPort) {} + + async execute(command: SendWelcomeCommand): Promise { + await this.mail.send(command.email, 'Welcome'); + } +} diff --git a/calibration/golden/hex/no-overengineering-static/good/application/ports/mail.port.ts b/calibration/golden/hex/no-overengineering-static/good/application/ports/mail.port.ts new file mode 100644 index 0000000..9c8d10c --- /dev/null +++ b/calibration/golden/hex/no-overengineering-static/good/application/ports/mail.port.ts @@ -0,0 +1,5 @@ +export interface MailPort { + send(to: string, body: string): Promise; +} + +export const MAIL_PORT = Symbol('MailPort'); diff --git a/calibration/golden/hex/pattern-consistent/bad/application/commands/cancel-order.handler.ts b/calibration/golden/hex/pattern-consistent/bad/application/commands/cancel-order.handler.ts new file mode 100644 index 0000000..2c153d1 --- /dev/null +++ b/calibration/golden/hex/pattern-consistent/bad/application/commands/cancel-order.handler.ts @@ -0,0 +1,11 @@ +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; +import { CancelOrderCommand } from './cancel-order.command'; + +@CommandHandler(CancelOrderCommand) +export class CancelOrderHandler implements ICommandHandler { + constructor(private readonly publisher: EventPublisher) {} + + async execute(command: CancelOrderCommand): Promise { + void command; + } +} diff --git a/calibration/golden/hex/pattern-consistent/bad/application/usecases/create-order.usecase.ts b/calibration/golden/hex/pattern-consistent/bad/application/usecases/create-order.usecase.ts new file mode 100644 index 0000000..3f17bb3 --- /dev/null +++ b/calibration/golden/hex/pattern-consistent/bad/application/usecases/create-order.usecase.ts @@ -0,0 +1,18 @@ +import type { OrderRepository } from '../../domain/repositories/order.repository'; + +export const CREATE_ORDER_USE_CASE_TOKEN = Symbol('CreateOrderUseCase'); + +export namespace CreateOrderUseCase { + export interface Input { + organizationId: string; + } + + export class UseCase { + constructor(private readonly repository: OrderRepository.Repository) {} + + async execute(input: Input): Promise<{ id: string }> { + void input; + return { id: 'order-1' }; + } + } +} diff --git a/calibration/golden/hex/pattern-consistent/good/application/commands/cancel-order.handler.ts b/calibration/golden/hex/pattern-consistent/good/application/commands/cancel-order.handler.ts new file mode 100644 index 0000000..2c153d1 --- /dev/null +++ b/calibration/golden/hex/pattern-consistent/good/application/commands/cancel-order.handler.ts @@ -0,0 +1,11 @@ +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; +import { CancelOrderCommand } from './cancel-order.command'; + +@CommandHandler(CancelOrderCommand) +export class CancelOrderHandler implements ICommandHandler { + constructor(private readonly publisher: EventPublisher) {} + + async execute(command: CancelOrderCommand): Promise { + void command; + } +} diff --git a/calibration/golden/hex/pattern-consistent/good/application/commands/create-order.handler.ts b/calibration/golden/hex/pattern-consistent/good/application/commands/create-order.handler.ts new file mode 100644 index 0000000..c64cbad --- /dev/null +++ b/calibration/golden/hex/pattern-consistent/good/application/commands/create-order.handler.ts @@ -0,0 +1,12 @@ +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; +import { CreateOrderCommand } from './create-order.command'; + +@CommandHandler(CreateOrderCommand) +export class CreateOrderHandler implements ICommandHandler { + constructor(private readonly publisher: EventPublisher) {} + + async execute(command: CreateOrderCommand): Promise<{ id: string }> { + void command; + return { id: 'order-1' }; + } +} diff --git a/calibration/golden/hex/repo-interface-in-domain/bad/domain/repositories/order-repository.ts b/calibration/golden/hex/repo-interface-in-domain/bad/domain/repositories/order-repository.ts new file mode 100644 index 0000000..fb58bca --- /dev/null +++ b/calibration/golden/hex/repo-interface-in-domain/bad/domain/repositories/order-repository.ts @@ -0,0 +1,3 @@ +export abstract class OrderRepository { + abstract findById(id: string): Promise; +} diff --git a/calibration/golden/hex/repo-interface-in-domain/bad/domain/repositories/task.repository.ts b/calibration/golden/hex/repo-interface-in-domain/bad/domain/repositories/task.repository.ts new file mode 100644 index 0000000..a27dfae --- /dev/null +++ b/calibration/golden/hex/repo-interface-in-domain/bad/domain/repositories/task.repository.ts @@ -0,0 +1,5 @@ +export class TaskRepository { + findById(id: string): Promise { + return Promise.resolve(null); + } +} diff --git a/calibration/golden/hex/repo-interface-in-domain/good/domain/repositories/order.repository.ts b/calibration/golden/hex/repo-interface-in-domain/good/domain/repositories/order.repository.ts new file mode 100644 index 0000000..332f38c --- /dev/null +++ b/calibration/golden/hex/repo-interface-in-domain/good/domain/repositories/order.repository.ts @@ -0,0 +1,13 @@ +import type { OrderEntity } from '../entities/order.entity'; +import { SearchParams as DefaultSearchParams } from '@/shared/repository-contracts/searchable-repository'; + +export namespace OrderRepository { + export class SearchParams extends DefaultSearchParams<{ organizationId: string }> {} + + export interface Repository { + findById(id: string): Promise; + save(entity: OrderEntity): Promise; + } +} + +export const ORDER_REPOSITORY_TOKEN = Symbol('OrderRepository'); diff --git a/calibration/golden/hex/repo-interface-in-domain/good/domain/repositories/task.repository.ts b/calibration/golden/hex/repo-interface-in-domain/good/domain/repositories/task.repository.ts new file mode 100644 index 0000000..ccb1539 --- /dev/null +++ b/calibration/golden/hex/repo-interface-in-domain/good/domain/repositories/task.repository.ts @@ -0,0 +1,8 @@ +import type { TaskEntity } from '../entities/task.entity'; + +export interface TaskRepository { + findById(id: string): Promise; + save(entity: TaskEntity): Promise; +} + +export const TASK_REPOSITORY_TOKEN = Symbol('TaskRepository'); diff --git a/calibration/golden/hex/tests-use-builders/bad/application/commands/__tests__/cancel-order.handler.spec.ts b/calibration/golden/hex/tests-use-builders/bad/application/commands/__tests__/cancel-order.handler.spec.ts new file mode 100644 index 0000000..8e91417 --- /dev/null +++ b/calibration/golden/hex/tests-use-builders/bad/application/commands/__tests__/cancel-order.handler.spec.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { OrderEntity } from '../../../domain/entities/order.entity'; +import { CancelOrderHandler } from '../cancel-order.handler'; + +describe('CancelOrderHandler', () => { + it('cancels', async () => { + const order = new OrderEntity({ organizationId: 'org-1', items: [] }, 'order-1'); + const handler = new CancelOrderHandler({ findById: async () => order, save: async () => undefined }); + await handler.execute({ orderId: 'order-1', organizationId: 'org-1', reason: 'test' }); + expect(order.status.isCancelled()).toBe(true); + }); +}); diff --git a/calibration/golden/hex/tests-use-builders/bad/domain/entities/__tests__/order.entity.spec.ts b/calibration/golden/hex/tests-use-builders/bad/domain/entities/__tests__/order.entity.spec.ts new file mode 100644 index 0000000..b9387cc --- /dev/null +++ b/calibration/golden/hex/tests-use-builders/bad/domain/entities/__tests__/order.entity.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { OrderEntity } from '../order.entity'; + +describe('OrderEntity', () => { + it('starts pending', () => { + const order = OrderEntity.create({ + organizationId: 'org-1', + customerId: 'customer-1', + customerName: 'Ada', + items: [{ productId: 'p-1', name: 'Widget', quantity: 1, unitPrice: 10 }], + currency: 'USD', + }); + expect(order.status.isPending()).toBe(true); + }); +}); diff --git a/calibration/golden/hex/tests-use-builders/good/domain/entities/__tests__/order.entity.spec.ts b/calibration/golden/hex/tests-use-builders/good/domain/entities/__tests__/order.entity.spec.ts new file mode 100644 index 0000000..1e57d71 --- /dev/null +++ b/calibration/golden/hex/tests-use-builders/good/domain/entities/__tests__/order.entity.spec.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { OrderEntity } from '../order.entity'; +import { OrderDataBuilder } from '../../testing/helpers/order.data-builder'; + +describe('OrderEntity', () => { + it('starts pending', () => { + const order = OrderEntity.create(OrderDataBuilder()); + expect(order.status.isPending()).toBe(true); + }); +}); diff --git a/calibration/golden/hex/tests-use-builders/good/domain/value-objects/__tests__/money.vo.spec.ts b/calibration/golden/hex/tests-use-builders/good/domain/value-objects/__tests__/money.vo.spec.ts new file mode 100644 index 0000000..b4233b2 --- /dev/null +++ b/calibration/golden/hex/tests-use-builders/good/domain/value-objects/__tests__/money.vo.spec.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { MoneyVO } from '../money.vo'; + +describe('MoneyVO', () => { + it('adds amounts of the same currency', () => { + expect(MoneyVO.of(10, 'USD').add(MoneyVO.of(5, 'USD')).amount).toBe(15); + }); +}); diff --git a/calibration/golden/hex/vo-immutable/bad/domain/value-objects/email.vo.ts b/calibration/golden/hex/vo-immutable/bad/domain/value-objects/email.vo.ts new file mode 100644 index 0000000..4ffe089 --- /dev/null +++ b/calibration/golden/hex/vo-immutable/bad/domain/value-objects/email.vo.ts @@ -0,0 +1,11 @@ +export class EmailVO { + private value: string; + + constructor(value: string) { + this.value = value.toLowerCase(); + } + + change(value: string): void { + this.value = value.toLowerCase(); + } +} diff --git a/calibration/golden/hex/vo-immutable/bad/domain/value-objects/money.vo.ts b/calibration/golden/hex/vo-immutable/bad/domain/value-objects/money.vo.ts new file mode 100644 index 0000000..d591728 --- /dev/null +++ b/calibration/golden/hex/vo-immutable/bad/domain/value-objects/money.vo.ts @@ -0,0 +1,15 @@ +export class MoneyVO { + private readonly _amount: number; + + constructor(amount: number) { + this._amount = amount; + } + + get amount(): number { + return this._amount; + } + + set amount(value: number) { + Object.assign(this, { _amount: value }); + } +} diff --git a/calibration/golden/hex/vo-immutable/good/domain/value-objects/money.vo.ts b/calibration/golden/hex/vo-immutable/good/domain/value-objects/money.vo.ts new file mode 100644 index 0000000..c940240 --- /dev/null +++ b/calibration/golden/hex/vo-immutable/good/domain/value-objects/money.vo.ts @@ -0,0 +1,11 @@ +export class MoneyVO { + constructor(private readonly _amount: number, private readonly _currency: string) {} + + get amount(): number { + return this._amount; + } + + add(other: MoneyVO): MoneyVO { + return new MoneyVO(this._amount + other.amount, this._currency); + } +} diff --git a/calibration/golden/hex/vo-immutable/good/domain/value-objects/order-status.vo.ts b/calibration/golden/hex/vo-immutable/good/domain/value-objects/order-status.vo.ts new file mode 100644 index 0000000..1513a3e --- /dev/null +++ b/calibration/golden/hex/vo-immutable/good/domain/value-objects/order-status.vo.ts @@ -0,0 +1,17 @@ +import { ValueObject } from '@/shared/base-classes/value-object'; + +export class OrderStatusVO extends ValueObject { + private constructor(value: string) { + super(value); + } + + protected validate(): void { + if (this._value.length === 0) { + throw new Error('status is required'); + } + } + + static pending(): OrderStatusVO { + return new OrderStatusVO('PENDING'); + } +} diff --git a/calibration/golden/hex/vo-no-class-validator/bad/domain/value-objects/email.vo.ts b/calibration/golden/hex/vo-no-class-validator/bad/domain/value-objects/email.vo.ts new file mode 100644 index 0000000..4aff59b --- /dev/null +++ b/calibration/golden/hex/vo-no-class-validator/bad/domain/value-objects/email.vo.ts @@ -0,0 +1,10 @@ +import { IsEmail } from 'class-validator'; + +export class EmailVO { + @IsEmail() + readonly value: string; + + constructor(value: string) { + this.value = value; + } +} diff --git a/calibration/golden/hex/vo-no-class-validator/bad/domain/value-objects/phone.vo.ts b/calibration/golden/hex/vo-no-class-validator/bad/domain/value-objects/phone.vo.ts new file mode 100644 index 0000000..b36fce8 --- /dev/null +++ b/calibration/golden/hex/vo-no-class-validator/bad/domain/value-objects/phone.vo.ts @@ -0,0 +1,11 @@ +import { IsString, Length } from 'class-validator'; + +export class PhoneVO { + @IsString() + @Length(8, 15) + readonly value: string; + + constructor(value: string) { + this.value = value; + } +} diff --git a/calibration/golden/hex/vo-no-class-validator/good/domain/value-objects/email.vo.ts b/calibration/golden/hex/vo-no-class-validator/good/domain/value-objects/email.vo.ts new file mode 100644 index 0000000..fb0b2a1 --- /dev/null +++ b/calibration/golden/hex/vo-no-class-validator/good/domain/value-objects/email.vo.ts @@ -0,0 +1,10 @@ +import { ValueObject } from '@/shared/base-classes/value-object'; +import { InvalidArgumentError } from '@/shared/domain-errors/errors'; + +export class EmailVO extends ValueObject { + protected validate(): void { + if (!this._value.includes('@')) { + throw new InvalidArgumentError(`Invalid email: ${this._value}`); + } + } +} diff --git a/calibration/golden/hex/vo-no-class-validator/good/domain/value-objects/phone.vo.ts b/calibration/golden/hex/vo-no-class-validator/good/domain/value-objects/phone.vo.ts new file mode 100644 index 0000000..f8d309a --- /dev/null +++ b/calibration/golden/hex/vo-no-class-validator/good/domain/value-objects/phone.vo.ts @@ -0,0 +1,10 @@ +export class PhoneVO { + private constructor(readonly value: string) {} + + static of(value: string): PhoneVO { + if (value.length < 8) { + throw new Error('phone too short'); + } + return new PhoneVO(value); + } +} diff --git a/calibration/golden/softtor/identifiers-english/bad/application/services/current-user.ts b/calibration/golden/softtor/identifiers-english/bad/application/services/current-user.ts new file mode 100644 index 0000000..516a0c0 --- /dev/null +++ b/calibration/golden/softtor/identifiers-english/bad/application/services/current-user.ts @@ -0,0 +1,9 @@ +export interface Usuario { + id: string; +} + +const usuarioAtual: Usuario = { id: 'u-1' }; + +export function getCurrentUser(): Usuario { + return usuarioAtual; +} diff --git a/calibration/golden/softtor/identifiers-english/bad/application/usecases/criar-pedido.usecase.ts b/calibration/golden/softtor/identifiers-english/bad/application/usecases/criar-pedido.usecase.ts new file mode 100644 index 0000000..a1dcc5d --- /dev/null +++ b/calibration/golden/softtor/identifiers-english/bad/application/usecases/criar-pedido.usecase.ts @@ -0,0 +1,5 @@ +export class CriarPedidoUseCase { + async execute(input: { organizationId: string }): Promise { + void input; + } +} diff --git a/calibration/golden/softtor/identifiers-english/good/application/services/current-user.ts b/calibration/golden/softtor/identifiers-english/good/application/services/current-user.ts new file mode 100644 index 0000000..b7da683 --- /dev/null +++ b/calibration/golden/softtor/identifiers-english/good/application/services/current-user.ts @@ -0,0 +1,14 @@ +export interface User { + id: string; +} + +// Comentários em português são permitidos; a localização fica na UI. +const currentUser: User = { id: 'u-1' }; + +export function getCurrentUser(): User { + return currentUser; +} + +export const location = 'HQ'; +export const listArray = [1]; +export const conversation = 'thread'; diff --git a/calibration/golden/softtor/identifiers-english/good/application/usecases/create-order.usecase.ts b/calibration/golden/softtor/identifiers-english/good/application/usecases/create-order.usecase.ts new file mode 100644 index 0000000..ac45227 --- /dev/null +++ b/calibration/golden/softtor/identifiers-english/good/application/usecases/create-order.usecase.ts @@ -0,0 +1,5 @@ +export class CreateOrderUseCase { + async execute(input: { organizationId: string }): Promise { + void input; + } +} diff --git a/calibration/golden/softtor/no-emoji/bad/application/notify.ts b/calibration/golden/softtor/no-emoji/bad/application/notify.ts new file mode 100644 index 0000000..f8d623c --- /dev/null +++ b/calibration/golden/softtor/no-emoji/bad/application/notify.ts @@ -0,0 +1,2 @@ +// 🚀 ships the notification +export const ROCKET = 'launch'; diff --git a/calibration/golden/softtor/no-emoji/bad/infrastructure/logger.ts b/calibration/golden/softtor/no-emoji/bad/infrastructure/logger.ts new file mode 100644 index 0000000..37d8fdd --- /dev/null +++ b/calibration/golden/softtor/no-emoji/bad/infrastructure/logger.ts @@ -0,0 +1,3 @@ +export function logSuccess(message: string): void { + console.log(`✅ ${message}`); +} diff --git a/calibration/golden/softtor/no-emoji/good/application/notify.ts b/calibration/golden/softtor/no-emoji/good/application/notify.ts new file mode 100644 index 0000000..d976f3a --- /dev/null +++ b/calibration/golden/softtor/no-emoji/good/application/notify.ts @@ -0,0 +1,2 @@ +// ships the notification with accented text: configuração, ação +export const LAUNCH = 'launch'; diff --git a/calibration/golden/softtor/no-emoji/good/infrastructure/logger.ts b/calibration/golden/softtor/no-emoji/good/infrastructure/logger.ts new file mode 100644 index 0000000..b768322 --- /dev/null +++ b/calibration/golden/softtor/no-emoji/good/infrastructure/logger.ts @@ -0,0 +1,3 @@ +export function logSuccess(message: string): void { + console.log(`[ok] ${message}`); +} diff --git a/calibration/golden/softtor/tenant-scoped-query/bad/infrastructure/database/prisma/repositories/prisma-order.repository.ts b/calibration/golden/softtor/tenant-scoped-query/bad/infrastructure/database/prisma/repositories/prisma-order.repository.ts new file mode 100644 index 0000000..5042296 --- /dev/null +++ b/calibration/golden/softtor/tenant-scoped-query/bad/infrastructure/database/prisma/repositories/prisma-order.repository.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/shared/infrastructure/prisma/prisma.service'; + +@Injectable() +export class PrismaOrderRepository { + constructor(private readonly prisma: PrismaService) {} + + async findAll(): Promise { + return this.prisma.order.findMany(); + } + + async delete(id: string): Promise { + await this.prisma.order.delete({ where: { id } }); + } +} diff --git a/calibration/golden/softtor/tenant-scoped-query/bad/infrastructure/database/prisma/repositories/prisma-task.repository.ts b/calibration/golden/softtor/tenant-scoped-query/bad/infrastructure/database/prisma/repositories/prisma-task.repository.ts new file mode 100644 index 0000000..4cf3e62 --- /dev/null +++ b/calibration/golden/softtor/tenant-scoped-query/bad/infrastructure/database/prisma/repositories/prisma-task.repository.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/shared/infrastructure/prisma/prisma.service'; + +@Injectable() +export class PrismaTaskRepository { + constructor(private readonly prisma: PrismaService) {} + + async rename(id: string, title: string): Promise { + await this.prisma.task.update({ + where: { id }, + data: { title }, + }); + } +} diff --git a/calibration/golden/softtor/tenant-scoped-query/good/infrastructure/database/prisma/repositories/prisma-order.repository.ts b/calibration/golden/softtor/tenant-scoped-query/good/infrastructure/database/prisma/repositories/prisma-order.repository.ts new file mode 100644 index 0000000..f4caed1 --- /dev/null +++ b/calibration/golden/softtor/tenant-scoped-query/good/infrastructure/database/prisma/repositories/prisma-order.repository.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/shared/infrastructure/prisma/prisma.service'; + +@Injectable() +export class PrismaOrderRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByOrganization(organizationId: string): Promise { + return this.prisma.order.findMany({ where: { organizationId }, orderBy: { createdAt: 'desc' } }); + } + + async delete(id: string, organizationId: string): Promise { + await this.prisma.order.delete({ where: { id, organizationId } }); + } + + async rename(id: string, organizationId: string, title: string): Promise { + await this.prisma.order.update({ + where: { id, organizationId }, + data: { title }, + }); + } +} diff --git a/calibration/golden/softtor/tenant-scoped-query/good/infrastructure/database/prisma/repositories/prisma-task.repository.ts b/calibration/golden/softtor/tenant-scoped-query/good/infrastructure/database/prisma/repositories/prisma-task.repository.ts new file mode 100644 index 0000000..ec1a21d --- /dev/null +++ b/calibration/golden/softtor/tenant-scoped-query/good/infrastructure/database/prisma/repositories/prisma-task.repository.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@/shared/infrastructure/prisma/prisma.service'; + +@Injectable() +export class PrismaTaskRepository { + constructor(private readonly prisma: PrismaService) {} + + async findById(id: string): Promise { + return this.prisma.task.findUnique({ where: { id } }); + } + + async insert(data: { organizationId: string; title: string }): Promise { + await this.prisma.task.create({ data }); + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..27556f7 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "nestjs-hexagonal", + "version": "1.2.0-dev.0", + "description": "Claude Code plugin for NestJS bounded contexts with Hexagonal Architecture, DDD and CQRS. Ships a rulebook checker CLI.", + "type": "module", + "license": "MIT", + "repository": "https://github.com/softtor/nestjs-hexagonal", + "bin": { + "nestjs-hexagonal-check": "scripts/run.sh" + }, + "files": [ + ".claude-plugin", + "agents", + "rulebooks", + "scripts/check.ts", + "scripts/lib", + "scripts/run.sh", + "shared", + "skills", + "CLAUDE.md", + "LICENSE", + "README.md" + ], + "scripts": { + "test": "bun test ./scripts", + "check": "bun scripts/check.ts" + }, + "dependencies": { + "yaml": "^2.8.1", + "zod": "^4.1.11" + }, + "devDependencies": { + "@types/bun": "^1.4.2" + } +} diff --git a/rulebooks/hexagonal.rulebook.yaml b/rulebooks/hexagonal.rulebook.yaml new file mode 100644 index 0000000..4cbfb48 --- /dev/null +++ b/rulebooks/hexagonal.rulebook.yaml @@ -0,0 +1,461 @@ +# Project-agnostic rulebook for NestJS bounded contexts built with +# Hexagonal Architecture + DDD + CQRS. Sources point at the check ids of +# skills/review-subdomain/SKILL.md (D1..M4) and the smells listed in +# agents/architecture-reviewer.md. Semantic and runtime rules are declared +# here but only static rules are executed by this version of the CLI. +$schema: nestjs-hexagonal/rulebook@1 +id: hexagonal +version: 1.2.0 +extends: [] +model: + provider: typesafe + pin: jev-1.13.0 + +rules: + # -------------------------------------------------------------------------- + # static FAIL + # -------------------------------------------------------------------------- + - id: hex/domain-no-nest-decorators + title: Domain has no framework or infrastructure imports + layer: domain + scope: + include: ['**/domain/**/*.ts'] + exclude: ['**/__tests__/**', '**/*.spec.ts', '**/*.test.ts'] + class: static + severity: FAIL + rationale: >- + The domain layer must compile without NestJS, Prisma or any adapter. + Only AggregateRoot and IEvent from @nestjs/cqrs are allowed. + fix: >- + Remove the import and the decorator. Inject dependencies through a port + in the application layer; keep entities and value objects plain classes. + source: review-subdomain D1, D3, D6, M3; architecture-reviewer leaky abstraction + check: + kind: forbidden-import + modules: ['@nestjs/*', '@prisma/client', 'prisma', '**/infrastructure/**'] + allow: ['@nestjs/cqrs'] + tags: [purity] + + - id: hex/entity-unique-id + title: Entities extend the Entity base and use UniqueEntityID + layer: domain + scope: + include: ['**/domain/entities/**/*.entity.ts'] + exclude: ['**/__tests__/**'] + class: static + severity: FAIL + rationale: >- + Every aggregate carries its identity through the shared Entity base + (UniqueEntityID). Hand-rolled id fields break equality and event + correlation. + fix: >- + Declare the class as `extends Entity` (or AggregateRoot) and let + the base own the id; never model ids as custom value objects. + source: review-subdomain D5; harness unique-entity-id-required + check: + kind: regex + pattern: 'class\s+\w+\s+extends\s+(?:\w+\.)?(?:Entity|AggregateRoot)\b|UniqueEntityID' + mustMatch: true + tags: [identity] + + - id: hex/vo-immutable + title: Value objects are immutable + layer: domain + scope: + include: ['**/domain/value-objects/**/*.ts'] + exclude: ['**/__tests__/**', '**/*.spec.ts'] + class: static + severity: FAIL + rationale: >- + A value object is defined by its value; mutation makes two equal values + diverge silently. Setters and mutable fields are not allowed. + fix: >- + Remove setters, mark fields `readonly` and return a new instance from + every operation that changes the value. + source: review-subdomain D2 context; harness value-object-immutable + check: + kind: regex + pattern: '^[ \t]*(?:public\s+)?set\s+\w+\s*\(|^[ \t]*(?:public|private|protected)\s+(?!readonly\b|static\b|constructor\b|abstract\b|get\b|set\b|async\b)\w+\s*[?!]?\s*[:=]' + flags: m + tags: [immutability] + + - id: hex/repo-interface-in-domain + title: Domain repositories are interfaces, not classes + layer: domain + scope: + include: ['**/domain/repositories/**/*.ts', '**/domain/**/*repository.ts'] + exclude: ['**/__tests__/**'] + class: static + severity: FAIL + rationale: >- + The domain declares what persistence must offer; implementations live + in infrastructure. A class named *Repository in the domain is either an + implementation leak or an untestable stub. + fix: >- + Turn the class into an `interface` (plus a `Symbol` token) and move the + implementation to infrastructure/database. + source: review-subdomain D6; harness repo-interface-in-domain + check: + kind: regex + pattern: '^[ \t]*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+\w*Repository\b' + flags: m + tags: [ports] + + - id: hex/module-exports-ports-only + title: Modules export only port tokens + layer: infrastructure + scope: + include: ['**/*.module.ts'] + exclude: ['**/__tests__/**'] + class: static + severity: FAIL + rationale: >- + Exporting use cases, handlers, repositories or services couples other + modules to concrete classes and bypasses the port boundary. + fix: >- + Export the `Symbol` tokens of the ports only and bind the concrete class + inside the module's providers. + source: review-subdomain I1; harness port-only-module-exports + check: + kind: regex + pattern: 'exports:\s*\[[^\]]*\b[A-Z]\w*(?:UseCase|Repository|Handler|Service|Controller)\b' + tags: [ports] + + - id: hex/vo-no-class-validator + title: Value objects do not use class-validator + layer: domain + scope: + include: ['**/domain/value-objects/**/*.ts'] + exclude: ['**/__tests__/**'] + class: static + severity: FAIL + rationale: >- + Business invariants are enforced by a manual `validate()`; + class-validator belongs to presentation request DTOs only. + fix: Replace the decorators with explicit checks inside `validate()`. + source: review-subdomain D2, P1; harness no-class-validator-in-vo + check: + kind: forbidden-import + modules: ['class-validator'] + tags: [validation] + + - id: hex/no-circular-import + title: Modules do not rely on forwardRef + layer: infrastructure + scope: + include: ['**/*.module.ts'] + class: static + severity: FAIL + rationale: >- + `forwardRef` hides a circular dependency between bounded contexts; the + fix is a port owned by one side, not a lazy reference. + fix: >- + Break the cycle: let one context define the port and the other implement + it, then remove `forwardRef`. + source: review-subdomain M3; architecture-reviewer circular dependency + check: + kind: regex + pattern: 'forwardRef\s*\(' + tags: [modules] + + - id: hex/event-payload-sufficient + title: Event handlers do not re-fetch what the event should carry + layer: infrastructure + scope: + include: ['**/infrastructure/**/*.ts'] + exclude: ['**/__tests__/**', '**/*.spec.ts'] + class: static + severity: FAIL + rationale: >- + An `@EventsHandler` that queries a repository to rebuild the context of + the event means the payload is insufficient; the event must carry the + data its consumers need. + fix: >- + Add the missing fields to the domain event and read them from the event + instead of querying the repository. + source: architecture-reviewer insufficient event payload + check: + kind: regex + whenPattern: '@EventsHandler\s*\(' + pattern: 'this\.\w*(?:[rR]epositor(?:y|ies)|[rR]epo)\w*\.(?:find|get|search|load)\w*\s*\(' + tags: [events] + + # -------------------------------------------------------------------------- + # static WARN + # -------------------------------------------------------------------------- + - id: hex/handler-max-lines + title: Handlers stay short + layer: application + scope: + include: ['**/application/**/*.handler.ts'] + exclude: ['**/__tests__/**'] + class: static + severity: WARN + rationale: >- + A long `execute()` usually hides business rules that belong to the + aggregate (god handler smell). The budget counts the whole method, + signature and braces included. + fix: Move rules into entity methods and keep the handler to load, act, save, commit. + source: architecture-reviewer god handler + check: + kind: line-count + selector: method + name: execute + max: 30 + tags: [size] + + - id: hex/tests-use-builders + title: Tests build entities through data builders + layer: testing + scope: + include: ['**/domain/**/*.spec.ts', '**/application/**/*.spec.ts'] + class: static + severity: WARN + rationale: >- + Inline entity props duplicate defaults across tests and break every + test when a prop is added; builders keep fixtures in one place. + fix: Import the `*DataBuilder` from domain/testing/helpers and override only what the test cares about. + source: review-subdomain D7, T5; architecture-reviewer test smell no data builders + check: + kind: required-import + modules: ['**/*data-builder*', '**/*.builder', '**/testing/helpers/**'] + whenPattern: '\w+Entity\.create\s*\(|new\s+\w+Entity\s*\(' + tags: [testing] + + - id: hex/pattern-consistent + title: One application pattern per bounded context + layer: application + scope: + include: ['**/application/**/*.ts'] + exclude: ['**/__tests__/**', '**/*.spec.ts'] + class: static + severity: WARN + rationale: >- + Mixing plain use cases with TOKEN (A), CQRS handlers (B) and + orchestrator handlers (C) in the same bounded context makes the flow + unpredictable for readers and reviewers. + fix: Pick one pattern for the bounded context and migrate the outliers. + source: architecture-reviewer inconsistent pattern + check: + kind: external + executorId: hex/pattern-consistent + tags: [consistency] + + - id: hex/no-overengineering-static + title: No single-use helpers or unused ports + layer: application + scope: + include: ['**/application/**/*.ts', '**/domain/**/*.ts'] + exclude: ['**/__tests__/**', '**/*.spec.ts', '**/testing/**'] + class: static + severity: WARN + rationale: >- + Counts that code can do: an exported helper with exactly one caller + should be inlined; a port token nobody injects should be removed. + Empty directories and small event tables are left to the reviewer. + fix: Inline the helper into its only caller or delete the unused port. + source: architecture-reviewer over-engineering audit (single-use abstraction, unused port) + check: + kind: external + executorId: hex/no-overengineering-static + tags: [yagni] + + # -------------------------------------------------------------------------- + # semantic (declared; inert until the Jev client ships) + # -------------------------------------------------------------------------- + - id: hex/handler-no-business-rules + title: Handlers do not decide business rules + layer: application + scope: + include: ['**/application/**/*.handler.ts'] + exclude: ['**/__tests__/**'] + class: semantic + severity: FAIL + rationale: >- + A handler orchestrates: load, call the aggregate, save, commit. A + condition on domain state that changes the outcome is an invariant and + belongs to the entity or value object. + fix: Move the condition into an entity or value object method and call it from the handler. + source: review-subdomain A4 context; architecture-reviewer god handler + question: + type: noul + instructions: >- + The handler itself decides a business rule: an if/switch on domain + state (status, amounts, dates, quantities) that changes what happens, + instead of calling a method of an entity or value object. Null checks, + organization ownership checks, mapping and the load-act-save-commit + sequence are not business rules. + state: + slice: file + contextLines: 0 + maxTokens: 4000 + preamble: >- + TypeScript CQRS command handler in a NestJS bounded context built with + hexagonal architecture. Invariants belong to aggregates and value + objects; handlers only orchestrate. + thresholds: + deny: 0.9 + ask: 0.75 + advise: 0.55 + uncertain: { lo: 0.35, hi: 0.65 } + tags: [orchestration] + + - id: hex/port-no-infra-leak + title: Ports do not leak infrastructure types + layer: application + scope: + include: ['**/application/ports/**/*.ts'] + class: semantic + severity: FAIL + rationale: >- + A port is the application's contract; vendor, ORM, HTTP or queue + concepts in its signature bind every consumer to one adapter. + fix: Express the port in domain terms and translate to the vendor shape inside the adapter. + source: review-subdomain A6; architecture-reviewer leaky abstraction + question: + type: noul + instructions: >- + The port interface exposes an infrastructure detail in its signature: + a parameter, return type or method name that names a vendor SDK, ORM + model, HTTP request or response, queue message or SQL. Domain + entities, value objects and plain TypeScript objects are not leaks. + state: + slice: file + contextLines: 0 + maxTokens: 3000 + preamble: >- + TypeScript port (interface plus Symbol token) from the application + layer of a hexagonal NestJS bounded context. + thresholds: + deny: 0.9 + ask: 0.75 + advise: 0.55 + uncertain: { lo: 0.35, hi: 0.65 } + tags: [ports] + + - id: hex/entity-not-anemic + title: Entities carry behavior + layer: domain + scope: + include: ['**/domain/entities/**/*.entity.ts'] + exclude: ['**/__tests__/**'] + class: semantic + severity: WARN + rationale: >- + An entity that only exposes getters pushes every rule into handlers and + services (anemic model). + fix: Add methods that enforce the invariant and emit the domain event; remove setters. + source: architecture-reviewer anemic domain model + question: + type: noul + instructions: >- + The entity class exposes state only through getters, setters or public + fields and has no method that enforces a rule, changes state under a + condition or applies a domain event. The create/restore factories, + getters and toJSON do not count as behavior. + state: + slice: file + contextLines: 0 + maxTokens: 4000 + preamble: >- + TypeScript aggregate root from the domain layer of a hexagonal NestJS + bounded context. + thresholds: + advise: 0.55 + uncertain: { lo: 0.35, hi: 0.65 } + tags: [ddd] + + - id: hex/controller-thin + title: Controllers only translate HTTP + layer: presentation + scope: + include: ['**/controllers/**/*.controller.ts', '**/presentation/**/*.controller.ts'] + exclude: ['**/__tests__/**'] + class: semantic + severity: WARN + rationale: >- + A controller maps the request to a command, query or use case and + returns the result; anything else is logic without a test seam. + fix: Move the logic into a use case or handler and dispatch it from the controller. + source: review-subdomain P5; architecture-reviewer fat controller + question: + type: noul + instructions: >- + A controller method does more than turning the HTTP request into a + command, query or use case call and returning the result: it calls a + repository directly, instantiates a domain entity, or branches on + domain data beyond a null check. + state: + slice: file + contextLines: 0 + maxTokens: 4000 + preamble: >- + TypeScript NestJS REST controller from a hexagonal bounded context. + Controllers dispatch through CommandBus, QueryBus or injected use cases. + thresholds: + advise: 0.55 + uncertain: { lo: 0.35, hi: 0.65 } + tags: [presentation] + + - id: hex/no-overengineering + title: No abstraction without a second use + layer: application + scope: + include: ['**/application/**/*.ts'] + exclude: ['**/__tests__/**', '**/*.spec.ts'] + class: semantic + severity: WARN + rationale: >- + Three lines of code beat a premature abstraction; each of the listed + shapes adds a layer that a simpler call already covers. + fix: Remove the layer and call the simpler thing directly (repository, toJSON, use case, Prisma query). + source: architecture-reviewer over-engineering audit + question: + type: choice + instructions: >- + Which over-engineering shape, if any, does this file introduce? + options: + - id: trivial-use-case + criteria: A use case or handler that only wraps a single findById or findAll, with no authorization, side effect or mapping. + - id: redundant-mapper + criteria: An output mapper that copies the same fields the entity's toJSON() already returns. + - id: delegating-service + criteria: A service whose methods only forward to one use case without adding logic. + - id: trivial-read-model + criteria: A read model, projection or cache for a query that one indexed database query answers. + - id: none + criteria: The file does not introduce any of the shapes above. + - id: other + criteria: Something else that does not fit the options above. + violatingOptions: [trivial-use-case, redundant-mapper, delegating-service, trivial-read-model] + state: + slice: file + contextLines: 0 + maxTokens: 4000 + preamble: >- + TypeScript file from the application layer of a hexagonal NestJS + bounded context that prefers the simplest working shape. + thresholds: + advise: 0.55 + minConfidence: 0.6 + tags: [yagni] + + # -------------------------------------------------------------------------- + # runtime (declared; executed by the package test runner in a later version) + # -------------------------------------------------------------------------- + - id: hex/tests-coverage + title: Every layer has specs and application specs use the in-memory repository + layer: testing + scope: + include: ['**/domain/**', '**/application/**', '**/controllers/**'] + class: runtime + severity: WARN + rationale: >- + Entities, value objects, handlers and controllers each need a sibling + spec; application specs run against the in-memory repository. + fix: Add the missing __tests__/*.spec.ts and use the InMemoryRepository in application specs. + source: review-subdomain T1, T2, T3, T4, T5 + runtime: + runner: package-test + gate: each entity, value object, handler and controller has a sibling __tests__/*.spec.ts and application specs import an InMemoryRepository + tags: [testing] diff --git a/rulebooks/project.example.rulebook.yaml b/rulebooks/project.example.rulebook.yaml new file mode 100644 index 0000000..278fb4e --- /dev/null +++ b/rulebooks/project.example.rulebook.yaml @@ -0,0 +1,40 @@ +# Example project rulebook. Copy it to /.claude/rulebook.yaml, +# refresh the sha256 stamps (sha256sum rulebooks/.rulebook.yaml in the +# installed plugin) and add the project's own rules under its namespace. +# A stale stamp does not stop the check: findings are reported as +# uncalibrated and never block. +$schema: nestjs-hexagonal/rulebook@1 +id: acme-crm +version: 0.1.0 +extends: + - id: hexagonal + version: 1.2.0 + sha256: c4fe2a9c4187bb7c2b2b3524663691d1f68264f6e9fd84c96ecac69e7033c6a2 + - id: softtor-conventions + version: 1.2.0 + sha256: 14f70ba797357cafaeadb8bdbf1cb4d2e9670fc86d9167cccb439cd6a9921dc8 +model: + provider: typesafe + pin: jev-1.13.0 + +rules: + - id: acme/no-console-in-handlers + title: Handlers do not log to the console + layer: application + scope: + include: ['src/**/application/**/*.handler.ts'] + class: static + severity: WARN + rationale: Handlers log through the injected logger port so tests can assert on it. + fix: Inject the LoggerPort and remove the console call. + source: acme conventions + check: + kind: regex + pattern: 'console\.(?:log|warn|error)\s*\(' + tags: [logging] + +overrides: + - id: softtor/identifiers-english + severity: WARN + scope: + exclude: ['src/legacy/**'] diff --git a/rulebooks/softtor-conventions.rulebook.yaml b/rulebooks/softtor-conventions.rulebook.yaml new file mode 100644 index 0000000..6df8ae8 --- /dev/null +++ b/rulebooks/softtor-conventions.rulebook.yaml @@ -0,0 +1,65 @@ +# Softtor house conventions that are not part of hexagonal architecture: +# multi-tenant scoping, no emoji in code, English identifiers. Projects that +# are not multi-tenant extend only the hexagonal rulebook. +$schema: nestjs-hexagonal/rulebook@1 +id: softtor-conventions +version: 1.2.0 +extends: [] +model: + provider: typesafe + pin: jev-1.13.0 + +rules: + - id: softtor/tenant-scoped-query + title: Repository queries are scoped by organizationId + layer: infrastructure + scope: + include: ['**/infrastructure/**/*.repository.ts', '**/infrastructure/**/repositories/**/*.ts'] + exclude: ['**/in-memory/**', '**/__tests__/**', '**/*.spec.ts'] + class: static + severity: FAIL + rationale: >- + A findMany, findFirst, update or delete without organizationId in its + arguments reads or writes across tenants. + fix: Add `organizationId` to the `where` clause of the call (or scope by the tenant field the model uses). + source: review-subdomain I7; architecture-reviewer tenant leakage + check: + kind: regex + pattern: '\.(?:findMany|findFirst|updateMany|deleteMany|update|delete)\s*\((?![^;]*organizationId)' + tags: [multi-tenant] + + - id: softtor/no-emoji + title: No emoji in source code + layer: any + scope: + include: ['**/*.ts', '**/*.tsx'] + class: static + severity: FAIL + rationale: Emoji in strings, logs or comments break grep, terminals and log pipelines. + fix: Replace the emoji with plain text. + source: Softtor code style (CLAUDE.md); harness pt-br conventions + check: + kind: regex + pattern: '[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F1E6}-\u{1F1FF}]' + flags: u + tags: [style] + + - id: softtor/identifiers-english + title: Identifiers are written in English + layer: any + scope: + include: ['**/*.ts', '**/*.tsx'] + exclude: ['**/__tests__/**', '**/*.spec.ts', '**/*.test.ts'] + class: static + severity: WARN + rationale: >- + Code identifiers are English; Portuguese stays in tests, comments, + docs and UI copy. Heuristic on common Portuguese stems in declarations; + grandfather legacy paths through a scope override. + fix: Rename the identifier in English (for example `usuario` -> `user`, `criarPedido` -> `createOrder`). + source: Softtor conventions (identifiers in English) + check: + kind: regex + pattern: '\b(?:const|let|var|function|class|interface|type|enum)\s+\w*(?:(?:[cç][aã]o|[cç][oõ]es)(?=[A-Z_\d]|\b)|usuario|pedido|tarefa|mensagem|empresa|endereco|telefone|senha|buscar|atualizar|salvar|validar|enviar|criar|obter|cadastr)\w*' + flags: i + tags: [style] diff --git a/scripts/__tests__/check.spec.ts b/scripts/__tests__/check.spec.ts new file mode 100644 index 0000000..a5be861 --- /dev/null +++ b/scripts/__tests__/check.spec.ts @@ -0,0 +1,246 @@ +import './helpers/no-network.ts'; +import { assertNetworkForbidden } from './helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { runCli, type CliIo } from '../check.ts'; +import { readRulebookFile, sha256Of } from '../lib/compose.ts'; +import { readFileSync } from 'node:fs'; + +const PLUGIN_ROOT = resolve(import.meta.dir, '../..'); +const GOLDEN_ROOT = join(PLUGIN_ROOT, 'calibration', 'golden'); + +interface Captured extends CliIo { + out: string[]; + err: string[]; +} + +function capture(): Captured { + const out: string[] = []; + const err: string[] = []; + return { + out, + err, + stdout: (text) => { + out.push(text); + }, + stderr: (text) => { + err.push(text); + }, + }; +} + +interface JsonReport { + rulebook: { id: string; version: string }; + uncalibrated: boolean; + warnings: string[]; + findings: Array<{ ruleId: string; severity: string; path: string; line?: number }>; + skipped: { semantic: string[]; runtime: string[] }; + explain?: Record; +} + +function isJsonReport(value: unknown): value is JsonReport { + return typeof value === 'object' && value !== null && 'findings' in value && 'skipped' in value; +} + +function run(args: string[], env: Record = {}, cwd = PLUGIN_ROOT): { code: number; io: Captured } { + const io = capture(); + const code = runCli(args, io, { cwd, env, pluginRoot: PLUGIN_ROOT }); + return { code, io }; +} + +function runJson(args: string[], env: Record = {}, cwd = PLUGIN_ROOT): { code: number; report: JsonReport; io: Captured } { + const { code, io } = run([...args, '--format', 'json'], env, cwd); + const parsed: unknown = JSON.parse(io.out.join('')); + if (!isJsonReport(parsed)) { + throw new Error(`unexpected report: ${io.out.join('')}`); + } + return { code, report: parsed, io }; +} + +function listFiles(dir: string): string[] { + if (!existsSync(dir)) { + return []; + } + return readdirSync(dir, { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath, entry.name)); +} + +const baseRulebooks = ['hexagonal', 'softtor-conventions'].map((id) => readRulebookFile(join(PLUGIN_ROOT, 'rulebooks', `${id}.rulebook.yaml`))); +const staticRules = baseRulebooks.flatMap(({ rulebook }) => rulebook.rules.filter((rule) => rule.class === 'static').map((rule) => ({ rule, rulebookId: rulebook.id }))); + +describe('golden fixtures', () => { + it('cover every static rule with at least two good and two bad files', () => { + for (const { rule } of staticRules) { + const good = listFiles(join(GOLDEN_ROOT, rule.id, 'good')); + const bad = listFiles(join(GOLDEN_ROOT, rule.id, 'bad')); + expect(good.length, `${rule.id} good fixtures`).toBeGreaterThanOrEqual(2); + expect(bad.length, `${rule.id} bad fixtures`).toBeGreaterThanOrEqual(2); + } + }); + + for (const { rule, rulebookId } of staticRules) { + it(`${rule.id}: bad fixtures produce findings, good fixtures do not`, () => { + const badGlob = `calibration/golden/${rule.id}/bad/**`; + const goodGlob = `calibration/golden/${rule.id}/good/**`; + const bad = runJson(['--rulebook', rulebookId, '--files', badGlob]); + const badHits = bad.report.findings.filter((finding) => finding.ruleId === rule.id); + expect(badHits.length, `${rule.id} bad`).toBeGreaterThan(0); + for (const finding of badHits) { + expect(finding.severity).toBe(rule.severity); + } + const good = runJson(['--rulebook', rulebookId, '--files', goodGlob]); + const goodHits = good.report.findings.filter((finding) => finding.ruleId === rule.id); + expect(goodHits, `${rule.id} good`).toEqual([]); + }); + } +}); + +describe('examples', () => { + it('order-bounded-context passes the hexagonal rulebook with zero FAIL', () => { + const { code, report } = runJson(['--rulebook', 'hexagonal', '--files', 'examples/**/*.ts', '--strict']); + const fails = report.findings.filter((finding) => finding.severity === 'FAIL'); + expect(fails).toEqual([]); + expect(code).toBe(0); + expect(report.warnings).toEqual([]); + }); +}); + +describe('runCli', () => { + it('never touches the network', () => { + runJson(['--rulebook', 'hexagonal', '--files', 'examples/**/*.ts']); + assertNetworkForbidden(); + }); + + it('reports semantic and runtime classes as not implemented and skips them', () => { + const { report, io } = runJson(['--rulebook', 'hexagonal', '--files', 'examples/**/*.ts', '--classes', 'static,semantic,runtime']); + expect(report.skipped.semantic).toContain('hex/handler-no-business-rules'); + expect(report.skipped.runtime).toContain('hex/tests-coverage'); + expect(io.err.join('')).toContain('not implemented in this version'); + }); + + it('exits 1 with --strict when a FAIL exists and 0 otherwise', () => { + const glob = 'calibration/golden/hex/domain-no-nest-decorators/bad/**'; + expect(run(['--rulebook', 'hexagonal', '--files', glob]).code).toBe(0); + expect(run(['--rulebook', 'hexagonal', '--files', glob, '--strict']).code).toBe(1); + }); + + it('prints text findings with path, line, severity, rule id and fix', () => { + const { io } = run(['--rulebook', 'hexagonal', '--files', 'calibration/golden/hex/no-circular-import/bad/**', '--format', 'text']); + const text = io.out.join(''); + expect(text).toMatch(/calibration\/golden\/hex\/no-circular-import\/bad\/\S+:\d+ FAIL hex\/no-circular-import/); + expect(text).toContain('fix:'); + expect(text).toMatch(/\d+ FAIL/); + }); + + it('lists the rules applied per file with --explain', () => { + const { report } = runJson(['--rulebook', 'hexagonal', '--files', 'examples/**/domain/entities/order.entity.ts', '--explain']); + expect(report.explain?.['examples/order-bounded-context/domain/entities/order.entity.ts']).toContain('hex/domain-no-nest-decorators'); + expect(report.explain?.['examples/order-bounded-context/domain/entities/order.entity.ts']).toContain('hex/entity-unique-id'); + }); + + it('resolves the project rulebook from flag, env or .claude/rulebook.yaml and composes extends', () => { + const dir = mkdtempSync(join(tmpdir(), 'hex-project-')); + mkdirSync(join(dir, '.claude')); + mkdirSync(join(dir, 'src', 'orders', 'domain'), { recursive: true }); + writeFileSync(join(dir, 'src', 'orders', 'domain', 'order.service.ts'), "import { Injectable } from '@nestjs/common';\n@Injectable()\nexport class OrderService {}\n"); + const hexText = readFileSync(join(PLUGIN_ROOT, 'rulebooks', 'hexagonal.rulebook.yaml'), 'utf8'); + const projectYaml = [ + '$schema: nestjs-hexagonal/rulebook@1', + 'id: acme', + 'version: 0.1.0', + 'extends:', + ` - { id: hexagonal, version: 1.2.0, sha256: ${sha256Of(hexText)} }`, + 'model: { provider: typesafe, pin: jev-1.13.0 }', + 'rules: []', + 'overrides:', + " - { id: hex/domain-no-nest-decorators, severity: WARN }", + '', + ].join('\n'); + writeFileSync(join(dir, '.claude', 'rulebook.yaml'), projectYaml); + + const viaDefault = runJson(['--files', 'src/**/*.ts'], { CLAUDE_PROJECT_DIR: dir }, dir); + expect(viaDefault.report.rulebook.id).toBe('acme'); + expect(viaDefault.report.uncalibrated).toBe(false); + expect(viaDefault.report.findings).toHaveLength(1); + expect(viaDefault.report.findings[0]).toMatchObject({ ruleId: 'hex/domain-no-nest-decorators', severity: 'WARN', line: 1 }); + + writeFileSync(join(dir, 'other.yaml'), projectYaml.replace('id: acme', 'id: other')); + const viaEnv = runJson(['--files', 'src/**/*.ts'], { NESTJS_HEXAGONAL_RULEBOOK: 'other.yaml' }, dir); + expect(viaEnv.report.rulebook.id).toBe('other'); + const viaFlag = runJson(['--project-rulebook', 'other.yaml', '--files', 'src/**/*.ts'], {}, dir); + expect(viaFlag.report.rulebook.id).toBe('other'); + }); + + it('marks a stale extends stamp as uncalibrated with a warning and still runs', () => { + const dir = mkdtempSync(join(tmpdir(), 'hex-stale-')); + writeFileSync( + join(dir, 'rulebook.yaml'), + [ + '$schema: nestjs-hexagonal/rulebook@1', + 'id: stale', + 'version: 0.1.0', + `extends: [{ id: hexagonal, version: 1.0.0, sha256: '${'0'.repeat(64)}' }]`, + 'model: { provider: typesafe, pin: jev-1.13.0 }', + '', + ].join('\n'), + ); + const { report, code } = runJson(['--project-rulebook', 'rulebook.yaml', '--files', 'nothing/**'], {}, dir); + expect(code).toBe(0); + expect(report.uncalibrated).toBe(true); + expect(report.warnings[0]).toMatch(/^rulebook-mismatch/); + }); + + it('fails with exit 2 and a message when no rulebook can be found or a flag is unknown', () => { + const dir = mkdtempSync(join(tmpdir(), 'hex-empty-')); + const missing = run(['--files', 'src/**'], {}, dir); + expect(missing.code).toBe(2); + expect(missing.io.err.join('')).toContain('rulebook'); + const unknown = run(['--rulebook', 'hexagonal', '--files', 'x', '--bogus']); + expect(unknown.code).toBe(2); + const unknownId = run(['--rulebook', 'does-not-exist', '--files', 'x']); + expect(unknownId.code).toBe(2); + }); + + it('uses git diff --name-only for --diff', () => { + const dir = mkdtempSync(join(tmpdir(), 'hex-git-')); + const git = (...args: string[]): string => execFileSync('git', args, { cwd: dir, encoding: 'utf8', env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } }); + git('init', '-q', '-b', 'main'); + mkdirSync(join(dir, 'bc', 'domain'), { recursive: true }); + writeFileSync(join(dir, 'bc', 'domain', 'clean.ts'), 'export const clean = 1;\n'); + git('add', '.'); + git('commit', '-q', '-m', 'base'); + writeFileSync(join(dir, 'bc', 'domain', 'dirty.ts'), "import { Injectable } from '@nestjs/common';\n"); + git('add', '.'); + git('commit', '-q', '-m', 'dirty'); + const { report } = runJson(['--rulebook', 'hexagonal', '--diff', 'HEAD~1'], {}, dir); + expect(report.findings.map((finding) => finding.path)).toEqual(['bc/domain/dirty.ts']); + }); + + it('treats --hook as a no-op in this version', () => { + const { code, io } = run(['--hook', 'pre-tool-use']); + expect(code).toBe(0); + expect(io.out.join('')).toBe(''); + }); + + it('project.example stamps match the shipped base rulebooks', () => { + const { report } = runJson(['--project-rulebook', 'rulebooks/project.example.rulebook.yaml', '--files', 'examples/**/*.ts']); + expect(report.uncalibrated).toBe(false); + expect(report.warnings).toEqual([]); + }); +}); + +describe('node fallback', () => { + it('runs check.ts under node --experimental-strip-types', () => { + const output = execFileSync( + 'node', + ['--experimental-strip-types', '--no-warnings', join(PLUGIN_ROOT, 'scripts', 'check.ts'), '--rulebook', 'hexagonal', '--files', 'calibration/golden/hex/no-circular-import/bad/**', '--format', 'json'], + { cwd: PLUGIN_ROOT, encoding: 'utf8' }, + ); + const parsed: unknown = JSON.parse(output); + expect(isJsonReport(parsed) && parsed.findings.length > 0).toBe(true); + }); +}); diff --git a/scripts/__tests__/compose.spec.ts b/scripts/__tests__/compose.spec.ts new file mode 100644 index 0000000..0e1aa86 --- /dev/null +++ b/scripts/__tests__/compose.spec.ts @@ -0,0 +1,206 @@ +import './helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { stringify } from 'yaml'; +import { composeRulebook, loadComposedRulebook, RulebookCompositionError, type BaseResolver } from '../lib/compose.ts'; +import { parseRulebook, type Rulebook } from '../lib/rulebook.schema.ts'; + +function sha256(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +function rule(id: string, overrides: Record = {}) { + return { + id, + title: id, + layer: 'domain', + scope: { include: ['**/domain/**'], exclude: ['**/__tests__/**'] }, + class: 'static', + severity: 'FAIL', + rationale: 'r', + fix: 'f', + check: { kind: 'regex', pattern: 'x' }, + ...overrides, + }; +} + +function book(id: string, rules: unknown[], extra: Record = {}): Rulebook { + const parsed = parseRulebook({ + $schema: 'nestjs-hexagonal/rulebook@1', + id, + version: '1.0.0', + model: { provider: 'typesafe', pin: 'jev-1.13.0' }, + rules, + ...extra, + }); + if (!parsed.ok) { + throw new Error(parsed.error); + } + return parsed.rulebook; +} + +const baseText = stringify({ id: 'base' }); +const base = book('base', [rule('hex/a'), rule('hex/b', { severity: 'WARN' })]); + +function resolver(entries: Record): BaseResolver { + return (id) => { + const entry = entries[id]; + if (!entry) { + return null; + } + return { rulebook: entry.rulebook, sha256: sha256(entry.text), path: `${id}.rulebook.yaml` }; + }; +} + +describe('composeRulebook', () => { + it('returns base rules plus project rules when the stamp matches', () => { + const project = book('proj', [rule('proj/c')], { + extends: [{ id: 'base', version: '1.0.0', sha256: sha256(baseText) }], + }); + const composed = composeRulebook(project, resolver({ base: { rulebook: base, text: baseText } })); + expect(composed.rules.map((r) => r.id)).toEqual(['hex/a', 'hex/b', 'proj/c']); + expect(composed.uncalibrated).toBe(false); + expect(composed.warnings).toEqual([]); + expect(composed.sources['hex/a']).toBe('base'); + expect(composed.sources['proj/c']).toBe('proj'); + }); + + it('marks the composition uncalibrated on sha mismatch without throwing', () => { + const project = book('proj', [], { extends: [{ id: 'base', version: '0.9.0', sha256: 'a'.repeat(64) }] }); + const composed = composeRulebook(project, resolver({ base: { rulebook: base, text: baseText } })); + expect(composed.uncalibrated).toBe(true); + expect(composed.warnings).toHaveLength(1); + expect(composed.warnings[0]).toMatch(/^rulebook-mismatch/); + expect(composed.warnings[0]).toContain('0.9.0@aaaaaaaa'); + expect(composed.warnings[0]).toContain(`1.0.0@${sha256(baseText).slice(0, 8)}`); + expect(composed.rules).toHaveLength(2); + }); + + it('throws on a rule id collision with a base', () => { + const project = book('proj', [rule('hex/a')], { + extends: [{ id: 'base', version: '1.0.0', sha256: sha256(baseText) }], + }); + expect(() => composeRulebook(project, resolver({ base: { rulebook: base, text: baseText } }))).toThrow( + RulebookCompositionError, + ); + }); + + it('throws when a base cannot be resolved or extends cycles', () => { + const project = book('proj', [], { extends: [{ id: 'ghost', version: '1', sha256: 'a'.repeat(64) }] }); + expect(() => composeRulebook(project, resolver({}))).toThrow(/ghost/); + + const loopText = 'loop'; + const loop = book('loop', [], { extends: [{ id: 'loop', version: '1', sha256: sha256(loopText) }] }); + expect(() => composeRulebook(loop, resolver({ loop: { rulebook: loop, text: loopText } }))).toThrow(/cycle/); + }); + + it('resolves extends recursively', () => { + const midText = 'mid'; + const mid = book('mid', [rule('mid/m')], { extends: [{ id: 'base', version: '1.0.0', sha256: sha256(baseText) }] }); + const project = book('proj', [], { extends: [{ id: 'mid', version: '1.0.0', sha256: sha256(midText) }] }); + const composed = composeRulebook( + project, + resolver({ base: { rulebook: base, text: baseText }, mid: { rulebook: mid, text: midText } }), + ); + expect(composed.rules.map((r) => r.id)).toEqual(['hex/a', 'hex/b', 'mid/m']); + }); + + it('applies overrides: disabled, severity, scope merge, thresholds field-merge', () => { + const semanticBase = book('sem', [ + rule('hex/s', { + class: 'semantic', + check: undefined, + question: { type: 'noul', instructions: 'q' }, + state: { slice: 'file' }, + thresholds: { deny: 0.9, ask: 0.75, advise: 0.55, uncertain: { lo: 0.35, hi: 0.65 } }, + }), + rule('hex/a'), + rule('hex/b'), + ]); + const semText = 'sem'; + const project = book('proj', [], { + extends: [{ id: 'sem', version: '1.0.0', sha256: sha256(semText) }], + overrides: [ + { id: 'hex/a', disabled: true }, + { id: 'hex/b', severity: 'WARN', scope: { include: ['src/**'], exclude: ['legacy/**'] } }, + { id: 'hex/s', thresholds: { uncertain: { hi: 0.7 }, deny: 0.95 } }, + ], + }); + const composed = composeRulebook(project, resolver({ sem: { rulebook: semanticBase, text: semText } })); + const ids = composed.rules.map((r) => r.id); + expect(ids).not.toContain('hex/a'); + const b = composed.rules.find((r) => r.id === 'hex/b'); + expect(b?.severity).toBe('WARN'); + expect(b?.scope).toEqual({ include: ['src/**'], exclude: ['**/__tests__/**', 'legacy/**'] }); + const s = composed.rules.find((r) => r.id === 'hex/s'); + expect(s?.thresholds).toEqual({ deny: 0.95, ask: 0.75, advise: 0.55, uncertain: { lo: 0.35, hi: 0.7 } }); + }); + + it('rejects an override for an unknown rule id or a mismatched threshold shape', () => { + const project = book('proj', [], { + extends: [{ id: 'base', version: '1.0.0', sha256: sha256(baseText) }], + overrides: [{ id: 'hex/zzz', disabled: true }], + }); + expect(() => composeRulebook(project, resolver({ base: { rulebook: base, text: baseText } }))).toThrow(/hex\/zzz/); + + const semText = 'sem'; + const semanticBase = book('sem', [ + rule('hex/s', { + class: 'semantic', + check: undefined, + question: { type: 'noul', instructions: 'q' }, + state: { slice: 'file' }, + thresholds: { advise: 0.55, uncertain: { lo: 0.35, hi: 0.65 } }, + }), + ]); + const bad = book('proj', [], { + extends: [{ id: 'sem', version: '1.0.0', sha256: sha256(semText) }], + overrides: [{ id: 'hex/s', thresholds: { minConfidence: 0.6 } }], + }); + expect(() => composeRulebook(bad, resolver({ sem: { rulebook: semanticBase, text: semText } }))).toThrow( + /minConfidence/, + ); + }); +}); + +describe('loadComposedRulebook', () => { + it('loads YAML from disk and resolves extends against a rulebooks directory', () => { + const dir = mkdtempSync(join(tmpdir(), 'rulebook-')); + const rulebooksDir = join(dir, 'rulebooks'); + mkdirSync(rulebooksDir); + const baseYaml = stringify({ + $schema: 'nestjs-hexagonal/rulebook@1', + id: 'base', + version: '1.0.0', + model: { provider: 'typesafe', pin: 'jev-1.13.0' }, + rules: [rule('hex/a')], + }); + writeFileSync(join(rulebooksDir, 'base.rulebook.yaml'), baseYaml); + const projectPath = join(dir, 'rulebook.yaml'); + writeFileSync( + projectPath, + stringify({ + $schema: 'nestjs-hexagonal/rulebook@1', + id: 'proj', + version: '0.1.0', + extends: [{ id: 'base', version: '1.0.0', sha256: sha256(baseYaml) }], + model: { provider: 'typesafe', pin: 'jev-1.13.0' }, + rules: [rule('proj/x')], + }), + ); + const composed = loadComposedRulebook(projectPath, rulebooksDir); + expect(composed.rules.map((r) => r.id)).toEqual(['hex/a', 'proj/x']); + expect(composed.uncalibrated).toBe(false); + expect(composed.rulebook.id).toBe('proj'); + }); + + it('reports schema errors with the file path', () => { + const dir = mkdtempSync(join(tmpdir(), 'rulebook-')); + const path = join(dir, 'broken.yaml'); + writeFileSync(path, stringify({ id: 'x' })); + expect(() => loadComposedRulebook(path, dir)).toThrow(/broken\.yaml/); + }); +}); diff --git a/scripts/__tests__/executors.spec.ts b/scripts/__tests__/executors.spec.ts new file mode 100644 index 0000000..881bc27 --- /dev/null +++ b/scripts/__tests__/executors.spec.ts @@ -0,0 +1,130 @@ +import './helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { RuleSchema, type Rule } from '../lib/rulebook.schema.ts'; +import { registerBuiltinExecutors } from '../lib/executors/index.ts'; +import { hasExecutor, runStaticRules, type SourceFile } from '../lib/static-engine.ts'; + +registerBuiltinExecutors(); + +function externalRule(id: string, executorId: string, include: string[]): Rule { + return RuleSchema.parse({ + id, + title: id, + layer: 'application', + scope: { include, exclude: ['**/__tests__/**'] }, + class: 'static', + severity: 'WARN', + rationale: 'r', + fix: 'f', + check: { kind: 'external', executorId }, + }); +} + +function file(path: string, content: string): SourceFile { + return { path, content }; +} + +const patternA = "export const CREATE_X_USE_CASE_TOKEN = Symbol('CreateXUseCase');\nexport namespace CreateXUseCase {\n export class UseCase {\n async execute(input: Input): Promise {}\n }\n}"; +const patternB = "@CommandHandler(CreateXCommand)\nexport class CreateXHandler {\n constructor(private readonly publisher: EventPublisher) {}\n async execute(command: CreateXCommand): Promise { entity.commit(); }\n}"; +const patternC = "@CommandHandler(CreateYCommand)\nexport class CreateYHandler {\n private readonly useCase = new CreateYUseCase.UseCase(this.repo);\n async execute(command: CreateYCommand): Promise {}\n}"; +const queryHandler = "@QueryHandler(GetXQuery)\nexport class GetXHandler {\n async execute(query: GetXQuery): Promise { return this.repo.findById(query.id); }\n}"; + +describe('hex/pattern-consistent executor', () => { + const rule = externalRule('hex/pattern-consistent', 'hex/pattern-consistent', ['**/application/**/*.ts']); + + it('is registered', () => { + expect(hasExecutor('hex/pattern-consistent')).toBe(true); + }); + + it('flags a bounded context mixing pattern A and pattern B', () => { + const result = runStaticRules([rule], [ + file('src/orders/application/usecases/create-x.usecase.ts', patternA), + file('src/orders/application/commands/create-x.handler.ts', patternB), + file('src/orders/application/queries/get-x.handler.ts', queryHandler), + ]); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ ruleId: 'hex/pattern-consistent', path: 'src/orders/application', severity: 'WARN' }); + expect(result.findings[0]?.evidence).toContain('A'); + expect(result.findings[0]?.evidence).toContain('B'); + }); + + it('accepts a bounded context that uses one pattern and query handlers', () => { + const result = runStaticRules([rule], [ + file('src/orders/application/commands/create-x.handler.ts', patternB), + file('src/orders/application/commands/cancel-x.handler.ts', patternB), + file('src/orders/application/queries/get-x.handler.ts', queryHandler), + file('src/orders/application/ports/mail.port.ts', "export const MAIL_PORT = Symbol('MailPort');"), + ]); + expect(result.findings).toHaveLength(0); + }); + + it('keeps bounded contexts independent and classifies pattern C', () => { + const result = runStaticRules([rule], [ + file('src/orders/application/commands/create-x.handler.ts', patternB), + file('src/billing/application/commands/create-y.handler.ts', patternC), + ]); + expect(result.findings).toHaveLength(0); + const mixed = runStaticRules([rule], [ + file('src/billing/application/commands/create-y.handler.ts', patternC), + file('src/billing/application/commands/create-x.handler.ts', patternB), + ]); + expect(mixed.findings).toHaveLength(1); + expect(mixed.findings[0]?.evidence).toContain('C'); + }); +}); + +describe('hex/no-overengineering-static executor', () => { + const rule = externalRule('hex/no-overengineering-static', 'hex/no-overengineering-static', ['**/application/**/*.ts', '**/domain/**/*.ts']); + const port = "export interface MailPort { send(): Promise; }\nexport const MAIL_PORT = Symbol('MailPort');"; + + it('is registered', () => { + expect(hasExecutor('hex/no-overengineering-static')).toBe(true); + }); + + it('flags a port token with zero injection consumers', () => { + const result = runStaticRules([rule], [ + file('src/x/application/ports/mail.port.ts', port), + file('src/x/infrastructure/x.module.ts', "import { MAIL_PORT } from '../application/ports/mail.port';\nproviders: [{ provide: MAIL_PORT, useClass: MailAdapter }]"), + ]); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ path: 'src/x/application/ports/mail.port.ts', line: 2 }); + expect(result.findings[0]?.evidence).toContain('MAIL_PORT'); + }); + + it('accepts a port token injected somewhere', () => { + const result = runStaticRules([rule], [ + file('src/x/application/ports/mail.port.ts', port), + file('src/x/application/commands/send.handler.ts', 'constructor(@Inject(MAIL_PORT) private readonly mail: MailPort) {}'), + ]); + expect(result.findings).toHaveLength(0); + }); + + it('flags an exported helper function referenced by exactly one other file', () => { + const helper = 'export function normalizeName(name: string): string {\n return name.trim();\n}'; + const single = runStaticRules([rule], [ + file('src/x/application/helpers/normalize.ts', helper), + file('src/x/application/commands/a.handler.ts', 'normalizeName(x);'), + ]); + expect(single.findings).toHaveLength(1); + expect(single.findings[0]?.evidence).toContain('normalizeName'); + + const multiple = runStaticRules([rule], [ + file('src/x/application/helpers/normalize.ts', helper), + file('src/x/application/commands/a.handler.ts', 'normalizeName(x);'), + file('src/x/application/commands/b.handler.ts', 'normalizeName(y);'), + ]); + expect(multiple.findings).toHaveLength(0); + + const unused = runStaticRules([rule], [file('src/x/application/helpers/normalize.ts', helper)]); + expect(unused.findings).toHaveLength(0); + }); + + it('ignores data builders and test helpers', () => { + const builder = 'export function OrderDataBuilder(overrides = {}) {\n return {};\n}'; + const result = runStaticRules([rule], [ + file('src/x/domain/testing/helpers/order.data-builder.ts', builder), + file('src/x/domain/entities/__tests__/order.entity.spec.ts', 'OrderDataBuilder();'), + ]); + expect(result.findings).toHaveLength(0); + }); +}); diff --git a/scripts/__tests__/helpers/no-network.ts b/scripts/__tests__/helpers/no-network.ts new file mode 100644 index 0000000..d9a29c6 --- /dev/null +++ b/scripts/__tests__/helpers/no-network.ts @@ -0,0 +1,11 @@ +const forbiddenFetch = (): never => { + throw new Error('network access is forbidden in unit tests'); +}; + +Object.defineProperty(globalThis, 'fetch', { value: forbiddenFetch, writable: true, configurable: true }); + +export function assertNetworkForbidden(): void { + if (!Object.is(globalThis.fetch, forbiddenFetch)) { + throw new Error('fetch stub was replaced'); + } +} diff --git a/scripts/__tests__/rulebook.schema.spec.ts b/scripts/__tests__/rulebook.schema.spec.ts new file mode 100644 index 0000000..0c31cb2 --- /dev/null +++ b/scripts/__tests__/rulebook.schema.spec.ts @@ -0,0 +1,201 @@ +import './helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { RuleSchema, RulebookSchema, parseRulebook } from '../lib/rulebook.schema.ts'; + +const staticRule = { + id: 'hex/domain-no-nest-decorators', + title: 'No NestJS decorators in domain', + layer: 'domain', + scope: { include: ['**/domain/**/*.ts'], exclude: ['**/__tests__/**'] }, + class: 'static', + severity: 'FAIL', + rationale: 'Domain must not depend on the framework.', + fix: 'Remove the decorator.', + source: 'review-subdomain D1', + check: { kind: 'forbidden-import', modules: ['@nestjs/*'], allow: ['@nestjs/cqrs'] }, + tags: ['purity'], +}; + +const noulRule = { + id: 'hex/handler-no-business-rules', + title: 'Handlers hold no business rules', + layer: 'application', + scope: { include: ['**/application/**/*.handler.ts'] }, + class: 'semantic', + severity: 'FAIL', + rationale: 'Business rules belong to entities.', + fix: 'Move the rule into the aggregate.', + source: 'architecture-reviewer god handler', + question: { type: 'noul', instructions: 'The handler contains a business rule.' }, + state: { slice: 'file', maxTokens: 4000, preamble: 'A CQRS handler.' }, + thresholds: { deny: 0.9, ask: 0.75, advise: 0.55, uncertain: { lo: 0.35, hi: 0.65 } }, +}; + +const choiceRule = { + ...noulRule, + id: 'hex/no-overengineering', + severity: 'WARN', + question: { + type: 'choice', + instructions: 'Which over-engineering pattern, if any, appears?', + options: [ + { id: 'trivial-use-case', criteria: 'Use case wrapping a findById.' }, + { id: 'none', criteria: 'No over-engineering.' }, + { id: 'other', criteria: 'Something else.' }, + ], + violatingOptions: ['trivial-use-case'], + }, + thresholds: { advise: 0.55, minConfidence: 0.6 }, +}; + +const rulebook = { + $schema: 'nestjs-hexagonal/rulebook@1', + id: 'hexagonal', + version: '1.2.0', + extends: [], + model: { provider: 'typesafe', pin: 'jev-1.13.0' }, + rules: [staticRule, noulRule, choiceRule], + overrides: [], +}; + +describe('RuleSchema', () => { + it('accepts a static rule with a check', () => { + expect(RuleSchema.safeParse(staticRule).success).toBe(true); + }); + + it('rejects a static rule without a check', () => { + const { check: _check, ...withoutCheck } = staticRule; + expect(RuleSchema.safeParse(withoutCheck).success).toBe(false); + }); + + it('rejects an id outside /', () => { + expect(RuleSchema.safeParse({ ...staticRule, id: 'NoSlash' }).success).toBe(false); + expect(RuleSchema.safeParse({ ...staticRule, id: 'hex/Upper' }).success).toBe(false); + }); + + it('accepts semantic noul and choice rules', () => { + expect(RuleSchema.safeParse(noulRule).success).toBe(true); + expect(RuleSchema.safeParse(choiceRule).success).toBe(true); + }); + + it('rejects a semantic rule without question or state', () => { + const { state: _state, ...withoutState } = noulRule; + expect(RuleSchema.safeParse(withoutState).success).toBe(false); + const { question: _question, ...withoutQuestion } = noulRule; + expect(RuleSchema.safeParse(withoutQuestion).success).toBe(false); + }); + + it('rejects minConfidence on a noul rule', () => { + const bad = { ...noulRule, thresholds: { advise: 0.55, minConfidence: 0.6 } }; + expect(RuleSchema.safeParse(bad).success).toBe(false); + }); + + it('rejects uncertain.lo/hi on a choice rule', () => { + const bad = { ...choiceRule, thresholds: { advise: 0.55, uncertain: { lo: 0.3, hi: 0.6 } } }; + expect(RuleSchema.safeParse(bad).success).toBe(false); + }); + + it('requires the other option on choice questions', () => { + const bad = { + ...choiceRule, + question: { + ...choiceRule.question, + options: choiceRule.question.options.filter((option) => option.id !== 'other'), + }, + }; + expect(RuleSchema.safeParse(bad).success).toBe(false); + }); + + it('rejects violatingOptions that are not declared options', () => { + const bad = { ...choiceRule, question: { ...choiceRule.question, violatingOptions: ['ghost'] } }; + expect(RuleSchema.safeParse(bad).success).toBe(false); + }); + + it('accepts a score question with 2..10 levels and rejects 1 level', () => { + const score = { + ...choiceRule, + question: { + type: 'score', + instructions: 'How thin is the controller?', + levels: [ + { id: 'thin', criteria: 'Only delegates.' }, + { id: 'fat', criteria: 'Contains rules.' }, + ], + violatingLevels: ['fat'], + }, + }; + expect(RuleSchema.safeParse(score).success).toBe(true); + const oneLevel = { ...score, question: { ...score.question, levels: [score.question.levels[0]], violatingLevels: [] } }; + expect(RuleSchema.safeParse(oneLevel).success).toBe(false); + }); + + it('caps state.maxTokens at 8000 and preamble at 600 chars', () => { + expect(RuleSchema.safeParse({ ...noulRule, state: { ...noulRule.state, maxTokens: 8001 } }).success).toBe(false); + expect(RuleSchema.safeParse({ ...noulRule, state: { ...noulRule.state, preamble: 'x'.repeat(601) } }).success).toBe(false); + }); + + it('requires runtime on runtime rules', () => { + const runtime = { ...staticRule, id: 'hex/tests-coverage', class: 'runtime', check: undefined }; + expect(RuleSchema.safeParse(runtime).success).toBe(false); + expect(RuleSchema.safeParse({ ...runtime, runtime: { runner: 'package-test', gate: 'spec per entity' } }).success).toBe(true); + }); + + it('validates each check kind', () => { + const kinds = [ + { kind: 'regex', pattern: 'foo', flags: 'gi' }, + { kind: 'regex', pattern: 'foo', mustMatch: true }, + { kind: 'required-import', modules: ['**/*data-builder*'], whenPattern: 'Entity\\.create\\(' }, + { kind: 'line-count', selector: 'method', name: 'execute', max: 20 }, + { kind: 'line-count', selector: 'file', max: 300 }, + { kind: 'external', executorId: 'hex/pattern-consistent' }, + ]; + for (const check of kinds) { + expect(RuleSchema.safeParse({ ...staticRule, check }).success).toBe(true); + } + expect(RuleSchema.safeParse({ ...staticRule, check: { kind: 'regex', pattern: '(', flags: 'g' } }).success).toBe(false); + expect(RuleSchema.safeParse({ ...staticRule, check: { kind: 'regex', pattern: 'x', flags: 'q' } }).success).toBe(false); + expect(RuleSchema.safeParse({ ...staticRule, check: { kind: 'nope' } }).success).toBe(false); + }); + + it('defaults tags and scope.exclude', () => { + const parsed = RuleSchema.parse(staticRule); + expect(parsed.tags).toEqual(['purity']); + const { tags: _tags, ...noTags } = staticRule; + expect(RuleSchema.parse({ ...noTags, scope: { include: ['**'] } })).toMatchObject({ tags: [], scope: { exclude: [] } }); + }); +}); + +describe('RulebookSchema', () => { + it('accepts a valid rulebook', () => { + expect(RulebookSchema.safeParse(rulebook).success).toBe(true); + }); + + it('rejects a wrong $schema or provider', () => { + expect(RulebookSchema.safeParse({ ...rulebook, $schema: 'other' }).success).toBe(false); + expect(RulebookSchema.safeParse({ ...rulebook, model: { provider: 'openai', pin: 'x' } }).success).toBe(false); + }); + + it('requires sha256 stamps on extends', () => { + expect(RulebookSchema.safeParse({ ...rulebook, extends: [{ id: 'hexagonal', version: '1.2.0' }] }).success).toBe(false); + expect( + RulebookSchema.safeParse({ ...rulebook, extends: [{ id: 'hexagonal', version: '1.2.0', sha256: 'a'.repeat(64) }] }).success, + ).toBe(true); + }); + + it('rejects duplicate rule ids inside one rulebook', () => { + expect(RulebookSchema.safeParse({ ...rulebook, rules: [staticRule, staticRule] }).success).toBe(false); + }); + + it('accepts overrides with partial thresholds', () => { + const overrides = [{ id: 'hex/handler-no-business-rules', severity: 'WARN', thresholds: { uncertain: { hi: 0.7 } } }]; + expect(RulebookSchema.safeParse({ ...rulebook, overrides }).success).toBe(true); + }); + + it('parseRulebook returns a readable error message', () => { + const result = parseRulebook({ ...rulebook, rules: [{ id: 'x' }] }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('rules'); + } + }); +}); diff --git a/scripts/__tests__/run-sh.spec.ts b/scripts/__tests__/run-sh.spec.ts new file mode 100644 index 0000000..46223de --- /dev/null +++ b/scripts/__tests__/run-sh.spec.ts @@ -0,0 +1,185 @@ +import './helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { cpSync, mkdirSync, mkdtempSync, symlinkSync, writeFileSync, chmodSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +const PLUGIN_ROOT = resolve(import.meta.dir, '../..'); +const RUN_SH = join(PLUGIN_ROOT, 'scripts', 'run.sh'); + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +function runSh(script: string, args: string[], env: Record, stdin = ''): RunResult { + const cleanEnv: Record = { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '' }; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + cleanEnv[key] = value; + } + } + const result = spawnSync('sh', [script, ...args], { env: cleanEnv, input: stdin, encoding: 'utf8', cwd: PLUGIN_ROOT }); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +function makeProject(withRulebook: boolean): string { + const dir = mkdtempSync(join(tmpdir(), 'hex-run-')); + if (withRulebook) { + mkdirSync(join(dir, '.claude')); + writeFileSync(join(dir, '.claude', 'rulebook.yaml'), '$schema: nestjs-hexagonal/rulebook@1\nid: p\nversion: 0.1.0\nmodel: { provider: typesafe, pin: jev-1.13.0 }\n'); + } + return dir; +} + +function hookInput(filePath: string): string { + return JSON.stringify({ tool_name: 'Write', tool_input: { file_path: filePath, content: 'x' } }); +} + +describe('run.sh hook gate', () => { + it('exits 0 with empty output when the project has no rulebook', () => { + const dir = makeProject(false); + const result = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir }, hookInput(join(dir, 'a.ts'))); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + }); + + it('exits 0 with empty output when NESTJS_HEXAGONAL_RULEBOOK points to a missing file', () => { + const dir = makeProject(false); + const result = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir, NESTJS_HEXAGONAL_RULEBOOK: join(dir, 'nope.yaml') }, hookInput(join(dir, 'a.ts'))); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + }); + + it('exits 0 with empty output when NESTJS_HEXAGONAL_DISABLE=1', () => { + const dir = makeProject(true); + const result = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir, NESTJS_HEXAGONAL_DISABLE: '1' }, hookInput(join(dir, 'a.ts'))); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + }); + + it('exits 0 with empty output when file_path resolves outside the project', () => { + const dir = makeProject(true); + const outside = mkdtempSync(join(tmpdir(), 'hex-outside-')); + const result = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir }, hookInput(join(outside, 'a.ts'))); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + + const traversal = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir }, hookInput(join(dir, 'src', '..', '..', 'escape.ts'))); + expect(traversal.status).toBe(0); + expect(traversal.stdout).toBe(''); + }); + + it('reaches check.ts for a file inside the project, even when it does not exist yet', () => { + const dir = makeProject(true); + const result = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir }, hookInput(join(dir, 'src', 'new', 'file.ts'))); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('not implemented in this version'); + }); + + it('prefers the project node_modules binary when it is not itself', () => { + const dir = makeProject(true); + const binDir = join(dir, 'node_modules', '.bin'); + mkdirSync(binDir, { recursive: true }); + const fakeBin = join(binDir, 'nestjs-hexagonal-check'); + writeFileSync(fakeBin, '#!/bin/sh\necho FROM_PROJECT_BIN "$@"\n'); + chmodSync(fakeBin, 0o755); + const result = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir }, hookInput(join(dir, 'a.ts'))); + expect(result.status).toBe(0); + expect(result.stdout).toContain('FROM_PROJECT_BIN --hook pre-tool-use'); + }); + + it('does not recurse when the project binary is a link to itself', () => { + const dir = makeProject(true); + const binDir = join(dir, 'node_modules', '.bin'); + mkdirSync(binDir, { recursive: true }); + symlinkSync(RUN_SH, join(binDir, 'nestjs-hexagonal-check')); + const result = runSh(RUN_SH, ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir }, hookInput(join(dir, 'a.ts'))); + expect(result.status).toBe(0); + expect(result.stderr).toContain('not implemented in this version'); + }); + + it('does not recurse when the project binary is a relative link to an installed copy of itself', () => { + const dir = makeProject(true); + const pkgDir = join(dir, 'node_modules', 'nestjs-hexagonal'); + mkdirSync(join(pkgDir, 'scripts'), { recursive: true }); + cpSync(RUN_SH, join(pkgDir, 'scripts', 'run.sh')); + const binDir = join(dir, 'node_modules', '.bin'); + mkdirSync(binDir, { recursive: true }); + symlinkSync('../nestjs-hexagonal/scripts/run.sh', join(binDir, 'nestjs-hexagonal-check')); + const result = runSh(join(binDir, 'nestjs-hexagonal-check'), ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir }, hookInput(join(dir, 'a.ts'))); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('bun install'); + expect(result.stderr).toContain(pkgDir); + }); +}); + +describe('run.sh without dependencies', () => { + function copyPluginWithoutNodeModules(): string { + const dir = mkdtempSync(join(tmpdir(), 'hex-copy-')); + for (const entry of ['scripts', 'rulebooks', 'package.json']) { + cpSync(join(PLUGIN_ROOT, entry), join(dir, entry), { recursive: true }); + } + return dir; + } + + it('fails open in hook mode with an actionable message', () => { + const copy = copyPluginWithoutNodeModules(); + const dir = makeProject(true); + const result = runSh(join(copy, 'scripts', 'run.sh'), ['--hook', 'pre-tool-use'], { CLAUDE_PROJECT_DIR: dir }, hookInput(join(dir, 'a.ts'))); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain(`bun install`); + expect(result.stderr).toContain(copy); + }); + + it('fails with exit 1 and the same message in CLI mode', () => { + const copy = copyPluginWithoutNodeModules(); + const result = runSh(join(copy, 'scripts', 'run.sh'), ['--rulebook', 'hexagonal', '--files', 'x'], {}); + expect(result.status).toBe(1); + expect(result.stderr).toContain('bun install'); + }); +}); + +describe('run.sh CLI mode', () => { + it('bypasses the opt-in gate and forwards arguments to check.ts', () => { + const dir = makeProject(false); + const result = runSh(RUN_SH, ['--rulebook', 'hexagonal', '--files', 'calibration/golden/hex/no-circular-import/bad/**', '--format', 'json'], { CLAUDE_PROJECT_DIR: dir }); + expect(result.status).toBe(0); + const parsed: unknown = JSON.parse(result.stdout); + expect(typeof parsed === 'object' && parsed !== null && 'findings' in parsed).toBe(true); + }); + + it('falls back to node when bun is not on PATH', () => { + const shimDir = mkdtempSync(join(tmpdir(), 'hex-path-')); + const nodeBin = process.execPath.endsWith('bun') ? '' : process.execPath; + const nodePath = spawnSync('sh', ['-c', 'command -v node'], { encoding: 'utf8' }).stdout.trim(); + symlinkSync(nodeBin || nodePath, join(shimDir, 'node')); + for (const tool of ['sh', 'dirname', 'basename', 'readlink', 'sed', 'head', 'cat', 'printf']) { + const found = spawnSync('sh', ['-c', `command -v ${tool}`], { encoding: 'utf8' }).stdout.trim(); + if (found) { + try { + symlinkSync(found, join(shimDir, tool)); + } catch { + void 0; + } + } + } + const result = spawnSync('sh', [RUN_SH, '--rulebook', 'hexagonal', '--files', 'calibration/golden/hex/no-circular-import/bad/**', '--format', 'json'], { + env: { PATH: shimDir, HOME: process.env.HOME ?? '' }, + encoding: 'utf8', + cwd: PLUGIN_ROOT, + }); + expect(result.stderr).not.toContain('bun'); + expect(result.status).toBe(0); + const parsed: unknown = JSON.parse(result.stdout); + expect(typeof parsed === 'object' && parsed !== null && 'findings' in parsed).toBe(true); + }); +}); diff --git a/scripts/__tests__/scope.spec.ts b/scripts/__tests__/scope.spec.ts new file mode 100644 index 0000000..90c01c4 --- /dev/null +++ b/scripts/__tests__/scope.spec.ts @@ -0,0 +1,69 @@ +import './helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { globToRegExp, matchGlob, isInScope } from '../lib/scope.ts'; + +describe('matchGlob', () => { + it('matches literal paths', () => { + expect(matchGlob('src/a.ts', 'src/a.ts')).toBe(true); + expect(matchGlob('src/a.ts', 'src/b.ts')).toBe(false); + }); + + it('* does not cross a slash', () => { + expect(matchGlob('src/*.ts', 'src/a.ts')).toBe(true); + expect(matchGlob('src/*.ts', 'src/x/a.ts')).toBe(false); + }); + + it('? matches a single character that is not a slash', () => { + expect(matchGlob('a?.ts', 'ab.ts')).toBe(true); + expect(matchGlob('a?.ts', 'a/.ts')).toBe(false); + expect(matchGlob('a?.ts', 'abc.ts')).toBe(false); + }); + + it('** matches zero or more directories at the start, middle and end', () => { + expect(matchGlob('**/domain/**', 'domain/x.ts')).toBe(true); + expect(matchGlob('**/domain/**', 'src/bc/domain/entities/x.ts')).toBe(true); + expect(matchGlob('**/domain/**', 'src/bc/application/x.ts')).toBe(false); + expect(matchGlob('a/**/b', 'a/b')).toBe(true); + expect(matchGlob('a/**/b', 'a/x/y/b')).toBe(true); + expect(matchGlob('**/*.spec.ts', 'x.spec.ts')).toBe(true); + expect(matchGlob('**/*.spec.ts', 'a/b/x.spec.ts')).toBe(true); + }); + + it('** inside a segment matches across slashes', () => { + expect(matchGlob('**/*data-builder*', '../../testing/helpers/order.data-builder')).toBe(true); + expect(matchGlob('**/testing/helpers/**', '../../testing/helpers/order.data-builder')).toBe(true); + }); + + it('{a,b} alternation', () => { + expect(matchGlob('src/**/*.{ts,tsx}', 'src/a/b.tsx')).toBe(true); + expect(matchGlob('src/**/*.{ts,tsx}', 'src/a/b.js')).toBe(false); + expect(matchGlob('@nestjs/{common,core}', '@nestjs/core')).toBe(true); + }); + + it('matches module specifiers with a glob', () => { + expect(matchGlob('@nestjs/*', '@nestjs/common')).toBe(true); + expect(matchGlob('@nestjs/*', '@nestjs/cqrs')).toBe(true); + expect(matchGlob('@nestjs/*', 'class-validator')).toBe(false); + }); + + it('escapes regex metacharacters', () => { + expect(matchGlob('a.b', 'axb')).toBe(false); + expect(matchGlob('a+b', 'a+b')).toBe(true); + expect(globToRegExp('a.b').source).toContain('\\.'); + }); + + it('normalizes windows separators and leading ./', () => { + expect(matchGlob('src/*.ts', 'src\\a.ts')).toBe(true); + expect(matchGlob('src/*.ts', './src/a.ts')).toBe(true); + }); +}); + +describe('isInScope', () => { + const scope = { include: ['**/domain/**/*.ts'], exclude: ['**/__tests__/**'] }; + + it('requires an include match and no exclude match', () => { + expect(isInScope(scope, 'bc/domain/x.ts')).toBe(true); + expect(isInScope(scope, 'bc/domain/__tests__/x.spec.ts')).toBe(false); + expect(isInScope(scope, 'bc/application/x.ts')).toBe(false); + }); +}); diff --git a/scripts/__tests__/static-engine.spec.ts b/scripts/__tests__/static-engine.spec.ts new file mode 100644 index 0000000..3305226 --- /dev/null +++ b/scripts/__tests__/static-engine.spec.ts @@ -0,0 +1,234 @@ +import './helpers/no-network.ts'; +import { describe, expect, it } from 'bun:test'; +import { RuleSchema, type Rule } from '../lib/rulebook.schema.ts'; +import { + extractImports, + maskCommentsAndStrings, + registerExecutor, + runStaticRules, + unregisterExecutor, + type SourceFile, +} from '../lib/static-engine.ts'; + +function makeRule(overrides: Record): Rule { + return RuleSchema.parse({ + id: 'test/rule', + title: 'test', + layer: 'any', + scope: { include: ['**/*.ts'] }, + class: 'static', + severity: 'FAIL', + rationale: 'because', + fix: 'do it', + check: { kind: 'regex', pattern: 'x' }, + ...overrides, + }); +} + +function file(path: string, content: string): SourceFile { + return { path, content }; +} + +describe('maskCommentsAndStrings', () => { + it('blanks comments and string contents while preserving length and newlines', () => { + const source = "const a = 'x{'; // {\n/* } */ const b = `t${1}`;"; + const masked = maskCommentsAndStrings(source); + expect(masked.length).toBe(source.length); + expect(masked.split('\n').length).toBe(2); + expect(masked).not.toContain('{'); + expect(masked).toContain('const a ='); + }); +}); + +describe('extractImports', () => { + it('finds single-line, multi-line, type, side-effect, re-export and require specifiers', () => { + const source = [ + "import { A } from '@nestjs/common';", + 'import {', + ' B,', + "} from './b';", + "import type { C } from '../c';", + "import './side-effect';", + "export * from './re';", + "export { D } from './d';", + "const e = require('e');", + "// import { Ghost } from './ghost';", + "const text = 'from \"not-an-import\"';", + ].join('\n'); + const imports = extractImports(source); + expect(imports.map((entry) => entry.specifier)).toEqual([ + '@nestjs/common', + './b', + '../c', + './side-effect', + './re', + './d', + 'e', + ]); + expect(imports[1]?.line).toBe(2); + }); +}); + +describe('regex check', () => { + it('reports each match with its line number', () => { + const rule = makeRule({ check: { kind: 'regex', pattern: 'forwardRef\\s*\\(' } }); + const result = runStaticRules([rule], [file('a.module.ts', 'imports: [\n forwardRef(() => B),\n forwardRef(() => C),\n]')]); + expect(result.findings).toHaveLength(2); + expect(result.findings[0]).toMatchObject({ ruleId: 'test/rule', severity: 'FAIL', path: 'a.module.ts', line: 2, class: 'static', fix: 'do it' }); + expect(result.findings[0]?.evidence).toContain('forwardRef'); + expect(result.findings[1]?.line).toBe(3); + }); + + it('honours flags, mustMatch and maxMatches', () => { + const mustMatch = makeRule({ check: { kind: 'regex', pattern: 'extends Entity<', mustMatch: true } }); + expect(runStaticRules([mustMatch], [file('x.entity.ts', 'export class X {}')]).findings).toHaveLength(1); + expect(runStaticRules([mustMatch], [file('x.entity.ts', 'export class X extends Entity

{}')]).findings).toHaveLength(0); + + const insensitive = makeRule({ check: { kind: 'regex', pattern: 'todo', flags: 'i' } }); + expect(runStaticRules([insensitive], [file('a.ts', 'TODO')]).findings).toHaveLength(1); + + const capped = makeRule({ check: { kind: 'regex', pattern: 'x', maxMatches: 2 } }); + expect(runStaticRules([capped], [file('a.ts', 'x x')]).findings).toHaveLength(0); + const over = runStaticRules([capped], [file('a.ts', 'x\nx\nx')]); + expect(over.findings).toHaveLength(1); + expect(over.findings[0]?.evidence).toContain('3'); + expect(over.findings[0]?.line).toBe(3); + }); + + it('only applies when whenPattern matches', () => { + const rule = makeRule({ check: { kind: 'regex', pattern: 'this\\.repo\\.find', whenPattern: '@EventsHandler\\(' } }); + const plain = file('h.ts', 'class H { handle() { this.repo.find(); } }'); + const handler = file('h.ts', '@EventsHandler(E)\nclass H { handle() { this.repo.find(); } }'); + expect(runStaticRules([rule], [plain]).findings).toHaveLength(0); + expect(runStaticRules([rule], [handler]).findings).toHaveLength(1); + }); + + it('respects the rule scope', () => { + const rule = makeRule({ scope: { include: ['**/domain/**'], exclude: ['**/__tests__/**'] }, check: { kind: 'regex', pattern: 'x' } }); + const result = runStaticRules([rule], [file('bc/domain/a.ts', 'x'), file('bc/domain/__tests__/a.spec.ts', 'x'), file('bc/app/a.ts', 'x')]); + expect(result.findings.map((finding) => finding.path)).toEqual(['bc/domain/a.ts']); + expect(result.applied['bc/domain/a.ts']).toEqual(['test/rule']); + expect(result.applied['bc/app/a.ts']).toBeUndefined(); + }); +}); + +describe('forbidden-import check', () => { + const rule = makeRule({ check: { kind: 'forbidden-import', modules: ['@nestjs/*', '**/infrastructure/**'], allow: ['@nestjs/cqrs'] } }); + + it('flags matching specifiers unless allowed', () => { + const source = "import { Injectable } from '@nestjs/common';\nimport { IEvent } from '@nestjs/cqrs';\nimport { X } from '../infrastructure/x';\n"; + const result = runStaticRules([rule], [file('d.ts', source)]); + expect(result.findings.map((finding) => finding.line)).toEqual([1, 3]); + expect(result.findings[0]?.evidence).toContain('@nestjs/common'); + }); + + it('ignores commented imports', () => { + expect(runStaticRules([rule], [file('d.ts', "// import { X } from '@nestjs/common';")]).findings).toHaveLength(0); + }); +}); + +describe('required-import check', () => { + const rule = makeRule({ check: { kind: 'required-import', modules: ['**/*data-builder*'], whenPattern: 'Entity\\.create\\(' } }); + + it('flags files that trigger whenPattern without the import', () => { + const missing = file('a.spec.ts', 'const e = OrderEntity.create({});'); + const present = file('b.spec.ts', "import { OrderDataBuilder } from '../../testing/helpers/order.data-builder';\nconst e = OrderEntity.create(OrderDataBuilder());"); + const untriggered = file('c.spec.ts', 'expect(1).toBe(1);'); + const result = runStaticRules([rule], [missing, present, untriggered]); + expect(result.findings.map((finding) => finding.path)).toEqual(['a.spec.ts']); + expect(result.findings[0]?.line).toBeUndefined(); + }); +}); + +describe('line-count check', () => { + const handler = [ + 'export class H {', + ' constructor(private readonly repo: Repo) {}', + '', + ' async execute(cmd: Cmd): Promise {', + ' const a = 1;', + " const s = '}';", + ' if (a) {', + ' // }', + ' }', + ' }', + '', + ' private helper(): void {}', + '}', + ].join('\n'); + + it('counts the lines of a named method including braces in strings and comments', () => { + const ok = makeRule({ check: { kind: 'line-count', selector: 'method', name: 'execute', max: 7 } }); + expect(runStaticRules([ok], [file('h.ts', handler)]).findings).toHaveLength(0); + const tight = makeRule({ check: { kind: 'line-count', selector: 'method', name: 'execute', max: 6 } }); + const result = runStaticRules([tight], [file('h.ts', handler)]); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]?.line).toBe(4); + expect(result.findings[0]?.evidence).toContain('7 lines'); + }); + + it('counts a method whose return type contains braces', () => { + const rule = makeRule({ check: { kind: 'line-count', selector: 'method', name: 'execute', max: 3 } }); + const source = 'class H {\n async execute(command: C): Promise<{ id: string }> {\n a();\n b();\n c();\n }\n}'; + const result = runStaticRules([rule], [file('h.ts', source)]); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]?.line).toBe(2); + expect(result.findings[0]?.evidence).toContain('5 lines'); + }); + + it('does not treat a call as a declaration', () => { + const rule = makeRule({ check: { kind: 'line-count', selector: 'method', name: 'execute', max: 1 } }); + expect(runStaticRules([rule], [file('c.ts', 'await this.handler.execute(\n cmd,\n);')]).findings).toHaveLength(0); + }); + + it('counts functions and arrow functions and whole files', () => { + const fn = makeRule({ check: { kind: 'line-count', selector: 'function', max: 3 } }); + const source = 'export function big() {\n a();\n b();\n}\nconst small = () => {\n a();\n};\nexport const arrow = async (x: number): Promise => {\n a();\n b();\n};'; + const result = runStaticRules([fn], [file('f.ts', source)]); + expect(result.findings.map((finding) => finding.line)).toEqual([1, 8]); + + const whole = makeRule({ check: { kind: 'line-count', selector: 'file', max: 2 } }); + expect(runStaticRules([whole], [file('f.ts', 'a\nb\nc')]).findings).toHaveLength(1); + expect(runStaticRules([whole], [file('f.ts', 'a\nb')]).findings).toHaveLength(0); + }); +}); + +describe('external check', () => { + it('skips with a warning when the executor is not registered', () => { + const rule = makeRule({ check: { kind: 'external', executorId: 'test/unregistered' } }); + const result = runStaticRules([rule], [file('a.ts', 'x')]); + expect(result.findings).toHaveLength(0); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('test/unregistered'); + }); + + it('passes scoped and all files to a registered executor', () => { + const rule = makeRule({ scope: { include: ['**/application/**'] }, check: { kind: 'external', executorId: 'test/registered' } }); + const seen: { scoped: string[]; all: string[] } = { scoped: [], all: [] }; + registerExecutor('test/registered', (targetRule, scoped, all) => { + seen.scoped = scoped.map((entry) => entry.path); + seen.all = all.map((entry) => entry.path); + return [{ ruleId: targetRule.id, severity: targetRule.severity, path: 'bc/application', evidence: 'mixed', fix: targetRule.fix, class: 'static' }]; + }); + try { + const result = runStaticRules([rule], [file('bc/application/a.ts', ''), file('bc/domain/b.ts', '')]); + expect(seen).toEqual({ scoped: ['bc/application/a.ts'], all: ['bc/application/a.ts', 'bc/domain/b.ts'] }); + expect(result.findings).toHaveLength(1); + expect(result.warnings).toHaveLength(0); + } finally { + unregisterExecutor('test/registered'); + } + }); +}); + +describe('runStaticRules', () => { + it('ignores non-static rules', () => { + const semantic = makeRule({ + class: 'semantic', + check: undefined, + question: { type: 'noul', instructions: 'q' }, + state: { slice: 'file' }, + }); + expect(runStaticRules([semantic], [file('a.ts', 'x')]).findings).toHaveLength(0); + }); +}); diff --git a/scripts/check.ts b/scripts/check.ts new file mode 100644 index 0000000..e55cdb6 --- /dev/null +++ b/scripts/check.ts @@ -0,0 +1,354 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + RulebookCompositionError, + composeRulebook, + createDirectoryResolver, + readRulebookFile, + rulebookPathForId, + type ComposedRulebook, +} from './lib/compose.ts'; +import { registerBuiltinExecutors } from './lib/executors/index.ts'; +import { matchGlob, normalizePath } from './lib/scope.ts'; +import { runStaticRules, type Finding, type SourceFile } from './lib/static-engine.ts'; +import type { RuleClass } from './lib/rulebook.schema.ts'; + +export interface CliIo { + stdout: (text: string) => void; + stderr: (text: string) => void; +} + +export interface CliOptions { + cwd: string; + env: Record; + pluginRoot: string; +} + +interface ParsedArgs { + rulebook?: string; + projectRulebook?: string; + files: string[]; + diff?: string; + classes: RuleClass[]; + format: 'json' | 'text'; + strict: boolean; + explain: boolean; + hook?: string; + help: boolean; +} + +class UsageError extends Error {} + +const USAGE = `Usage: nestjs-hexagonal-check [options] + + --rulebook rulebook to run; an id resolves to /rulebooks/.rulebook.yaml + --project-rulebook project rulebook (default: $NESTJS_HEXAGONAL_RULEBOOK or $CLAUDE_PROJECT_DIR/.claude/rulebook.yaml) + --files files to check, as globs relative to the current directory + --diff check the files changed since (git diff --name-only ) + --classes comma-separated rule classes to run (default: static) + --format json|text output format (default: text) + --strict exit 1 when any FAIL finding exists + --explain list the rules applied to each file + --help show this message +`; + +const IGNORED_DIRECTORIES = new Set(['node_modules', '.git']); +const RULE_CLASSES: RuleClass[] = ['static', 'semantic', 'runtime']; + +function isRuleClass(value: string): value is RuleClass { + return RULE_CLASSES.some((entry) => entry === value); +} + +export function parseArgs(argv: string[]): ParsedArgs { + const parsed: ParsedArgs = { files: [], classes: ['static'], format: 'text', strict: false, explain: false, help: false }; + let i = 0; + const takeValue = (flag: string): string => { + const value = argv[i + 1]; + if (value === undefined || value.startsWith('--')) { + throw new UsageError(`${flag} requires a value`); + } + i += 1; + return value; + }; + + while (i < argv.length) { + const arg = argv[i]; + switch (arg) { + case '--rulebook': + parsed.rulebook = takeValue(arg); + break; + case '--project-rulebook': + parsed.projectRulebook = takeValue(arg); + break; + case '--files': + while (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) { + parsed.files.push(argv[i + 1]); + i += 1; + } + if (parsed.files.length === 0) { + throw new UsageError('--files requires at least one glob'); + } + break; + case '--diff': + parsed.diff = takeValue(arg); + break; + case '--classes': { + const classes = takeValue(arg).split(',').map((entry) => entry.trim()).filter((entry) => entry.length > 0); + for (const entry of classes) { + if (!isRuleClass(entry)) { + throw new UsageError(`unknown rule class '${entry}'`); + } + } + parsed.classes = classes.filter(isRuleClass); + break; + } + case '--format': { + const format = takeValue(arg); + if (format !== 'json' && format !== 'text') { + throw new UsageError(`unknown format '${format}'`); + } + parsed.format = format; + break; + } + case '--strict': + parsed.strict = true; + break; + case '--explain': + parsed.explain = true; + break; + case '--hook': + parsed.hook = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[++i] : ''; + break; + case '--help': + case '-h': + parsed.help = true; + break; + default: + throw new UsageError(`unknown option '${arg}'`); + } + i += 1; + } + return parsed; +} + +function resolveRootRulebook(args: ParsedArgs, options: CliOptions): string { + const rulebooksDir = join(options.pluginRoot, 'rulebooks'); + if (args.rulebook !== undefined) { + const asPath = resolve(options.cwd, args.rulebook); + if (/\.ya?ml$/.test(args.rulebook) || existsSync(asPath)) { + return asPath; + } + const byId = rulebookPathForId(rulebooksDir, args.rulebook); + if (existsSync(byId)) { + return byId; + } + throw new UsageError(`rulebook '${args.rulebook}' is neither a file nor an id under ${rulebooksDir}`); + } + + const candidates: Array<{ path: string; origin: string }> = []; + if (args.projectRulebook !== undefined) { + candidates.push({ path: resolve(options.cwd, args.projectRulebook), origin: '--project-rulebook' }); + } + const fromEnv = options.env.NESTJS_HEXAGONAL_RULEBOOK; + if (fromEnv !== undefined && fromEnv !== '') { + candidates.push({ path: resolve(options.cwd, fromEnv), origin: 'NESTJS_HEXAGONAL_RULEBOOK' }); + } + const projectDir = options.env.CLAUDE_PROJECT_DIR ?? options.cwd; + candidates.push({ path: join(projectDir, '.claude', 'rulebook.yaml'), origin: '.claude/rulebook.yaml' }); + + for (const candidate of candidates) { + if (existsSync(candidate.path)) { + return candidate.path; + } + } + throw new UsageError( + `no rulebook found: pass --rulebook , --project-rulebook , set NESTJS_HEXAGONAL_RULEBOOK or create ${join(projectDir, '.claude', 'rulebook.yaml')}`, + ); +} + +function walk(dir: string, base: string, out: string[]): void { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (IGNORED_DIRECTORIES.has(entry.name)) { + continue; + } + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full, base, out); + } else if (entry.isFile()) { + out.push(normalizePath(relative(base, full))); + } + } +} + +function expandGlobs(globs: string[], cwd: string): string[] { + const literal = globs.filter((pattern) => !/[*?{]/.test(pattern)); + const patterns = globs.filter((pattern) => /[*?{]/.test(pattern)); + const selected = new Set(); + + for (const path of literal) { + const full = resolve(cwd, path); + if (existsSync(full) && statSync(full).isFile()) { + selected.add(normalizePath(relative(cwd, full))); + } + } + + if (patterns.length > 0) { + const all: string[] = []; + walk(cwd, cwd, all); + for (const path of all) { + if (patterns.some((pattern) => matchGlob(pattern, path))) { + selected.add(path); + } + } + } + + return [...selected].sort(); +} + +function changedFiles(base: string, cwd: string): string[] { + const output = execFileSync('git', ['diff', '--name-only', '--diff-filter=ACMR', base], { cwd, encoding: 'utf8' }); + return output + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .filter((line) => existsSync(resolve(cwd, line))) + .map((line) => normalizePath(line)); +} + +function readSources(paths: string[], cwd: string): SourceFile[] { + return paths.map((path) => ({ path, content: readFileSync(resolve(cwd, path), 'utf8') })); +} + +interface Report { + rulebook: { id: string; version: string; path: string }; + uncalibrated: boolean; + warnings: string[]; + files: number; + findings: Finding[]; + skipped: { semantic: string[]; runtime: string[] }; + explain?: Record; +} + +function formatText(report: Report): string { + const lines: string[] = []; + for (const finding of report.findings) { + const location = finding.line === undefined ? finding.path : `${finding.path}:${finding.line}`; + lines.push(`${location} ${finding.severity} ${finding.ruleId}: ${finding.evidence}`); + lines.push(` fix: ${finding.fix}`); + } + if (report.explain) { + lines.push(''); + for (const [path, ruleIds] of Object.entries(report.explain)) { + lines.push(`${path}: ${ruleIds.join(', ')}`); + } + } + const fails = report.findings.filter((finding) => finding.severity === 'FAIL').length; + const warns = report.findings.length - fails; + const calibration = report.uncalibrated ? ', uncalibrated' : ''; + lines.push(''); + lines.push(`${fails} FAIL, ${warns} WARN in ${report.files} file(s) (rulebook ${report.rulebook.id} ${report.rulebook.version}${calibration})`); + return `${lines.join('\n')}\n`; +} + +function buildReport(composed: ComposedRulebook, rulebookPath: string, files: SourceFile[], args: ParsedArgs, io: CliIo): Report { + const skipped: Report['skipped'] = { semantic: [], runtime: [] }; + for (const cls of args.classes) { + if (cls === 'static') { + continue; + } + const ids = composed.rules.filter((rule) => rule.class === cls).map((rule) => rule.id); + skipped[cls] = ids; + if (ids.length > 0) { + io.stderr(`rule class '${cls}' is not implemented in this version; skipped ${ids.length} rule(s)\n`); + } + } + + const staticResult = args.classes.includes('static') + ? runStaticRules(composed.rules, files) + : { findings: [], warnings: [], applied: {} }; + + const report: Report = { + rulebook: { id: composed.rulebook.id, version: composed.rulebook.version, path: rulebookPath }, + uncalibrated: composed.uncalibrated, + warnings: [...composed.warnings, ...staticResult.warnings], + files: files.length, + findings: staticResult.findings.sort((a, b) => a.path.localeCompare(b.path) || (a.line ?? 0) - (b.line ?? 0)), + skipped, + }; + if (args.explain) { + report.explain = staticResult.applied; + } + return report; +} + +export function runCli(argv: string[], io: CliIo, options: CliOptions): number { + let args: ParsedArgs; + try { + args = parseArgs(argv); + } catch (error) { + io.stderr(`${error instanceof Error ? error.message : String(error)}\n${USAGE}`); + return 2; + } + + if (args.help) { + io.stdout(USAGE); + return 0; + } + + if (args.hook !== undefined) { + io.stderr(`hook '${args.hook}' is not implemented in this version\n`); + return 0; + } + + try { + const rulebookPath = resolveRootRulebook(args, options); + const { rulebook } = readRulebookFile(rulebookPath); + registerBuiltinExecutors(); + const composed = composeRulebook(rulebook, createDirectoryResolver(join(options.pluginRoot, 'rulebooks'))); + + if (args.files.length === 0 && args.diff === undefined) { + throw new UsageError('pass --files or --diff '); + } + const paths = args.diff !== undefined ? changedFiles(args.diff, options.cwd) : expandGlobs(args.files, options.cwd); + const files = readSources(paths, options.cwd); + + const report = buildReport(composed, rulebookPath, files, args, io); + for (const warning of report.warnings) { + io.stderr(`warning: ${warning}\n`); + } + io.stdout(args.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : formatText(report)); + + const hasFail = report.findings.some((finding) => finding.severity === 'FAIL'); + return args.strict && hasFail ? 1 : 0; + } catch (error) { + if (error instanceof UsageError) { + io.stderr(`${error.message}\n${USAGE}`); + return 2; + } + if (error instanceof RulebookCompositionError) { + io.stderr(`${error.message}\n`); + return 2; + } + throw error; + } +} + +function isMainModule(): boolean { + const entry = process.argv[1]; + if (entry === undefined) { + return false; + } + return import.meta.url === pathToFileURL(isAbsolute(entry) ? entry : resolve(entry)).href; +} + +if (isMainModule()) { + const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url))); + const code = runCli( + process.argv.slice(2), + { stdout: (text) => process.stdout.write(text), stderr: (text) => process.stderr.write(text) }, + { cwd: process.cwd(), env: process.env, pluginRoot }, + ); + process.exitCode = code; +} diff --git a/scripts/lib/compose.ts b/scripts/lib/compose.ts new file mode 100644 index 0000000..ca05153 --- /dev/null +++ b/scripts/lib/compose.ts @@ -0,0 +1,196 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse } from 'yaml'; +import { + ThresholdsSchema, + formatIssues, + parseRulebook, + validateThresholdsForQuestion, + type Override, + type Rule, + type Rulebook, + type Thresholds, + type ThresholdsOverride, +} from './rulebook.schema.ts'; + +export class RulebookCompositionError extends Error {} + +export interface ResolvedBase { + rulebook: Rulebook; + sha256: string; + path: string; +} + +export type BaseResolver = (id: string) => ResolvedBase | null; + +export interface ComposedRulebook { + rulebook: Rulebook; + rules: Rule[]; + sources: Record; + warnings: string[]; + uncalibrated: boolean; +} + +export function sha256Of(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +function collectRules( + rulebook: Rulebook, + resolve: BaseResolver, + visiting: Set, + warnings: string[], +): { rules: Rule[]; sources: Record } { + if (visiting.has(rulebook.id)) { + throw new RulebookCompositionError(`extends cycle detected at rulebook '${rulebook.id}'`); + } + visiting.add(rulebook.id); + + const rules: Rule[] = []; + const sources: Record = {}; + + for (const entry of rulebook.extends) { + const base = resolve(entry.id); + if (!base) { + throw new RulebookCompositionError(`rulebook '${rulebook.id}' extends unknown base '${entry.id}'`); + } + if (base.sha256 !== entry.sha256) { + warnings.push( + `rulebook-mismatch for '${entry.id}': expected ${entry.version}@${entry.sha256.slice(0, 8)}, found ${base.rulebook.version}@${base.sha256.slice(0, 8)} (${base.path}); decisions are uncalibrated`, + ); + } + const inherited = collectRules(base.rulebook, resolve, visiting, warnings); + for (const rule of inherited.rules) { + if (sources[rule.id] !== undefined) { + throw new RulebookCompositionError( + `rule id '${rule.id}' is defined by both '${sources[rule.id]}' and '${inherited.sources[rule.id]}'`, + ); + } + rules.push(rule); + sources[rule.id] = inherited.sources[rule.id]; + } + } + + for (const rule of rulebook.rules) { + if (sources[rule.id] !== undefined) { + throw new RulebookCompositionError( + `rule id '${rule.id}' in '${rulebook.id}' collides with the same id from '${sources[rule.id]}'`, + ); + } + rules.push(rule); + sources[rule.id] = rulebook.id; + } + + visiting.delete(rulebook.id); + return { rules, sources }; +} + +function mergeThresholds(rule: Rule, patch: ThresholdsOverride): Thresholds { + const current = rule.thresholds; + const base: Record = current ? { ...current } : {}; + if (patch.deny !== undefined) base.deny = patch.deny; + if (patch.ask !== undefined) base.ask = patch.ask; + if (patch.advise !== undefined) base.advise = patch.advise; + if (patch.minConfidence !== undefined) base.minConfidence = patch.minConfidence; + if (patch.uncertain !== undefined) { + const currentUncertain = current && 'uncertain' in current ? current.uncertain : { lo: undefined, hi: undefined }; + base.uncertain = { + lo: patch.uncertain.lo ?? currentUncertain.lo, + hi: patch.uncertain.hi ?? currentUncertain.hi, + }; + } + const mismatch = validateThresholdsForQuestion(rule.question, base); + if (mismatch) { + throw new RulebookCompositionError(`override for '${rule.id}': ${mismatch}`); + } + const parsed = ThresholdsSchema.safeParse(base); + if (!parsed.success) { + throw new RulebookCompositionError(`invalid thresholds after override: ${formatIssues(parsed.error)}`); + } + return parsed.data; +} + +function applyOverride(rule: Rule, override: Override): Rule | null { + if (override.disabled) { + return null; + } + const next: Rule = { ...rule, scope: { ...rule.scope } }; + if (override.severity) { + next.severity = override.severity; + } + if (override.scope) { + if (override.scope.include) { + next.scope.include = [...override.scope.include]; + } + if (override.scope.exclude) { + next.scope.exclude = [...new Set([...rule.scope.exclude, ...override.scope.exclude])]; + } + } + if (override.thresholds) { + next.thresholds = mergeThresholds(rule, override.thresholds); + } + return next; +} + +export function composeRulebook(rulebook: Rulebook, resolve: BaseResolver): ComposedRulebook { + const warnings: string[] = []; + const collected = collectRules(rulebook, resolve, new Set(), warnings); + const byId = new Map(collected.rules.map((rule) => [rule.id, rule])); + + for (const override of rulebook.overrides) { + const target = byId.get(override.id); + if (!target) { + throw new RulebookCompositionError(`override targets unknown rule id '${override.id}'`); + } + const next = applyOverride(target, override); + if (next === null) { + byId.delete(override.id); + } else { + byId.set(override.id, next); + } + } + + const rules = collected.rules.filter((rule) => byId.has(rule.id)).map((rule) => byId.get(rule.id) ?? rule); + const sources: Record = {}; + for (const rule of rules) { + sources[rule.id] = collected.sources[rule.id]; + } + + return { + rulebook, + rules, + sources, + warnings, + uncalibrated: warnings.some((warning) => warning.startsWith('rulebook-mismatch')), + }; +} + +export function readRulebookFile(path: string): { rulebook: Rulebook; text: string } { + const text = readFileSync(path, 'utf8'); + const parsed = parseRulebook(parse(text)); + if (!parsed.ok) { + throw new RulebookCompositionError(`invalid rulebook at ${path}:\n${parsed.error}`); + } + return { rulebook: parsed.rulebook, text }; +} + +export function rulebookPathForId(rulebooksDir: string, id: string): string { + return join(rulebooksDir, `${id}.rulebook.yaml`); +} + +export function createDirectoryResolver(rulebooksDir: string): BaseResolver { + return (id) => { + const path = rulebookPathForId(rulebooksDir, id); + if (!existsSync(path)) { + return null; + } + const { rulebook, text } = readRulebookFile(path); + return { rulebook, sha256: sha256Of(text), path }; + }; +} + +export function loadComposedRulebook(path: string, rulebooksDir: string): ComposedRulebook { + const { rulebook } = readRulebookFile(path); + return composeRulebook(rulebook, createDirectoryResolver(rulebooksDir)); +} diff --git a/scripts/lib/executors/index.ts b/scripts/lib/executors/index.ts new file mode 100644 index 0000000..644b739 --- /dev/null +++ b/scripts/lib/executors/index.ts @@ -0,0 +1,8 @@ +import { registerExecutor } from '../static-engine.ts'; +import { noOverengineeringStaticExecutor } from './no-overengineering-static.ts'; +import { patternConsistentExecutor } from './pattern-consistent.ts'; + +export function registerBuiltinExecutors(): void { + registerExecutor('hex/pattern-consistent', patternConsistentExecutor); + registerExecutor('hex/no-overengineering-static', noOverengineeringStaticExecutor); +} diff --git a/scripts/lib/executors/no-overengineering-static.ts b/scripts/lib/executors/no-overengineering-static.ts new file mode 100644 index 0000000..b2abb02 --- /dev/null +++ b/scripts/lib/executors/no-overengineering-static.ts @@ -0,0 +1,67 @@ +import { lineAt, maskCommentsAndStrings, type Executor, type Finding, type SourceFile } from '../static-engine.ts'; + +const TOKEN_EXPORT = /export\s+const\s+(\w+)\s*=\s*Symbol\s*\(/g; +const FUNCTION_EXPORT = /^[ \t]*export\s+(?:async\s+)?function\s*\*?\s*(\w+)\s*\(/gm; +const ARROW_EXPORT = /^[ \t]*export\s+const\s+(\w+)\s*(?::[^=]*)?=\s*(?:async\s*)?(?:\([^)]*\)|\w+)\s*(?::[^=]*)?=>/gm; +const TEST_OR_BUILDER = /(?:__tests__|\.spec\.|\.test\.|\/testing\/|data-builder|\.builder\.)/; + +function referencedBy(name: string, files: SourceFile[], definer: string): string[] { + const pattern = new RegExp(`\\b${name}\\b`); + return files.filter((file) => file.path !== definer && pattern.test(maskCommentsAndStrings(file.content))).map((file) => file.path); +} + +function injectedBy(name: string, files: SourceFile[], definer: string): string[] { + const pattern = new RegExp(`@Inject\\s*\\(\\s*${name}\\s*\\)`); + return files.filter((file) => file.path !== definer && pattern.test(maskCommentsAndStrings(file.content))).map((file) => file.path); +} + +export const noOverengineeringStaticExecutor: Executor = (rule, scopedFiles, allFiles) => { + const findings: Finding[] = []; + + for (const file of scopedFiles) { + if (TEST_OR_BUILDER.test(file.path)) { + continue; + } + const masked = maskCommentsAndStrings(file.content); + + if (file.path.includes('/ports/') || file.path.endsWith('.port.ts')) { + for (const match of masked.matchAll(TOKEN_EXPORT)) { + const token = match[1]; + if (token !== undefined && injectedBy(token, allFiles, file.path).length === 0) { + findings.push({ + ruleId: rule.id, + severity: rule.severity, + path: file.path, + line: lineAt(file.content, match.index), + evidence: `port token ${token} has no @Inject consumer`, + fix: rule.fix, + class: 'static', + }); + } + } + } + + for (const pattern of [FUNCTION_EXPORT, ARROW_EXPORT]) { + for (const match of masked.matchAll(pattern)) { + const name = match[1]; + if (name === undefined) { + continue; + } + const callers = referencedBy(name, allFiles, file.path).filter((path) => !TEST_OR_BUILDER.test(path)); + if (callers.length === 1) { + findings.push({ + ruleId: rule.id, + severity: rule.severity, + path: file.path, + line: lineAt(file.content, match.index), + evidence: `helper ${name} has exactly one caller (${callers[0]}); inline it`, + fix: rule.fix, + class: 'static', + }); + } + } + } + } + + return findings; +}; diff --git a/scripts/lib/executors/pattern-consistent.ts b/scripts/lib/executors/pattern-consistent.ts new file mode 100644 index 0000000..a9931da --- /dev/null +++ b/scripts/lib/executors/pattern-consistent.ts @@ -0,0 +1,69 @@ +import { maskCommentsAndStrings, type Executor, type Finding } from '../static-engine.ts'; + +type Pattern = 'A' | 'B' | 'C'; + +const HANDLER_DECORATOR = /@(?:CommandHandler|QueryHandler)\s*\(/; +const ORCHESTRATOR_INSTANCE = /\bnew\s+\w*UseCase(?:\.\w+)?\s*\(/; +const PLAIN_USE_CASE = /\bexecute\s*\(/; +const TOKEN_EXPORT = /export\s+const\s+\w+\s*=\s*Symbol\s*\(/; +const EVENT_PUBLISHER = /\bEventPublisher\b|\.commit\s*\(\)/; + +export function classifyPattern(content: string): Pattern | null { + const masked = maskCommentsAndStrings(content); + if (HANDLER_DECORATOR.test(masked)) { + if (ORCHESTRATOR_INSTANCE.test(masked)) { + return 'C'; + } + return EVENT_PUBLISHER.test(masked) ? 'B' : null; + } + if (PLAIN_USE_CASE.test(masked) && TOKEN_EXPORT.test(masked)) { + return 'A'; + } + return null; +} + +export function boundedContextOf(path: string): string | null { + const index = path.indexOf('/application/'); + if (index === -1) { + return path.startsWith('application/') ? '' : null; + } + return path.slice(0, index); +} + +export const patternConsistentExecutor: Executor = (rule, scopedFiles) => { + const byContext = new Map>(); + + for (const file of scopedFiles) { + const context = boundedContextOf(file.path); + if (context === null) { + continue; + } + const pattern = classifyPattern(file.content); + if (!pattern) { + continue; + } + const patterns = byContext.get(context) ?? new Map(); + patterns.set(pattern, [...(patterns.get(pattern) ?? []), file.path]); + byContext.set(context, patterns); + } + + const findings: Finding[] = []; + for (const [context, patterns] of byContext) { + if (patterns.size < 2) { + continue; + } + const summary = [...patterns.entries()] + .map(([pattern, files]) => `${pattern}: ${files.length} file(s), e.g. ${files[0]}`) + .join('; '); + findings.push({ + ruleId: rule.id, + severity: rule.severity, + path: context === '' ? 'application' : `${context}/application`, + evidence: `bounded context mixes application patterns (${summary})`, + fix: rule.fix, + class: 'static', + }); + } + return findings; +}; + diff --git a/scripts/lib/rulebook.schema.ts b/scripts/lib/rulebook.schema.ts new file mode 100644 index 0000000..838d575 --- /dev/null +++ b/scripts/lib/rulebook.schema.ts @@ -0,0 +1,307 @@ +import { z } from 'zod'; + +export const RULEBOOK_SCHEMA_ID = 'nestjs-hexagonal/rulebook@1'; + +const RULE_ID_PATTERN = /^[a-z0-9-]+\/[a-z0-9-]+$/; +const REGEX_FLAGS_PATTERN = /^[dgimsuvy]*$/; + +function isValidRegex(pattern: string, flags: string): boolean { + try { + new RegExp(pattern, flags); + return true; + } catch { + return false; + } +} + +const RegexCheckSchema = z + .object({ + kind: z.literal('regex'), + pattern: z.string().min(1), + flags: z.string().regex(REGEX_FLAGS_PATTERN).default(''), + mustMatch: z.boolean().default(false), + maxMatches: z.number().int().nonnegative().optional(), + whenPattern: z.string().min(1).optional(), + }) + .superRefine((check, ctx) => { + if (!isValidRegex(check.pattern, check.flags)) { + ctx.addIssue({ code: 'custom', path: ['pattern'], message: 'invalid regular expression' }); + } + if (check.whenPattern !== undefined && !isValidRegex(check.whenPattern, '')) { + ctx.addIssue({ code: 'custom', path: ['whenPattern'], message: 'invalid regular expression' }); + } + }); + +const ForbiddenImportCheckSchema = z.object({ + kind: z.literal('forbidden-import'), + modules: z.array(z.string().min(1)).min(1), + allow: z.array(z.string().min(1)).default([]), +}); + +const RequiredImportCheckSchema = z + .object({ + kind: z.literal('required-import'), + modules: z.array(z.string().min(1)).min(1), + whenPattern: z.string().min(1).optional(), + }) + .superRefine((check, ctx) => { + if (check.whenPattern !== undefined && !isValidRegex(check.whenPattern, '')) { + ctx.addIssue({ code: 'custom', path: ['whenPattern'], message: 'invalid regular expression' }); + } + }); + +const LineCountCheckSchema = z.object({ + kind: z.literal('line-count'), + selector: z.enum(['function', 'method', 'file']), + name: z.string().min(1).optional(), + max: z.number().int().positive(), +}); + +const ExternalCheckSchema = z.object({ + kind: z.literal('external'), + executorId: z.string().min(1), +}); + +export const CheckSchema = z.discriminatedUnion('kind', [ + RegexCheckSchema, + ForbiddenImportCheckSchema, + RequiredImportCheckSchema, + LineCountCheckSchema, + ExternalCheckSchema, +]); + +const OptionSchema = z.object({ + id: z.string().min(1), + criteria: z.string().min(1), +}); + +const NoulQuestionSchema = z.object({ + type: z.literal('noul'), + instructions: z.string().min(1), +}); + +const ChoiceQuestionSchema = z + .object({ + type: z.literal('choice'), + instructions: z.string().min(1), + options: z.array(OptionSchema).min(2).max(255), + violatingOptions: z.array(z.string().min(1)).min(1), + }) + .superRefine((question, ctx) => { + const ids = question.options.map((option) => option.id); + if (!ids.includes('other')) { + ctx.addIssue({ code: 'custom', path: ['options'], message: "choice questions must declare an 'other' option" }); + } + if (new Set(ids).size !== ids.length) { + ctx.addIssue({ code: 'custom', path: ['options'], message: 'option ids must be unique' }); + } + for (const violating of question.violatingOptions) { + if (!ids.includes(violating)) { + ctx.addIssue({ code: 'custom', path: ['violatingOptions'], message: `unknown option '${violating}'` }); + } + } + }); + +const ScoreQuestionSchema = z + .object({ + type: z.literal('score'), + instructions: z.string().min(1), + levels: z.array(OptionSchema).min(2).max(10), + violatingLevels: z.array(z.string().min(1)).min(1), + }) + .superRefine((question, ctx) => { + const ids = question.levels.map((level) => level.id); + if (new Set(ids).size !== ids.length) { + ctx.addIssue({ code: 'custom', path: ['levels'], message: 'level ids must be unique' }); + } + for (const violating of question.violatingLevels) { + if (!ids.includes(violating)) { + ctx.addIssue({ code: 'custom', path: ['violatingLevels'], message: `unknown level '${violating}'` }); + } + } + }); + +export const QuestionSchema = z.discriminatedUnion('type', [NoulQuestionSchema, ChoiceQuestionSchema, ScoreQuestionSchema]); + +export const StateSchema = z.object({ + slice: z.enum(['file', 'diff-window', 'declaration', 'exports-only', 'strings-only', 'jsx-only']).default('file'), + contextLines: z.number().int().nonnegative().default(0), + maxTokens: z.number().int().positive().max(8000).default(4000), + preamble: z.string().max(600).default(''), +}); + +const Probability = z.number().min(0).max(1); + +export const NoulThresholdsSchema = z + .object({ + deny: Probability.optional(), + ask: Probability.optional(), + advise: Probability, + uncertain: z.object({ lo: Probability, hi: Probability }), + }) + .strict(); + +export const DistributionThresholdsSchema = z + .object({ + deny: Probability.optional(), + ask: Probability.optional(), + advise: Probability, + minConfidence: Probability, + }) + .strict(); + +export const ThresholdsSchema = z.union([NoulThresholdsSchema, DistributionThresholdsSchema]); + +export const ThresholdsOverrideSchema = z.object({ + deny: Probability.optional(), + ask: Probability.optional(), + advise: Probability.optional(), + minConfidence: Probability.optional(), + uncertain: z.object({ lo: Probability.optional(), hi: Probability.optional() }).optional(), +}); + +export const RuntimeSchema = z.object({ + runner: z.string().min(1), + gate: z.string().min(1), +}); + +export const ScopeSchema = z.object({ + include: z.array(z.string().min(1)).min(1), + exclude: z.array(z.string().min(1)).default([]), +}); + +export const LayerSchema = z.enum(['domain', 'application', 'infrastructure', 'presentation', 'testing', 'any']); +export const SeveritySchema = z.enum(['FAIL', 'WARN']); +export const RuleClassSchema = z.enum(['static', 'semantic', 'runtime']); + +const RuleBaseSchema = z.object({ + id: z.string().regex(RULE_ID_PATTERN, 'rule id must match / in lowercase'), + title: z.string().min(1), + layer: LayerSchema, + scope: ScopeSchema, + class: RuleClassSchema, + severity: SeveritySchema, + rationale: z.string().min(1), + fix: z.string().min(1), + source: z.string().min(1).optional(), + check: CheckSchema.optional(), + question: QuestionSchema.optional(), + state: StateSchema.optional(), + thresholds: ThresholdsSchema.optional(), + runtime: RuntimeSchema.optional(), + tags: z.array(z.string().min(1)).default([]), +}); + +export function validateThresholdsForQuestion( + question: z.infer | undefined, + thresholds: { [key: string]: unknown } | undefined, +): string | null { + if (!thresholds) { + return null; + } + if (!question) { + return 'thresholds require a question'; + } + const hasUncertain = 'uncertain' in thresholds; + const hasMinConfidence = 'minConfidence' in thresholds; + if (question.type === 'noul') { + if (hasMinConfidence) { + return 'noul rules use thresholds.uncertain, not minConfidence'; + } + if (!hasUncertain) { + return 'noul rules require thresholds.uncertain { lo, hi }'; + } + return null; + } + if (hasUncertain) { + return `${question.type} rules use thresholds.minConfidence, not uncertain`; + } + if (!hasMinConfidence) { + return `${question.type} rules require thresholds.minConfidence`; + } + return null; +} + +export const RuleSchema = RuleBaseSchema.superRefine((rule, ctx) => { + if (rule.class === 'static' && !rule.check) { + ctx.addIssue({ code: 'custom', path: ['check'], message: 'static rules require a check' }); + } + if (rule.class === 'semantic') { + if (!rule.question) { + ctx.addIssue({ code: 'custom', path: ['question'], message: 'semantic rules require a question' }); + } + if (!rule.state) { + ctx.addIssue({ code: 'custom', path: ['state'], message: 'semantic rules require a state' }); + } + } + if (rule.class === 'runtime' && !rule.runtime) { + ctx.addIssue({ code: 'custom', path: ['runtime'], message: 'runtime rules require a runtime block' }); + } + const thresholdsError = validateThresholdsForQuestion(rule.question, rule.thresholds); + if (thresholdsError) { + ctx.addIssue({ code: 'custom', path: ['thresholds'], message: thresholdsError }); + } +}); + +export const OverrideSchema = z.object({ + id: z.string().regex(RULE_ID_PATTERN), + disabled: z.boolean().optional(), + severity: SeveritySchema.optional(), + scope: z + .object({ + include: z.array(z.string().min(1)).min(1).optional(), + exclude: z.array(z.string().min(1)).optional(), + }) + .optional(), + thresholds: ThresholdsOverrideSchema.optional(), +}); + +export const ExtendsEntrySchema = z.object({ + id: z.string().min(1), + version: z.string().min(1), + sha256: z.string().regex(/^[a-f0-9]{64}$/), +}); + +export const RulebookSchema = z + .object({ + $schema: z.literal(RULEBOOK_SCHEMA_ID), + id: z.string().min(1), + version: z.string().min(1), + extends: z.array(ExtendsEntrySchema).default([]), + model: z.object({ provider: z.literal('typesafe'), pin: z.string().min(1) }), + rules: z.array(RuleSchema).default([]), + overrides: z.array(OverrideSchema).default([]), + }) + .superRefine((rulebook, ctx) => { + const seen = new Set(); + rulebook.rules.forEach((rule, index) => { + if (seen.has(rule.id)) { + ctx.addIssue({ code: 'custom', path: ['rules', index, 'id'], message: `duplicate rule id '${rule.id}'` }); + } + seen.add(rule.id); + }); + }); + +export type Check = z.infer; +export type Question = z.infer; +export type Thresholds = z.infer; +export type ThresholdsOverride = z.infer; +export type Rule = z.infer; +export type Override = z.infer; +export type Rulebook = z.infer; +export type Severity = z.infer; +export type RuleClass = z.infer; + +export type ParseResult = { ok: true; rulebook: Rulebook } | { ok: false; error: string }; + +export function formatIssues(error: z.ZodError): string { + return error.issues.map((issue) => `${issue.path.map(String).join('.') || ''}: ${issue.message}`).join('\n'); +} + +export function parseRulebook(input: unknown): ParseResult { + const result = RulebookSchema.safeParse(input); + if (result.success) { + return { ok: true, rulebook: result.data }; + } + return { ok: false, error: formatIssues(result.error) }; +} diff --git a/scripts/lib/scope.ts b/scripts/lib/scope.ts new file mode 100644 index 0000000..a22efc7 --- /dev/null +++ b/scripts/lib/scope.ts @@ -0,0 +1,120 @@ +export interface Scope { + include: string[]; + exclude: string[]; +} + +const REGEX_SPECIALS = /[.+^$()|[\]\\]/g; + +export function normalizePath(path: string): string { + let normalized = path.replace(/\\/g, '/'); + while (normalized.startsWith('./')) { + normalized = normalized.slice(2); + } + return normalized; +} + +function expandBraces(pattern: string): string[] { + const start = pattern.indexOf('{'); + if (start === -1) { + return [pattern]; + } + let depth = 0; + for (let i = start; i < pattern.length; i += 1) { + const char = pattern[i]; + if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + const head = pattern.slice(0, start); + const body = pattern.slice(start + 1, i); + const tail = pattern.slice(i + 1); + return splitTopLevel(body).flatMap((option) => expandBraces(head + option + tail)); + } + } + } + return [pattern]; +} + +function splitTopLevel(body: string): string[] { + const parts: string[] = []; + let depth = 0; + let current = ''; + for (const char of body) { + if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + } + if (char === ',' && depth === 0) { + parts.push(current); + current = ''; + } else { + current += char; + } + } + parts.push(current); + return parts; +} + +function segmentToRegex(segment: string): string { + let out = ''; + let i = 0; + while (i < segment.length) { + const char = segment[i]; + if (char === '*') { + if (segment[i + 1] === '*') { + out += '.*'; + i += 2; + continue; + } + out += '[^/]*'; + } else if (char === '?') { + out += '[^/]'; + } else { + out += char.replace(REGEX_SPECIALS, '\\$&'); + } + i += 1; + } + return out; +} + +function singleGlobToRegexSource(pattern: string): string { + const segments = normalizePath(pattern).split('/'); + const parts: string[] = []; + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]; + const isLast = index === segments.length - 1; + if (segment === '**') { + parts.push(isLast ? '.*' : '(?:.*/)?'); + continue; + } + parts.push(segmentToRegex(segment) + (isLast ? '' : '/')); + } + return parts.join(''); +} + +export function globToRegExp(pattern: string): RegExp { + const alternatives = expandBraces(pattern).map(singleGlobToRegexSource); + return new RegExp(`^(?:${alternatives.join('|')})$`); +} + +const cache = new Map(); + +export function matchGlob(pattern: string, path: string): boolean { + let regex = cache.get(pattern); + if (!regex) { + regex = globToRegExp(pattern); + cache.set(pattern, regex); + } + return regex.test(normalizePath(path)); +} + +export function isInScope(scope: Scope, path: string): boolean { + const normalized = normalizePath(path); + const included = scope.include.some((pattern) => matchGlob(pattern, normalized)); + if (!included) { + return false; + } + return !scope.exclude.some((pattern) => matchGlob(pattern, normalized)); +} diff --git a/scripts/lib/static-engine.ts b/scripts/lib/static-engine.ts new file mode 100644 index 0000000..fb8e8be --- /dev/null +++ b/scripts/lib/static-engine.ts @@ -0,0 +1,321 @@ +import { isInScope, matchGlob } from './scope.ts'; +import type { Check, Rule, Severity } from './rulebook.schema.ts'; + +export interface SourceFile { + path: string; + content: string; +} + +export interface Finding { + ruleId: string; + severity: Severity; + path: string; + line?: number; + evidence: string; + fix: string; + class: 'static'; +} + +export type Executor = (rule: Rule, scopedFiles: SourceFile[], allFiles: SourceFile[]) => Finding[]; + +export interface StaticRunResult { + findings: Finding[]; + warnings: string[]; + applied: Record; +} + +const executors = new Map(); + +export function registerExecutor(id: string, executor: Executor): void { + executors.set(id, executor); +} + +export function unregisterExecutor(id: string): void { + executors.delete(id); +} + +export function hasExecutor(id: string): boolean { + return executors.has(id); +} + +const EVIDENCE_MAX_LENGTH = 160; + +export function lineAt(content: string, index: number): number { + let line = 1; + for (let i = 0; i < index && i < content.length; i += 1) { + if (content.charCodeAt(i) === 10) { + line += 1; + } + } + return line; +} + +function lineText(content: string, index: number): string { + const start = content.lastIndexOf('\n', index - 1) + 1; + const endIndex = content.indexOf('\n', index); + const end = endIndex === -1 ? content.length : endIndex; + const text = content.slice(start, end).trim(); + return text.length > EVIDENCE_MAX_LENGTH ? `${text.slice(0, EVIDENCE_MAX_LENGTH)}...` : text; +} + +export function maskCommentsAndStrings(source: string): string { + const out: string[] = []; + let i = 0; + const length = source.length; + const blank = (char: string): string => (char === '\n' ? '\n' : ' '); + + while (i < length) { + const char = source[i]; + const next = source[i + 1]; + + if (char === '/' && next === '/') { + while (i < length && source[i] !== '\n') { + out.push(' '); + i += 1; + } + continue; + } + + if (char === '/' && next === '*') { + out.push(' ', ' '); + i += 2; + while (i < length && !(source[i] === '*' && source[i + 1] === '/')) { + out.push(blank(source[i])); + i += 1; + } + if (i < length) { + out.push(' ', ' '); + i += 2; + } + continue; + } + + if (char === "'" || char === '"' || char === '`') { + const quote = char; + out.push(quote); + i += 1; + while (i < length && source[i] !== quote) { + if (source[i] === '\\' && i + 1 < length) { + out.push(' ', blank(source[i + 1])); + i += 2; + continue; + } + if (quote !== '`' && source[i] === '\n') { + break; + } + out.push(blank(source[i])); + i += 1; + } + if (i < length && source[i] === quote) { + out.push(quote); + i += 1; + } + continue; + } + + out.push(char); + i += 1; + } + + return out.join(''); +} + +export interface ImportEntry { + specifier: string; + line: number; +} + +const IMPORT_PATTERN = /\b(?:import|export)\s+(?:type\s+)?(?:[^'";]*?\s+from\s+)?(['"])(?:\s*)\1/g; +const REQUIRE_PATTERN = /\brequire\s*\(\s*(['"])\s*\1\s*\)/g; + +export function extractImports(source: string): ImportEntry[] { + const masked = maskCommentsAndStrings(source); + const entries: Array = []; + + for (const match of masked.matchAll(IMPORT_PATTERN)) { + const openQuote = match[0].lastIndexOf(match[1], match[0].length - 2); + const start = match.index + openQuote + 1; + const end = match.index + match[0].length - 1; + entries.push({ specifier: source.slice(start, end).trim(), line: lineAt(source, match.index), index: match.index }); + } + + for (const match of masked.matchAll(REQUIRE_PATTERN)) { + const openQuote = match[0].indexOf(match[1]); + const closeQuote = match[0].lastIndexOf(match[1]); + const start = match.index + openQuote + 1; + const end = match.index + closeQuote; + entries.push({ specifier: source.slice(start, end).trim(), line: lineAt(source, match.index), index: match.index }); + } + + return entries.sort((a, b) => a.index - b.index).map(({ specifier, line }) => ({ specifier, line })); +} + +function finding(rule: Rule, path: string, evidence: string, line?: number): Finding { + const result: Finding = { ruleId: rule.id, severity: rule.severity, path, evidence, fix: rule.fix, class: 'static' }; + if (line !== undefined) { + result.line = line; + } + return result; +} + +function runRegex(rule: Rule, check: Extract, file: SourceFile): Finding[] { + if (check.whenPattern !== undefined && !new RegExp(check.whenPattern).test(file.content)) { + return []; + } + const flags = check.flags.includes('g') ? check.flags : `${check.flags}g`; + const matches = [...file.content.matchAll(new RegExp(check.pattern, flags))]; + + if (check.mustMatch) { + return matches.length === 0 ? [finding(rule, file.path, `no match for /${check.pattern}/`)] : []; + } + + if (check.maxMatches !== undefined) { + if (matches.length <= check.maxMatches) { + return []; + } + const overflow = matches[check.maxMatches]; + return [ + finding( + rule, + file.path, + `${matches.length} matches of /${check.pattern}/ (max ${check.maxMatches})`, + overflow ? lineAt(file.content, overflow.index) : undefined, + ), + ]; + } + + return matches.map((match) => finding(rule, file.path, lineText(file.content, match.index), lineAt(file.content, match.index))); +} + +function runForbiddenImport(rule: Rule, check: Extract, file: SourceFile): Finding[] { + const findings: Finding[] = []; + for (const entry of extractImports(file.content)) { + const forbidden = check.modules.some((pattern) => matchGlob(pattern, entry.specifier)); + const allowed = check.allow.some((pattern) => matchGlob(pattern, entry.specifier)); + if (forbidden && !allowed) { + findings.push(finding(rule, file.path, `imports '${entry.specifier}'`, entry.line)); + } + } + return findings; +} + +function runRequiredImport(rule: Rule, check: Extract, file: SourceFile): Finding[] { + if (check.whenPattern !== undefined && !new RegExp(check.whenPattern).test(file.content)) { + return []; + } + const satisfied = extractImports(file.content).some((entry) => + check.modules.some((pattern) => matchGlob(pattern, entry.specifier)), + ); + return satisfied ? [] : [finding(rule, file.path, `no import matching ${check.modules.join(', ')}`)]; +} + +function findBlockEnd(masked: string, openIndex: number): number { + let depth = 0; + for (let i = openIndex; i < masked.length; i += 1) { + const char = masked[i]; + if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + return i; + } + } + } + return masked.length - 1; +} + +const MODIFIERS = '(?:(?:public|private|protected|static|async|override|readonly|export|default)\\s+)*'; + +function declarationPatterns(check: Extract): RegExp[] { + const name = check.name ?? '[A-Za-z_$][\\w$]*'; + if (check.selector === 'method') { + return [new RegExp(`^[ \\t]*${MODIFIERS}(?:async\\s+)?\\*?\\s*(?:${name})\\s*(?:<[^>]*>)?\\s*\\([^)]*\\)(?:[^{;=]|\\{[^{}]*\\})*\\{`, 'gm')]; + } + return [ + new RegExp(`^[ \\t]*${MODIFIERS}function\\s*\\*?\\s*(?:${name})\\s*(?:<[^>]*>)?\\s*\\([^)]*\\)(?:[^{;]|\\{[^{}]*\\})*\\{`, 'gm'), + new RegExp( + `^[ \\t]*${MODIFIERS}(?:const|let|var)\\s+(?:${name})\\s*(?::[^=]*)?=\\s*(?:async\\s*)?(?:\\([^)]*\\)|[A-Za-z_$][\\w$]*)\\s*(?::[^=]*)?=>\\s*\\{`, + 'gm', + ), + ]; +} + +function runLineCount(rule: Rule, check: Extract, file: SourceFile): Finding[] { + if (check.selector === 'file') { + const lines = file.content.split('\n').length; + return lines > check.max ? [finding(rule, file.path, `file spans ${lines} lines (max ${check.max})`, 1)] : []; + } + + const masked = maskCommentsAndStrings(file.content); + const findings: Finding[] = []; + const seen = new Set(); + for (const pattern of declarationPatterns(check)) { + for (const match of masked.matchAll(pattern)) { + const openIndex = match.index + match[0].length - 1; + if (seen.has(openIndex)) { + continue; + } + seen.add(openIndex); + const closeIndex = findBlockEnd(masked, openIndex); + const startLine = lineAt(file.content, match.index); + const endLine = lineAt(file.content, closeIndex); + const span = endLine - startLine + 1; + if (span > check.max) { + const label = match[0].trim().split('(')[0]?.trim() ?? check.selector; + findings.push(finding(rule, file.path, `${label} spans ${span} lines (max ${check.max})`, startLine)); + } + } + } + return findings.sort((a, b) => (a.line ?? 0) - (b.line ?? 0)); +} + +export function runCheckOnFile(rule: Rule, check: Check, file: SourceFile): Finding[] { + switch (check.kind) { + case 'regex': + return runRegex(rule, check, file); + case 'forbidden-import': + return runForbiddenImport(rule, check, file); + case 'required-import': + return runRequiredImport(rule, check, file); + case 'line-count': + return runLineCount(rule, check, file); + case 'external': + return []; + } +} + +export function runStaticRules(rules: Rule[], files: SourceFile[]): StaticRunResult { + const findings: Finding[] = []; + const warnings: string[] = []; + const applied: Record = {}; + + for (const rule of rules) { + if (rule.class !== 'static' || !rule.check) { + continue; + } + const scoped = files.filter((file) => isInScope(rule.scope, file.path)); + if (scoped.length === 0) { + continue; + } + for (const file of scoped) { + (applied[file.path] ??= []).push(rule.id); + } + + if (rule.check.kind === 'external') { + const executor = executors.get(rule.check.executorId); + if (!executor) { + warnings.push(`rule ${rule.id}: external executor '${rule.check.executorId}' is not registered; skipped`); + continue; + } + findings.push(...executor(rule, scoped, files)); + continue; + } + + for (const file of scoped) { + findings.push(...runCheckOnFile(rule, rule.check, file)); + } + } + + return { findings, warnings, applied }; +} diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100755 index 0000000..3ecdb5d --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,122 @@ +#!/bin/sh +# Entry point for the nestjs-hexagonal-check CLI and for the plugin hooks. +# Pure shell until the gate decides that a runtime is needed. +# +# run.sh --hook [args] hook mode: opt-in gate, path containment, +# then check.ts --hook with stdin forwarded +# run.sh [args] CLI mode: forwards to check.ts + +set -u + +resolve_link() { + target=$1 + while [ -L "$target" ]; do + link=$(readlink "$target") || break + case $link in + /*) target=$link ;; + *) target=$(dirname "$target")/$link ;; + esac + done + printf '%s' "$target" +} + +real_dir() { + (cd "$1" 2>/dev/null && pwd -P) +} + +real_path() { + resolved=$(resolve_link "$1") + dir=$(real_dir "$(dirname "$resolved")") || return 1 + printf '%s/%s' "$dir" "$(basename "$resolved")" +} + +self_script=$(real_path "$0") +self_root=$(real_dir "$(dirname "$self_script")/..") + +hook_mode=0 +if [ "${1:-}" = "--hook" ]; then + hook_mode=1 +fi + +if [ "${NESTJS_HEXAGONAL_DISABLE:-}" = "1" ]; then + if [ "$hook_mode" -eq 0 ]; then + echo "nestjs-hexagonal-check: NESTJS_HEXAGONAL_DISABLE=1, nothing to do" >&2 + fi + exit 0 +fi + +project_dir=${CLAUDE_PROJECT_DIR:-$(pwd)} +input="" + +if [ "$hook_mode" -eq 1 ]; then + rulebook_env=${NESTJS_HEXAGONAL_RULEBOOK:-} + if [ -n "$rulebook_env" ]; then + case $rulebook_env in + /*) rulebook_path=$rulebook_env ;; + *) rulebook_path="$project_dir/$rulebook_env" ;; + esac + else + rulebook_path="" + fi + if [ ! -f "$project_dir/.claude/rulebook.yaml" ] && { [ -z "$rulebook_path" ] || [ ! -f "$rulebook_path" ]; }; then + exit 0 + fi + + input=$(cat) + file_path=$(printf '%s' "$input" | sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + if [ -n "$file_path" ]; then + case $file_path in + /*) ;; + *) file_path="$project_dir/$file_path" ;; + esac + probe=$(dirname "$file_path") + while [ ! -d "$probe" ] && [ "$probe" != "/" ] && [ "$probe" != "." ]; do + probe=$(dirname "$probe") + done + probe_real=$(real_dir "$probe") || exit 0 + project_real=$(real_dir "$project_dir") || exit 0 + case "$probe_real/" in + "$project_real/"*) ;; + *) exit 0 ;; + esac + fi +fi + +project_bin="$project_dir/node_modules/.bin/nestjs-hexagonal-check" +if [ -x "$project_bin" ] && [ "$(real_path "$project_bin")" != "$self_script" ]; then + export NESTJS_HEXAGONAL_BINARY_SOURCE=node_modules + if [ "$hook_mode" -eq 1 ]; then + printf '%s' "$input" | "$project_bin" "$@" + exit $? + fi + exec "$project_bin" "$@" +fi + +export NESTJS_HEXAGONAL_BINARY_SOURCE=plugin-root +check_script="$self_root/scripts/check.ts" + +if [ ! -d "$self_root/node_modules/zod" ] || [ ! -d "$self_root/node_modules/yaml" ]; then + echo "nestjs-hexagonal-check: dependencies missing in $self_root; run 'bun install' in $self_root (skipping)" >&2 + if [ "$hook_mode" -eq 1 ]; then + exit 0 + fi + exit 1 +fi + +if command -v bun >/dev/null 2>&1; then + set -- bun "$check_script" "$@" +elif command -v node >/dev/null 2>&1; then + set -- node --experimental-strip-types --no-warnings "$check_script" "$@" +else + echo "nestjs-hexagonal-check: neither bun nor node found on PATH (skipping)" >&2 + if [ "$hook_mode" -eq 1 ]; then + exit 0 + fi + exit 1 +fi + +if [ "$hook_mode" -eq 1 ]; then + printf '%s' "$input" | "$@" + exit $? +fi +exec "$@" diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..edaacc9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["scripts/**/*.ts"] +}