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 installat the worktree root so@swc/coreandvitestresolve (F4). Not a code change; do it once. - [ ] RED: in
source-model.test.ts, test the pureparseFile(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,Dvalue);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(findsGlobal). Then one disk test —SC8: scanTree enumerates real src—scanTree(<api>/src)filesincludesgw2/gw2.service.tsand its*.test.ts,modelsexcludes*.test.tsand theconventions/dir, and thegw2.service.tsmodel has aninjectedClass. Watch them fail (parseFile is not defined). - [ ] GREEN: implement
parseFilewithswc.parseSync(text, { syntax: 'typescript', decorators: true }), walkingbodyforImportDeclaration(maptypeOnlyand each specifier'sisTypeOnly),ClassDeclaration/ExportDeclaration(decorators → names;@Injectable/@Controller→ collect constructor param names + type-reference identifier;@Module({…})→ object literal key names). ImplementscanTree(dir)withreaddirSync(dir, { recursive: true }): collect all.tsfiles,parseFileevery non-*.test.tsfile outsideconventions/intomodels. Return{ models, files }. - [ ] REFACTOR: only with the tests green.
- [ ] Teeth: hard-code the specifier
isTypeOnlytofalse; theimport { 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 tinyTreeModels from inline snippets viaparseFile—SC1/P1#2/G1: a class-validator import is flagged, a nestjs-zod import is not(a model importingclass-validator→ oneViolationnaming the file; a model importingnestjs-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.tsdefinetype Guard = (tree: TreeModel) => Violation[]and implementg1NoClassValidator(any importsourcematchingclass-validator/class-transformer),g3NoGlobalModule(anydecoratorNamesincludesGlobal),g4Gw2AccessOnlyInGw2Dir(a model whosepathis not undergw2/whosetextincludesapi.guildwars2.comor importsGw2Client). - [ ] REFACTOR: only with the tests green.
- [ ] Teeth: make
g1match only exactclass-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 modelimport type { Dep } from './dep'; @Injectable() class S { constructor(private d: Dep){} }→ oneViolationnaming paramd; the value-import version (import { Dep }) →[]; the per-specifier version (import { type Dep }) → flagged. PlusG2: a ctor param whose type is not a local import is ignored—constructor(private n: number)and an@Inject(TOKEN) private x: FoowhereFooisn't imported →[](only locally-imported class types are checked). Watch them fail. - [ ] GREEN: implement
g2InjectedParamsAreValueImports— for eachinjectedClassctor param with atypeName, find the import specifier whoselocal === typeName; if found and (typeOnlyImport === trueor specifierisTypeOnly === true) →Violation. Params with no matching local import are skipped. - [ ] REFACTOR: only with the tests green.
- [ ] Teeth: ignore the per-specifier
isTypeOnlying2; theimport { 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.tsimportingNotFoundExceptionfrom@nestjs/commonwiththrow new NotFoundException('x')→ violation;foo.controller.tssame →[]; a service importing it with nothrow 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.tsmodel importing a name matching/Exception$/from@nestjs/commonwhosetextcontainsthrow new <Name>(→ violation. Implementg6FeatureModuleStructure— groupmodelsby directory; a directory with a model bearinginjectedClassesbut no*.module.tsentry intree.files→ violation; and theapp.module.tsmodel whosemoduleObjectKeyscontains anything other thanimports→ 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— aTreeModelwheremodelshasfoo/foo.service.ts(aninjectedClass) andfileslacksfoo/foo.service.test.ts→ violation; add the sibling tofiles→[]. Watch it fail. - [ ] GREEN (guard): implement
g7ColocatedTests— each model with a non-emptyinjectedClasseswhose sibling<base>.test.tsis absent fromtree.files→ violation. - [ ] Teeth (guard): make
g7require<base>.spec.tsinstead of.test.ts; the sibling-present test must fail. Restore. - [ ] RED (drift):
health.service.test.ts—R11/SC7: HealthService.getStatus() returns {status:'ok'}. ImportHealthService, assertnew HealthService().getStatus()equals{ status: 'ok' }. (A coverage-closing characterization test; it passes on write because the code exists — its purpose is to clear G7 forhealth.service.ts.) - [ ] Teeth (drift): temporarily change
getStatusto 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 inbeforeAll, then one nameditper 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— plusP1#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/G1must 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 rootworkspaceVitest project) —SC9/P2#1: docs/architecture/nestjs.md existsandSC9/P2#2: CLAUDE.md's Reference list links nestjs.md and the target resolves(readCLAUDE.md, assert a Reference bullet containsdocs/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 onmain(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 toCLAUDE.md:- `docs/architecture/nestjs.md` — …. - [ ] REFACTOR: tighten wording only with the doc test green.
- [ ] Teeth: delete the
CLAUDE.mdReference 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 perP1 #n/SCmapping 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, noany, no escape hatches. - [ ] Record
## Notesgraduation candidates for step 6 — chieflyresearch.mdF3 (TS 7 is the native port; use@swc/corefor AST work) → a line indocs/architecture/typescript.md. - [ ] On the human's decision, transcribe
spec.mdstatusapproved → implementedinside 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.mdF3): TypeScript 7 is the native port with no classic compiler API; programmatic AST work in this repo uses@swc/core. Destined fordocs/architecture/typescript.mdat step 6. - Prerequisite recorded (F4): the worktree needs
pnpm installbefore 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.tstest file may not useimport.meta—tscrejects it with TS1470 even though Vitest runs it fine. Use__dirname(Vitest injects__dirname/__filenameat runtime; it also typechecks as a CJS global). The arch-test was fixed accordingly (3a6afd1). Graduation candidate fordocs/architecture/typescript.mdalongside F3.