Plan 002 — Monorepo wiring
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
Turn the empty apps/* / packages/* workspace globs into a working monorepo spine: a shared package can be added under packages/, imported by its @gw2priory/* name from anywhere, and typechecked, tested, and linted by one root command each — with no build step, no dist/, and no orchestration tool. After this plan, packages/domain exists and is consumed by a root test, proving the wiring end to end; adding the NestJS and React apps later becomes "drop a directory in and declare its deps".
Approach
Five moving parts, each small:
Split the TypeScript config. Extract the environment-agnostic strictness rules from today's
tsconfig.jsoninto a newtsconfig.base.json. The roottsconfig.jsonthenextendsit, keeps the Node profile (target/lib/module/moduleResolution/types), keepsnoEmitandallowImportingTsExtensions, and widensincludeto coverpackages/**/*.ts. Onetsc --noEmitstill typechecks the whole tree. The base carries only strictness/syntax so that whenapps/web(DOM+JSX) andapps/api(decorators) arrive in their own specs, each extends the same base without conflict — this is why the split happens now even though there is one profile today (spec.md→ Assumptions).Add the seed package.
packages/domainis a real shared-code seed, not a fixture: a brandedItemIdtype, anitemId()constructor, andnetSellPrice()(the brief's fixed 15% Trading Post tax). Itspackage.jsonisprivate,type: module, and resolves its entry throughexports: { ".": "./src/index.ts" }— consumed as TypeScript source, no build. A co-locatedsrc/index.test.tsunit-tests it.Declare the workspace dependency (the F3 finding). Discovery proved the
packages/*glob makes a package part of the workspace but does not make it importable — pnpm only symlinks a package into a consumer that declares it. So the rootpackage.jsongains"@gw2priory/domain": "workspace:*", and a root testtests/monorepo/resolution.test.tsimports it by name and asserts on it. That test fails first (no dep →Cannot find package) and passes once the dep is declared — the RED that guards the whole wiring.Wire the single test configuration. Add
vitest.config.tswhoseincludeis['packages/**/*.test.ts', 'tests/**/*.test.ts']— one config discovering both trees in one invocation (matches exactly what discovery ran). Later app specs extend this glob.Adopt Biome. Add
@biomejs/biomeand a singlebiome.jsonconfigured to the repo's existing style (2-space indent, single quotes, semicolons, import organizing on) so churn on existing files is minimal. Root gainslint(biome check .) andformat(biome check --write .) scripts. One dedicated formatting pass reformats whatever Biome flags on existing files, committed on its own so the wiring diff stays readable.
Repo-scan tests (tests/monorepo/wiring.test.ts) lock the invariants the spec's success criteria demand: no package emits dist/, every package is private with an exports-to-.ts entry under the @gw2priory scope, Biome is the only linter/formatter, and no orchestration tool (Turborepo/Nx/Moon) is present.
Architecture
gw2-priory (root workspace)
├── tsconfig.base.json strictness only ─┐ extended by
├── tsconfig.json (Node profile, noEmit) ────┤ every profile
│ include: scripts/ tests/ packages/ │ (apps later)
├── vitest.config.ts one config, both trees
├── biome.json lint + format, repo style
├── package.json scripts: typecheck | test | lint | format
│ devDep: @gw2priory/domain: workspace:* ← F3
├── packages/
│ └── domain/ @gw2priory/domain (private, source-only)
│ ├── package.json exports "." → ./src/index.ts
│ └── src/index.ts (+ index.test.ts co-located)
└── tests/
├── workflow/ spec 001 suite (untouched)
└── monorepo/
├── resolution.test.ts imports @gw2priory/domain BY NAME (integration)
└── wiring.test.ts repo-scan invariants (SC1/SC4/SC5, R2/R3/R5/R6)Dependency direction: the root test consumes @gw2priory/domain; nothing in packages/ depends upward. tsconfig.base.json is depended on by every config and depends on nothing.
Tech stack
- pnpm 11.15.1 (workspaces;
packageManagerpinned) — orchestration is plainpnpmscripts, no Turborepo/Nx. - Node ≥22.18 (runtime here 26.5.0) — runs
.tsdirectly;tsconfigstaysnoEmit. - TypeScript 7.0.2 — one
tsc --noEmitover the tree. (Existing dep.) - Vitest 4.1.10 — one config, both trees. (Existing dep.)
@biomejs/biome2.5.5 — NEW dependency, justified: the sole linter and formatter, replacing the ESLint + Prettier pair with one tool and one config. A bundled Rust binary with zero deps/peers, so it is independent of thetscAPI and carries no TypeScript-7 compatibility risk (research.mdV2). This is the only dependency this plan adds.@types/node26.1.1 — existing.
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.
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 |
|---|---|---|
tsconfig.base.json | new | Environment-agnostic strictness/syntax only: strict, noUncheckedIndexedAccess, noImplicitOverride, noFallthroughCasesInSwitch, exactOptionalPropertyTypes, verbatimModuleSyntax, skipLibCheck. No lib/module/types/noEmit. |
tsconfig.json | modified | extends the base; keeps the Node profile (target: ES2023, lib: [ES2023], module/moduleResolution: nodenext, types: [node]), allowImportingTsExtensions, noEmit; include widened to add packages/**/*.ts. |
vitest.config.ts | new | The single Vitest configuration; include: ['packages/**/*.test.ts', 'tests/**/*.test.ts']. |
biome.json | new | Biome lint + format config, repo style (2-space, single quotes, semicolons, organize-imports on). |
package.json | modified | Add scripts lint (biome check .) and format (biome check --write .); add devDeps @biomejs/biome and @gw2priory/domain: workspace:*. |
packages/domain/package.json | new | @gw2priory/domain, private, type: module, version: 0.0.0, exports: { ".": "./src/index.ts" }. |
packages/domain/src/index.ts | new | Seed domain: ItemId brand, itemId(), netSellPrice(). Pure functions, no I/O. |
packages/domain/src/index.test.ts | new | Co-located unit test for the seed (P1 #3). |
tests/monorepo/resolution.test.ts | new | Imports @gw2priory/domain by name, asserts on its exports (P1 #1, P1 #4, R8, SC3). |
tests/monorepo/wiring.test.ts | new | Repo-scan invariants: no package dist/; every package private + exports→.ts under @gw2priory; Biome present, no ESLint/Prettier; no Turbo/Nx/Moon; tsconfig.base.json split + noEmit; root scripts present (SC1, SC4, SC5, R2, R3, R5, R6). |
specs/002-monorepo-wiring/spec.md | modified | Fill the traceability table's Test column with the test names above (done during implementation, per the template). |
Not changed, and why: pnpm-workspace.yaml already globs packages/*; .gitignore already ignores dist/ and *.tsbuildinfo; tests/workflow/* (spec 001) is untouched.
Data & contracts
The seed package's public surface (consumed by the resolution test and, later, real domain code):
// @gw2priory/domain
export type ItemId = number & { readonly __brand: 'ItemId' };
export const itemId = (value: number): ItemId => value as ItemId;
/** Gross Trading Post price minus the fixed 15% tax (5% listing + 10% exchange). */
export const netSellPrice = (gross: number): number => Math.floor(gross * 0.85);No other schemas or API shapes. The 15% tax value is the brief's confirmed profit-model constant, so the seed is a genuine first brick of the domain, not throwaway.
Test strategy
Each acceptance scenario and success criterion maps to a named test; the traceability table records which. By kind:
- Integration (the wiring proof).
tests/monorepo/resolution.test.tsimports@gw2priory/domainby name and assertsitemId()/netSellPrice()behaviour. This single test exercises P1 #1 (by-name resolution), P1 #4 (root test resolves across the workspace), R8, and SC3 (cross-workspace import passes under the single Vitest run). It is written RED first: before theworkspace:*dep exists it fails withCannot find package, which is the F3 guard. - Unit.
packages/domain/src/index.test.tscovers P1 #3 (a co-located package test is discovered and run) and the seed's behaviour. - Repo-scan invariants.
tests/monorepo/wiring.test.tsreads the filesystem and manifests to assert SC1 (no package emitsdist/; each isprivate,exports→.ts,@gw2prioryscope), SC4 (Biome present; zero ESLint/Prettier in the manifest/lockfile), SC5 (zero Turbo/Nx/Moon), R2/R3 (thetsconfig.base.jsonsplit exists and the root staysnoEmitwithpackages/**included), R5/R6 (lint/format scripts exist; orchestration is plain pnpm). This mirrors spec 001's repo-invariant tests. - Verified by running the commands at step 5, not by a self-referential test. SC2 ("
pnpm typecheckpasses clean") and P2 #1–#2 are proven by executingpnpm typecheckandpnpm testat verification and observing exit 0 — runningtscinside Vitest would be slow and circular. The test-side floor for SC2 is the no-anyscan already required by the constitution's definition of done; the "clean pass" itself is a step-5 command run and a human-reviewed diff. P2 #3 (pnpm lintruns Biome over the tree) is likewise a step-5 command run, floored bywiring.test.tsasserting thelintscript invokes Biome. - Non-regression. Spec 001's suite (
tests/workflow/*) must stay green, and the count of files under anydocs/superpowers/path must stay zero (SC6) — spec 001 already owns a test for the latter; this plan adds nothing under that path.
Deliberately not tested: that Node can import a raw .ts package at runtime (only typecheck and Vitest are in scope here — research.md V1 caveat); app-profile tsconfigs (no apps exist yet).
Alternatives considered
- Turborepo (or any cache/orchestration tool) now — rejected: at two-app scale a content cache saves seconds that do not exist; it is a drop-in wrapper over these same pnpm scripts and returns in its own "CI/build is slow" spec. Mirrors
spec.mdOut of scope and the "no Redis until earned" posture. - Nx / Moon — rejected: platform weight and TS-aware inference/plugins that may lag TypeScript 7, for a repo that needs none of the project-graph machinery.
- Compiled packages (
tsc -b,dist/, project references) — rejected: adds a build step anddist/staleness for no gain, since nothing is published and Node runs TS directly. Project references return only with divergent app profiles (spec.mdOut of scope). - ESLint + typescript-eslint — rejected: couples to the
tscAPI (TS7 compatibility risk,research.mdV2), needs a formatter alongside it, and is heavier config than Biome's single file. - No Vitest config (rely on the default
**/*.test.tsglob) — rejected: the spec calls for one explicit configuration discovering both trees; an explicitincludeis what discovery actually verified and is a concrete artifact the repo-scan test can point at.
Risks
- Biome reformats existing files, muddying the diff. Mitigation: configure Biome to the repo's current style so changes are minimal, and land the
biome check --writepass as its own commit, separate from the wiring changes. - Forgetting the
workspace:*dep (F3) silently breaks resolution. Mitigation: the resolution test is written RED first and only goes green once the dep is declared — the failure is impossible to skip. - TypeScript 7 is bleeding-edge. Mitigation: the exact toolchain (pnpm/tsc/vitest/biome at the pinned versions) was exercised together in discovery; Biome and pnpm are
tsc-agnostic, and the onetsc-dependent step (typecheck) was run and passed. - The Vitest
includeglob must grow when apps arrive. Accepted and documented: addingapps/**/*.test.tsis a one-line edit owned by each app's spec.
Open questions
- Biome style specifics (semicolons) — resolved. The repo was split (
.vitepress/config.mtsomits semicolons,scripts/new-spec.tsuses them). The human delegated the call to the agent; the decision is semicolons + single quotes + 2-space — it matches the hand-written code file, is Biome's and the ecosystem's default, and leaves only.vitepress/config.mtsto gain semicolons in the formatting pass.biome.jsonis configured accordingly. - Nothing left open by
research.md. Both[NEEDS VERIFICATION]markers are confirmed.