Skip to content

Tasks 011 — NestJS conventions & architecture guards ​

Execution skill: superpowers:subagent-driven-development — one implementer per task, then a two-stage review (spec compliance, then code quality). superpowers:test-driven-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on any surprise rather than guessing.

Derived from plan.md (approved). Each task is small, independently verifiable, and reviewed as its own diff. Split where a reviewer could reject one task while approving its neighbour — not where the work merely changes subject. A task is done only when it satisfies the definition of done in CLAUDE.md.

Global Constraints in plan.md apply to every task and are not repeated per task.

Build order: the scanner first (T1), then the seven guards as pure predicates proven to catch and to pass (T2–T5), then the arch-test that runs them over the real tree (T6), then the guide + its doc test (T7), then close-out (T8). Every test is offline — the suite only reads source text; no network, no Nest module, no app boot.

One-time prerequisite (all tasks): this worktree has no installed deps yet (research.md F4). Run pnpm install from the worktree root before T1, or @swc/core/vitest will not resolve.


T1 — Source scanner over @swc/core ​

Satisfies: R2, SC8.

Files: create apps/api/src/conventions/source-model.ts, apps/api/src/conventions/source-model.test.ts.

  • [ ] Setup: pnpm install at the worktree root so @swc/core and vitest resolve (F4). Not a code change; do it once.
  • [ ] RED: in source-model.test.ts, test the pure parseFile(text, path) on inline source — SC8: parseFile splits value vs type-only imports (import { A } from './a' → value; import type { B } from './b' → typeOnlyImport; import { type C, D } from './c' → C.isTypeOnly, D value); SC8: parseFile reads @Injectable ctor param types (@Injectable() class S { constructor(private d: Dep){} } → injectedClasses[0] decorators ['Injectable'], ctorParams [{name:'d', typeName:'Dep'}]); SC8: parseFile reads @Module object keys (@Module({ imports:[X], providers:[Y] }) class M {} → moduleObjectKeys ['imports','providers']); SC8: parseFile collects decorator names (finds Global). Then one disk test — SC8: scanTree enumerates real src — scanTree(<api>/src) files includes gw2/gw2.service.ts and its *.test.ts, models excludes *.test.ts and the conventions/ dir, and the gw2.service.ts model has an injectedClass. Watch them fail (parseFile is not defined).
  • [ ] GREEN: implement parseFile with swc.parseSync(text, { syntax: 'typescript', decorators: true }), walking body for ImportDeclaration (map typeOnly and each specifier's isTypeOnly), ClassDeclaration/ExportDeclaration (decorators → names; @Injectable/@Controller → collect constructor param names + type-reference identifier; @Module({…}) → object literal key names). Implement scanTree(dir) with readdirSync(dir, { recursive: true }): collect all .ts files, parseFile every non-*.test.ts file outside conventions/ into models. Return { models, files }.
  • [ ] REFACTOR: only with the tests green.
  • [ ] Teeth: hard-code the specifier isTypeOnly to false; the import { type C, D } assertion must fail. Restore.
  • [ ] Commit: api: add conventions source scanner over @swc/core (R2, SC8).

Verified by: source-model.test.ts › SC8: … (four parseFile + one scanTree).


T2 — Guards G1, G3, G4 (import / decorator / text) ​

Satisfies: R3/G1, R5/G3, R6/G4; catch-side of SC1, SC3, SC4; P1 #2, P1 #4, P1 #5.

Files: create apps/api/src/conventions/guards.ts, apps/api/src/conventions/guards.test.ts.

  • [ ] RED: in guards.test.ts, build tiny TreeModels from inline snippets via parseFile — SC1/P1#2/G1: a class-validator import is flagged, a nestjs-zod import is not (a model importing class-validator → one Violation naming the file; a model importing nestjs-zod → []); SC3/P1#4/G3: an @Global() module is flagged, a plain @Module is not; SC4/P1#5/G4: a file outside gw2/ referencing api.guildwars2.com is flagged; the same path under gw2/ is not; a file importing Gw2Service (not Gw2Client) is not. Watch them fail.
  • [ ] GREEN: in guards.ts define type Guard = (tree: TreeModel) => Violation[] and implement g1NoClassValidator (any import source matching class-validator/class-transformer), g3NoGlobalModule (any decoratorNames includes Global), g4Gw2AccessOnlyInGw2Dir (a model whose path is not under gw2/ whose text includes api.guildwars2.com or imports Gw2Client).
  • [ ] REFACTOR: only with the tests green.
  • [ ] Teeth: make g1 match only exact class-validators (typo); the class-validator test must stop flagging. Restore.
  • [ ] Commit: api: add convention guards G1/G3/G4 (R3, R5, R6).

Verified by: guards.test.ts › SC1/P1#2/G1: …, SC3/P1#4/G3: …, SC4/P1#5/G4: ….


T3 — Guard G2, the DI value-import rule ​

Satisfies: R4/G2, SC2, P1 #3.

Files: modify apps/api/src/conventions/guards.ts; add tests to apps/api/src/conventions/guards.test.ts.

  • [ ] RED: SC2/P1#3/G2: an injected ctor param typed by a type-only import is flagged — a model import type { Dep } from './dep'; @Injectable() class S { constructor(private d: Dep){} } → one Violation naming param d; the value-import version (import { Dep }) → []; the per-specifier version (import { type Dep }) → flagged. Plus G2: a ctor param whose type is not a local import is ignored — constructor(private n: number) and an @Inject(TOKEN) private x: Foo where Foo isn't imported → [] (only locally-imported class types are checked). Watch them fail.
  • [ ] GREEN: implement g2InjectedParamsAreValueImports — for each injectedClass ctor param with a typeName, find the import specifier whose local === typeName; if found and (typeOnlyImport === true or specifier isTypeOnly === true) → Violation. Params with no matching local import are skipped.
  • [ ] REFACTOR: only with the tests green.
  • [ ] Teeth: ignore the per-specifier isTypeOnly in g2; the import { type Dep } test must fail. Restore.
  • [ ] Commit: api: add DI value-import guard G2 (R4, SC2).

Verified by: guards.test.ts › SC2/P1#3/G2: ….


T4 — Guards G5, G6 (HTTP exceptions, module structure) ​

Satisfies: R7/G5, R8/G6; catch-side of SC5, SC6; P1 #6, P1 #7.

Files: modify apps/api/src/conventions/guards.ts; add tests to apps/api/src/conventions/guards.test.ts.

  • [ ] RED: SC5/P1#6/G5: a service that throws a Nest *Exception is flagged; a controller is not; a service that imports but never throws one is not (foo.service.ts importing NotFoundException from @nestjs/common with throw new NotFoundException('x') → violation; foo.controller.ts same → []; a service importing it with no throw new → []). SC6/P1#7/G6: a folder with an @Injectable but no *.module.ts is flagged; one with a sibling module is not; app.module.ts with a providers key is flagged; imports-only is not. Watch them fail.
  • [ ] GREEN: implement g5HttpExceptionsOnlyInControllers — a non-*.controller.ts model importing a name matching /Exception$/ from @nestjs/common whose text contains throw new <Name>( → violation. Implement g6FeatureModuleStructure — group models by directory; a directory with a model bearing injectedClasses but no *.module.ts entry in tree.files → violation; and the app.module.ts model whose moduleObjectKeys contains anything other than imports → violation.
  • [ ] REFACTOR: only with the tests green.
  • [ ] Teeth: drop the module-sibling requirement in g6; the no-module test must stop flagging. Restore.
  • [ ] Commit: api: add guards G5 (http-exceptions) and G6 (module structure) (R7, R8).

Verified by: guards.test.ts › SC5/P1#6/G5: …, SC6/P1#7/G6: ….


T5 — Guard G7 + the health.service drift fix ​

Satisfies: R9/G7, SC7, R11; P1 #8.

Files: modify apps/api/src/conventions/guards.ts and apps/api/src/conventions/guards.test.ts; create apps/api/src/health/health.service.test.ts.

  • [ ] RED (guard): SC7/P1#8/G7: a non-test @Injectable file with no sibling test is flagged; one with a sibling *.test.ts is not — a TreeModel where models has foo/foo.service.ts (an injectedClass) and files lacks foo/foo.service.test.ts → violation; add the sibling to files → []. Watch it fail.
  • [ ] GREEN (guard): implement g7ColocatedTests — each model with a non-empty injectedClasses whose sibling <base>.test.ts is absent from tree.files → violation.
  • [ ] Teeth (guard): make g7 require <base>.spec.ts instead of .test.ts; the sibling-present test must fail. Restore.
  • [ ] RED (drift): health.service.test.ts — R11/SC7: HealthService.getStatus() returns {status:'ok'}. Import HealthService, assert new HealthService().getStatus() equals { status: 'ok' }. (A coverage-closing characterization test; it passes on write because the code exists — its purpose is to clear G7 for health.service.ts.)
  • [ ] Teeth (drift): temporarily change getStatus to return { status: 'down' }; the test must fail. Restore.
  • [ ] Commit: api: add colocated-test guard G7 and health.service test (R9, R11, SC7).

Verified by: guards.test.ts › SC7/P1#8/G7: …; health.service.test.ts › R11/SC7: ….


T6 — Arch-test: enforce all guards over the real apps/api/src ​

Satisfies: R1, SC10, P1 #1, and the pass-side of SC1–SC7.

Files: create apps/api/src/conventions/conventions.arch.test.ts.

  • [ ] RED: in conventions.arch.test.ts, scanTree(<api>/src) once in beforeAll, then one named it per guard asserting [] against the real tree — SC1/G1: no class-validator imports, SC2/G2: every injected ctor param is a value import, SC3/G3: no @Global modules, SC4/G4: GW2 access only under gw2/, SC5/G5: HTTP exceptions only in controllers, SC6/G6: every feature folder has a module and app.module imports-only, SC7/G7: every provider/controller has a colocated test — plus P1#1/SC10: all guards pass with no network. With T2–T5 landed and the drift fixed, these are green on write; if any fails, a real violation exists (fix the code, not the guard — systematic-debugging).
  • [ ] GREEN: none — the arch-test is a consumer of guards.ts; no production code.
  • [ ] Teeth: add import 'class-validator' to a real api file, run the suite — SC1/G1 must go red; remove it and confirm green. (Demonstrates the suite actually guards the tree.)
  • [ ] Commit: api: enforce conventions over apps/api/src via arch-test (R1, SC10, P1#1).

Verified by: conventions.arch.test.ts › the seven SC*/G* its + P1#1/SC10: …, all green.


T7 — The conventions guide + its doc test ​

Satisfies: R10, SC9, P2 #1, P2 #2.

Files: create docs/architecture/nestjs.md; modify CLAUDE.md (Reference list); create tests/docs/nestjs-guide.test.ts.

  • [ ] RED: tests/docs/nestjs-guide.test.ts (runs under the root workspace Vitest project) — SC9/P2#1: docs/architecture/nestjs.md exists and SC9/P2#2: CLAUDE.md's Reference list links nestjs.md and the target resolves (read CLAUDE.md, assert a Reference bullet contains docs/architecture/nestjs.md, assert that file exists). Watch them fail (guide/link absent on this branch).
  • [ ] GREEN: write docs/architecture/nestjs.md — fold in the guide drafted on main (feature-module layout, thin controllers, the DI value-import rule, Zod-first contract, GW2 client boundary, errors, testing, constants) and its "documented-only vs guarded" framing so the prose matches G1–G7. Add the one Reference line to CLAUDE.md: - `docs/architecture/nestjs.md` — ….
  • [ ] REFACTOR: tighten wording only with the doc test green.
  • [ ] Teeth: delete the CLAUDE.md Reference line; the link test must fail. Restore.
  • [ ] Commit: docs: add nestjs conventions guide + reference + doc test (R10, SC9).

Verified by: tests/docs/nestjs-guide.test.ts › SC9/P2#1: …, SC9/P2#2: ….


T8 — Close-out: traceability, verification, status ​

Satisfies: the definition of done in CLAUDE.md (every criterion traced to a named test; human reviews the diff).

Files: modify specs/011-nestjs-conventions/spec.md (traceability table and, on the human's decision, status); update ## Notes below.

  • [ ] Fill spec.md's traceability table — one row per P1 #n / SC mapping to the exact test name from T1–T7 (a transcription; each test was named for its criterion).
  • [ ] Run the full gate from the repo root and paste the passing output into the review: pnpm typecheck && pnpm test && pnpm lint && pnpm build. All green, no any, no escape hatches.
  • [ ] Record ## Notes graduation candidates for step 6 — chiefly research.md F3 (TS 7 is the native port; use @swc/core for AST work) → a line in docs/architecture/typescript.md.
  • [ ] On the human's decision, transcribe spec.md status approved → implemented inside this branch (part of the PR diff), and say the human decided it.
  • [ ] Commit: specs: 011 fill traceability, mark implemented.

Verified by: pnpm typecheck && pnpm test && pnpm lint && pnpm build green from the root; every row of the traceability table names a passing test.


Notes ​

Staging area for decisions and surprises found during implementation — including anything that turned out differently from what plan.md assumed. Move each one into spec.md, research.md, or docs/ before closing the feature; this section is not a home.

  • Known graduation candidate (from research.md F3): TypeScript 7 is the native port with no classic compiler API; programmatic AST work in this repo uses @swc/core. Destined for docs/architecture/typescript.md at step 6.
  • Prerequisite recorded (F4): the worktree needs pnpm install before any api test runs; folded into T1's setup step, not a code or CI change.
  • Implementation finding (T6/T8): in apps/api ("type": "commonjs", moduleResolution: nodenext) a .ts test file may not use import.meta — tsc rejects it with TS1470 even though Vitest runs it fine. Use __dirname (Vitest injects __dirname/__filename at runtime; it also typechecks as a CJS global). The arch-test was fixed accordingly (3a6afd1). Graduation candidate for docs/architecture/typescript.md alongside F3.