Plan 011 — NestJS conventions & architecture guards
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Goal
After this plan, apps/api's mechanical NestJS conventions are executable. A pure Vitest architecture-test suite parses every api source file and fails when a convention is broken — a class-validator import, an injected service written import type, an @Global() module, a GW2 call outside its client, an HTTP exception thrown from a service, a feature folder with no module, or an untested provider. The seven conventions are documented in one committed guide (docs/architecture/nestjs.md), and the single drift the guards find today — HealthService has no colocated test — is fixed. No product feature, HTTP route, openapi.json, or apps/web change.
Approach
The suite is built from three pure, independently testable pieces plus two consumers, all under apps/api/src/conventions/:
A scanner (
source-model.ts). It readsapps/api/src/**/*.tsfrom disk and parses each file with@swc/core.parseSync({ syntax: 'typescript', decorators: true }) — not the TypeScript compiler API, which TS 7 (the native port) removed (research.mdV2/F3). It returns a typedTreeModel: oneFileModelper non-test source file (its imports with value/type-only kind, its@Injectable/@Controllerclasses with constructor param types, its@Moduleobject keys, its decorator names, its raw text), plus the flat list of every.tspath (tests included) so the colocated-test guard can check for siblings. The scanner is pure input→output; it is the trust anchor and gets its own unit test on inline source snippets (spec R2/SC8).Seven guard predicates (
guards.ts). Each guard is a pure function(tree: TreeModel) => Violation[]— it never reads disk or the network; it only inspects the model. Expressing the guards as pure predicates (the project's "pure functions for domain logic" pattern) is what lets each one be unit-tested both ways: fed a violating snippet it must report a violation, fed a compliant snippet it must report none.The arch-test (
conventions.arch.test.ts). It scans the realapps/api/srctree once and asserts every guard returns zero violations — the day-to-day enforcement, offline, in the existing CIteststep (spec R1/SC10). Each assertion is a named test tracing to a success criterion.
Alongside the suite: fold in the guide (docs/architecture/nestjs.md, already drafted on main) and add its CLAUDE.md Reference line, guarded by a repo-level doc test (SC9); and fix the drift by writing health.service.test.ts (spec R11/SC7). The order is scanner → guards (TDD, one guard at a time, each proven to catch and to pass) → arch-test → drift fix → doc + doc test.
Architecture
apps/api/src/conventions/
source-model.ts scanTree(srcDir) → TreeModel (pure: fs read + @swc/core parse)
source-model.test.ts unit tests for the parser/queries on inline source (SC8)
guards.ts G1..G7 : (tree: TreeModel) => Violation[] (pure predicates)
guards.test.ts each guard flags a violating snippet AND passes a clean one (P1 #2..#8)
conventions.arch.test.ts runs G1..G7 over the real apps/api/src tree, asserts 0 violations (P1 #1, SC1..7, SC10)
apps/api/src/health/health.service.test.ts drift fix — HealthService.getStatus() (R11 / SC7)
docs/architecture/nestjs.md the committed guide (folded in from main) (R10 / P2 #1)
CLAUDE.md + one Reference line linking the guide (R10 / P2 #2)
tests/docs/nestjs-guide.test.ts repo-level: guide exists & CLAUDE.md links it (SC9)Dependency direction is one-way and Nest-free: conventions.arch.test.ts and guards.test.ts → guards.ts → source-model.ts → @swc/core + node:fs. The suite imports no application code and constructs no Nest module — it only reads source text, so it never touches the network or the DI container (the structural proof of SC10). The conventions/ directory is itself excluded from the guards (it declares no @Controller/@Injectable and needs no *.module.ts).
Tech stack
@swc/core1.15.46 — the parser. Already anapps/apidevDependency (it is the api's compiler) and already inpnpm-workspace.yaml'sallowBuilds;parseSync(src, { syntax: 'typescript', decorators: true })returns an AST exposing importtypeOnly/ per-specifierisTypeOnly, class decorators, and constructor parameter types (confirmed,research.mdV2). No new dependency.node:fs—readdirSync(dir, { recursive: true })+readFileSync; paths anchored viaimport.meta.dirnameso cwd is irrelevant (confirmed,research.mdV1). No network, no app boot.- Vitest 4 — the api's existing runner;
apps/api/vitest.config.mtsalready includessrc/**/*.test.ts, so*.arch.test.tsandguards.test.tsare picked up with no config change. The repo-level doc test runs under the rootworkspaceproject (tests/**). - TypeScript 7.0.2 typechecks the suite (no
any); it is not used as a parser (research.mdF3).
New dependencies: none. The scanner is ~120 lines over @swc/core; a dedicated parser dep (oxc-parser, typescript-estree) was rejected because SWC is already present and sufficient (Alternatives), and "no new dependency without justification" is a Global Constraint.
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: encrypted at rest, never logged, never returned to the client.
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
apps/api/src/conventions/source-model.ts | new | scanTree(srcDir) — read + @swc/core parse; return TreeModel (per-file imports, injected classes, @Module keys, decorator names, text; plus all .ts paths). Pure. |
apps/api/src/conventions/source-model.test.ts | new | Unit-test the parser queries on inline source: value vs type-only import, decorators, ctor param types, @Module keys, file enumeration (SC8). |
apps/api/src/conventions/guards.ts | new | G1..G7, each (tree: TreeModel) => Violation[]. Pure predicates; no fs, no network. |
apps/api/src/conventions/guards.test.ts | new | Each guard flags a violating snippet and passes a compliant one (P1 #2..#8; catch-side of SC1..SC7). |
apps/api/src/conventions/conventions.arch.test.ts | new | Scan the real apps/api/src; assert each guard returns zero violations (P1 #1; pass-side SC1..SC7; SC10 offline). |
apps/api/src/health/health.service.test.ts | new | Drift fix: new HealthService().getStatus() returns { status: 'ok' } (R11 / SC7). |
docs/architecture/nestjs.md | new | The committed conventions guide: G1–G7 + the documented-only rules (R10 / P2 #1). |
CLAUDE.md | modified | One line in the Reference list linking docs/architecture/nestjs.md (R10 / P2 #2). |
tests/docs/nestjs-guide.test.ts | new | Repo-level: the guide file exists and CLAUDE.md's Reference list links it, and the link resolves (SC9). |
Data & contracts
Internal to the suite; nothing is exposed on any route. Types (final names live in source-model.ts):
interface ImportInfo {
source: string; // module specifier
typeOnlyImport: boolean; // `import type { … }`
names: { local: string; isTypeOnly: boolean }[]; // per-specifier `type` marker
}
interface InjectedClass {
name: string;
decorators: string[]; // e.g. ['Controller'] / ['Injectable']
ctorParams: { name: string; typeName: string | null }[]; // typeName = the type reference identifier
}
interface FileModel {
path: string; // relative to apps/api/src, POSIX
imports: ImportInfo[];
injectedClasses: InjectedClass[]; // classes decorated @Injectable/@Controller
moduleObjectKeys: string[] | null; // keys of the @Module({…}) literal, if the file has one
decoratorNames: string[]; // every decorator identifier used in the file (for @Global)
text: string; // raw source (base-URL / `throw new *Exception` scans)
}
interface TreeModel {
models: FileModel[]; // non-test source files
files: string[]; // every .ts path under src (tests included), relative POSIX
}
interface Violation { guard: string; file: string; detail: string; }
type Guard = (tree: TreeModel) => Violation[];Guard definitions (asserted behaviour; exact code is in tasks.md):
- G1
noClassValidator— anyImportInfo.sourcematchingclass-validator/class-transformer→ violation. - G2
injectedParamsAreValueImports— for eachInjectedClassctor param whosetypeNameresolves to a local import, that import's specifier must havetypeOnlyImport === falseandisTypeOnly === false; otherwise violation naming the param. - G3
noGlobalModule— anydecoratorNamesentry equal toGlobal→ violation. - G4
gw2AccessOnlyInGw2Dir— aFileModelwhosepathis not undergw2/whosetextcontains the GW2 hostapi.guildwars2.comor importsGw2Clientfrom../gw2/gw2-client→ violation. - G5
httpExceptionsOnlyInControllers— a non-*.controller.tsFileModelthat imports a name matching/Exception$/from@nestjs/commonand whosetextcontainsthrow new <Name>(→ violation. - G6
featureModuleStructure— any directory undersrc/containing a non-test file with anInjectedClassbut no*.module.tssibling → violation; andapp.module.ts'smoduleObjectKeyscontaining anything other thanimports→ violation. - G7
colocatedTests— any non-test file with a non-emptyinjectedClasseswhose sibling<base>.test.tsis absent fromtree.files→ violation.
Test strategy
Three layers, all offline; traceability names every test after its criterion so spec.md's table fills during implementation.
- Scanner unit (SC8) —
source-model.test.tsfeeds inline TypeScript strings (a value import, animport type, a per-specifiertypeimport, an@Injectableclass with a typed ctor, a@Moduleliteral) and asserts the returnedFileModelfields. Proves the trust anchor before any guard leans on it. - Guard catch/pass (P1 #2..#8) —
guards.test.tsbuilds a tinyTreeModelfrom inline snippets: each guard gets one snippet it must flag and one it must pass. This is where "a violation is detected" is proven — the arch-test can't prove detection because the real tree is (intentionally) clean. - Arch-test over real source (P1 #1, SC1–SC7 pass-side, SC10) —
conventions.arch.test.tsscansapps/api/srconce and asserts each guard returns[]. Runs with no network available (structural: the suite only reads files), which is SC10. - Drift fix (R11 / SC7) —
health.service.test.tsunit-testsHealthService.getStatus(); after it lands, G7 over the real tree is green. - Doc (SC9) —
tests/docs/nestjs-guide.test.tsassertsdocs/architecture/nestjs.mdexists andCLAUDE.mdcontains a Reference link to it that resolves to a real file.
Not tested directly: the documented-only rules (thin controllers, named constants, error-class placement) — by decision (spec R12) they are prose in the guide, verified by human review, not by a guard. That a guard's detail message is human-legible is checked by eye in review, not asserted.
Task outline
The header-half decomposition (bite-sized TDD steps land in tasks.md at step 3). Each task ends at an independently testable, committable deliverable.
- T1 — Scanner (
source-model.ts+ test).scanTree+ queries; unit-tested on inline source (SC8). Prerequisite:pnpm installin this worktree (research.mdF4). - T2 — Guards G1, G3, G4 (import/decorator/text). The three that need only imports/decorators/text; catch+pass snippet tests (P1 #2/#4/#5).
- T3 — Guard G2 (DI value-import, the flagship). Ctor-param → import-kind resolution; catch+pass (P1 #3).
- T4 — Guards G5, G6 (exceptions, module structure).
*Exceptionthrow-site +@Modulekeys / module-per-folder; catch+pass (P1 #6/#7). - T5 — Guard G7 + drift fix. Colocated-test guard (P1 #8); then
health.service.test.tsso the real tree passes G7 (R11/SC7). - T6 — Arch-test over real source.
conventions.arch.test.tsasserts all guards return[]onapps/api/src(P1 #1, SC1–SC7 pass-side, SC10). - T7 — Guide + doc test. Commit
docs/architecture/nestjs.md, add theCLAUDE.mdReference line, andtests/docs/nestjs-guide.test.ts(R10, SC9, P2). Fold in the version drafted onmain. - T8 — Fill traceability + set status. Complete
spec.md's traceability table with the real test names; the human moves the spec status toimplementedinside the branch (Definition of Done).
Alternatives considered
- A dedicated parser dependency (
oxc-parser,@typescript-eslint/typescript-estree) — rejected:@swc/coreis already present and exposes everything the guards need; a new parser (oxc pulls a native binary needing anotherallowBuildsentry) violates "no new dependency without justification." - The TS 7
unstablecompiler API (typescript/unstable/sync+/ast) — rejected: heavier project/tsconfig setup and an explicitly unstable surface (research.mdV2); SWC is simpler and already a build dependency. - Regex-only guards (no AST) — rejected for G2/G6: constructor-param import-kind and
@Modulekey inspection are not reliably regex-able; a real AST removes a class of false positives. G1/G3/G4/G5/G7 could be regex but share the one parse for consistency. - Biome custom lint rules — rejected: Biome can express at most G1 (
noRestrictedImports) and none of G2–G7; splitting enforcement across two systems (spec §Out of scope, R12) is worse than one Vitest suite. Nobiome.jsonchange. - A standalone CI script — rejected: it would not produce the named tests the Definition of Done's traceability table requires.
- One file per guard — rejected: seven ~15-line predicates are more navigable in one cohesive
guards.tsthan seven files; they share theViolation/TreeModeltypes.
Risks
- SWC AST shape coupling. The scanner reads SWC's node shapes (
Constructor,TsTypeReference,ImportDeclaration.typeOnly, specifierisTypeOnly), not TypeScript's. Mitigation: all shape knowledge is isolated insource-model.tsand pinned bysource-model.test.ts(SC8), so an SWC upgrade that moves a field fails there loudly, not silently across the guards. - Worktree has no installed deps (
research.mdF4).@swc/core/vitestdon't resolve here untilpnpm installruns in the worktree; without it T1 fails spuriously. Mitigation: T1's first step ispnpm install; recorded as a Global prerequisite, not a code change. - Guard false positives on the suite's own files.
conventions/, generated files, and fixtures must be out of scope for structure/test guards. Mitigation:scanTreeexcludes*.test.tsand theconventions/dir frommodels; G6/G7 only consider files with anInjectedClass, and the suite has none. - G5 under-detection. A service could construct-and-store an exception without
throw newon one line. Mitigation: the convention is specifically about throwing HTTP exceptions from services; thethrow new <Name>(+ import check covers the real pattern, and the guide documents the intent for the rare case review must catch.
Open questions
None blocking — research.md is complete and every [NEEDS VERIFICATION] has a verdict. Two implementation-detail defaults, decidable by the implementer without a gate (recorded so they are not mistaken for oversights):
- Where SC9's doc test lives — proposed
tests/docs/nestjs-guide.test.tsunder the rootworkspaceVitest project; if an existing repo-invariant test is the better home, it may move there. - G4 host constant — the guard scans for the literal
api.guildwars2.com; ifgw2-client.tsever parameterises the host, the guard reads the same constant rather than duplicating the string.