Skip to content

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:

  1. Split the TypeScript config. Extract the environment-agnostic strictness rules from today's tsconfig.json into a new tsconfig.base.json. The root tsconfig.json then extends it, keeps the Node profile (target/lib/module/moduleResolution/types), keeps noEmit and allowImportingTsExtensions, and widens include to cover packages/**/*.ts. One tsc --noEmit still typechecks the whole tree. The base carries only strictness/syntax so that when apps/web (DOM+JSX) and apps/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).

  2. Add the seed package. packages/domain is a real shared-code seed, not a fixture: a branded ItemId type, an itemId() constructor, and netSellPrice() (the brief's fixed 15% Trading Post tax). Its package.json is private, type: module, and resolves its entry through exports: { ".": "./src/index.ts" } — consumed as TypeScript source, no build. A co-located src/index.test.ts unit-tests it.

  3. 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 root package.json gains "@gw2priory/domain": "workspace:*", and a root test tests/monorepo/resolution.test.ts imports 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.

  4. Wire the single test configuration. Add vitest.config.ts whose include is ['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.

  5. Adopt Biome. Add @biomejs/biome and a single biome.json configured to the repo's existing style (2-space indent, single quotes, semicolons, import organizing on) so churn on existing files is minimal. Root gains lint (biome check .) and format (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; packageManager pinned) — orchestration is plain pnpm scripts, no Turborepo/Nx.
  • Node ≥22.18 (runtime here 26.5.0) — runs .ts directly; tsconfig stays noEmit.
  • TypeScript 7.0.2 — one tsc --noEmit over the tree. (Existing dep.)
  • Vitest 4.1.10 — one config, both trees. (Existing dep.)
  • @biomejs/biome 2.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 the tsc API and carries no TypeScript-7 compatibility risk (research.md V2). This is the only dependency this plan adds.
  • @types/node 26.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. Use unknown plus 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-error without 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, 429 on 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.

PathChangeResponsibility
tsconfig.base.jsonnewEnvironment-agnostic strictness/syntax only: strict, noUncheckedIndexedAccess, noImplicitOverride, noFallthroughCasesInSwitch, exactOptionalPropertyTypes, verbatimModuleSyntax, skipLibCheck. No lib/module/types/noEmit.
tsconfig.jsonmodifiedextends 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.tsnewThe single Vitest configuration; include: ['packages/**/*.test.ts', 'tests/**/*.test.ts'].
biome.jsonnewBiome lint + format config, repo style (2-space, single quotes, semicolons, organize-imports on).
package.jsonmodifiedAdd scripts lint (biome check .) and format (biome check --write .); add devDeps @biomejs/biome and @gw2priory/domain: workspace:*.
packages/domain/package.jsonnew@gw2priory/domain, private, type: module, version: 0.0.0, exports: { ".": "./src/index.ts" }.
packages/domain/src/index.tsnewSeed domain: ItemId brand, itemId(), netSellPrice(). Pure functions, no I/O.
packages/domain/src/index.test.tsnewCo-located unit test for the seed (P1 #3).
tests/monorepo/resolution.test.tsnewImports @gw2priory/domain by name, asserts on its exports (P1 #1, P1 #4, R8, SC3).
tests/monorepo/wiring.test.tsnewRepo-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.mdmodifiedFill 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):

ts
// @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.ts imports @gw2priory/domain by name and asserts itemId()/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 the workspace:* dep exists it fails with Cannot find package, which is the F3 guard.
  • Unit. packages/domain/src/index.test.ts covers P1 #3 (a co-located package test is discovered and run) and the seed's behaviour.
  • Repo-scan invariants. tests/monorepo/wiring.test.ts reads the filesystem and manifests to assert SC1 (no package emits dist/; each is private, exports→.ts, @gw2priory scope), SC4 (Biome present; zero ESLint/Prettier in the manifest/lockfile), SC5 (zero Turbo/Nx/Moon), R2/R3 (the tsconfig.base.json split exists and the root stays noEmit with packages/** 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 typecheck passes clean") and P2 #1–#2 are proven by executing pnpm typecheck and pnpm test at verification and observing exit 0 — running tsc inside Vitest would be slow and circular. The test-side floor for SC2 is the no-any scan 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 lint runs Biome over the tree) is likewise a step-5 command run, floored by wiring.test.ts asserting the lint script invokes Biome.
  • Non-regression. Spec 001's suite (tests/workflow/*) must stay green, and the count of files under any docs/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.md Out 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 and dist/ staleness for no gain, since nothing is published and Node runs TS directly. Project references return only with divergent app profiles (spec.md Out of scope).
  • ESLint + typescript-eslint — rejected: couples to the tsc API (TS7 compatibility risk, research.md V2), needs a formatter alongside it, and is heavier config than Biome's single file.
  • No Vitest config (rely on the default **/*.test.ts glob) — rejected: the spec calls for one explicit configuration discovering both trees; an explicit include is 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 --write pass 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 one tsc-dependent step (typecheck) was run and passed.
  • The Vitest include glob must grow when apps arrive. Accepted and documented: adding apps/**/*.test.ts is a one-line edit owned by each app's spec.

Open questions ​

  • Biome style specifics (semicolons) — resolved. The repo was split (.vitepress/config.mts omits semicolons, scripts/new-spec.ts uses 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.mts to gain semicolons in the formatting pass. biome.json is configured accordingly.
  • Nothing left open by research.md. Both [NEEDS VERIFICATION] markers are confirmed.