Skip to content

Spec 002 — Monorepo wiring ​

Status: implemented Branch: 002-monorepo-wiring

Status is set by the human, never by the agent. It moves draft → approved → implemented.

Problem ​

pnpm-workspace.yaml declares apps/* and packages/*, but neither directory exists — the globs point at nothing. There is no way to add shared code and have it typechecked, linted, and tested alongside the rest of the repo, because the root only knows about scripts/ and tests/. Before any app or domain package can be built, the monorepo needs a spine: a place shared packages live, a single way to typecheck the whole tree, a single way to run every test, and a linter. This spec builds that spine and nothing else — it is the workbench, not the product.

User stories ​

Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.

P1 — A shared package is consumable across the workspace, as source, with no build step ​

As the developer, I want to add a package under packages/ and import it by name from anywhere in the monorepo — with no build, no dist/, and no publish step — so that sharing code costs nothing and a package stays plain TypeScript source, exactly the way scripts/*.ts already run under Node's type stripping.

Independent test: create packages/domain exporting a value as @gw2priory/domain, import it by name from a file in the root tests/, and run pnpm typecheck and pnpm test; both see the package, the import resolves, and no dist/ is produced.

Acceptance scenarios

  1. Given a package packages/domain whose package.json exports maps its entry to ./src/index.ts, when a file elsewhere in the workspace imports @gw2priory/domain by name, then the import resolves to the package's TypeScript source with no build step in between.
  2. Given that package, when pnpm typecheck runs, then the package's source is typechecked under the shared config and no emit (dist/ or .js) is produced.
  3. Given a co-located test packages/domain/src/index.test.ts, when pnpm test runs from the root, then that test is discovered and executed.
  4. Given a test in the root tests/ that imports @gw2priory/domain by name and asserts on it, when pnpm test runs, then the import resolves across the workspace and the test passes.

P2 — Each cross-cutting check is one root command over the whole tree ​

As the developer, I want a single command each for typecheck, test, and lint that covers every workspace at once, so that I — and later CI — verify the repo with a handful of commands rather than one per package.

Independent test: with at least one package present, pnpm typecheck, pnpm test, and pnpm lint each run once at the root and each covers packages/** together with the root scripts/ and tests/.

Acceptance scenarios

  1. Given the root scripts, when pnpm typecheck runs, then it typechecks the whole tree (root plus every package) under one config in a single invocation, and passes clean.
  2. Given the root scripts, when pnpm test runs, then one Vitest configuration discovers and runs the tests under both packages/** and the root tests/ in a single invocation.
  3. Given the root scripts, when pnpm lint runs, then Biome checks the whole tree in a single invocation, and a format script applies Biome's formatting.

Requirements ​

  • R1 — Packages under packages/* are consumed as TypeScript source: each package's package.json resolves its public entry through exports to a .ts file, emits no dist/, and is marked private: true (never published). Confirmed — research.md V1: a package whose exports points at raw .ts resolves via the pnpm symlink under both tsc (TypeScript 7, nodenext) and Vitest 4 with no build step, provided the consumer declares the package workspace:* (F3). The exports-conditions fallback was not needed.
  • R2 — Shared compiler options live in a single tsconfig.base.json; the root tsconfig.json extends it, and its include covers packages/** alongside the existing scripts/ and tests/, while remaining noEmit.
  • R3 — There is exactly one typecheck entry point: a root typecheck script running tsc --noEmit once over the whole tree. No per-package typecheck scripts and no TypeScript project references.
  • R4 — There is exactly one test entry point: a root test script whose single Vitest configuration discovers tests under packages/** and the root tests/.
  • R5 — Lint and format are provided by Biome through a single biome.json; root lint and format scripts run it over the whole tree. Biome is the only linter/formatter — no ESLint, no Prettier. Confirmed — research.md V2: Biome 2.5.5 runs cleanly on Node ≥22.18 from one config and is a bundled Rust binary independent of the tsc API, so TypeScript 7 poses no compatibility risk.
  • R6 — Task orchestration uses plain pnpm only (pnpm -r / --filter). No Turborepo, Nx, Moon, or other build/cache orchestration tool is introduced.
  • R7 — A seed package @gw2priory/domain exists under packages/domain with at least one exported type and one pure function, plus a co-located test — a real shared-code seed, not a throwaway fixture.
  • R8 — A test in the root tests/ imports @gw2priory/domain by name and asserts on it, so cross-workspace resolution is exercised end to end and not merely assumed.
  • R9 — The package scope is @gw2priory/*.
  • R10 — spec 001's conformance suite continues to pass unchanged, and no artifact is written under any docs/superpowers/ path.

Mark anything unresolved inline rather than assuming an answer. Two markers, split by who can answer:

  • [NEEDS CLARIFICATION: specific question] — only the human can answer. A product decision, a scope boundary, a preference. Blocks step 1.5.
  • [NEEDS VERIFICATION: specific question] — only reality can answer. Whether the codebase works that way, whether an endpoint returns that field, whether that number is achievable. Answered in research.md with cited evidence, never by assumption. Blocks the approval gate.

Any success criterion stating a number carries a [NEEDS VERIFICATION] until a measurement in research.md backs it. An unbacked number is a guess wearing a criterion's clothes.

Success criteria ​

Measurable and technology-agnostic — outcomes, not implementation.

  • SC1 — A package placed under packages/ is importable by its @gw2priory/* name from anywhere in the workspace with no build step; across all packages, the count of emitted output directories (dist/ or built .js) is zero.
  • SC2 — pnpm typecheck typechecks the entire tree — root plus every package — in a single invocation and passes clean, with no any and no unexplained escape hatches added.
  • SC3 — pnpm test discovers and runs tests under both packages/** and the root tests/ in a single invocation, and the cross-workspace import test passes.
  • SC4 — pnpm lint runs Biome over the whole tree in a single invocation; the count of other linters or formatters (ESLint, Prettier) in the dependency tree is zero.
  • SC5 — The count of build or cache orchestration tools (Turborepo, Nx, Moon, and the like) in the dependency tree is zero.
  • SC6 — spec 001's suite still passes, and the count of files under any docs/superpowers/ path stays zero.
  • SC7 — Every acceptance scenario and success criterion above maps to a named automated test, and the suite passes.

Out of scope ​

  • Any build-cache / task orchestration tool. Turborepo is the intended later upgrade — a drop-in wrapper over these same pnpm scripts — but only once build or CI time earns it. Deferred to its own spec, mirroring spec 001's "no Redis until the caching story earns it".
  • App scaffolds. apps/api (NestJS) and apps/web (React) are not created here; each gets its own spec. This spec only makes the apps/* glob safe to fill later.
  • CI wiring (GitHub Actions running lint/typecheck/test). Still its own spec, per spec 001's out of scope. This spec only makes the commands those workflows will call exist and pass locally.
  • TypeScript project references and compiled package boundaries. Not needed while every workspace member shares one compiler profile (Node — no DOM, no JSX, no decorators). They return with the apps: apps/web (React — lib: DOM, jsx, bundler resolution) and apps/api (NestJS — experimentalDecorators + emitDecoratorMetadata) require compiler options that cannot coexist in a single compilerOptions, so each will carry its own tsconfig.json extending tsconfig.base.json, and the root typecheck graduates from one tsc --noEmit to a per-project run (project references + tsc -b, or pnpm -r typecheck). Divergent app profiles — not typecheck speed — are the real trigger, and it is the app specs' work, not this one's.
  • Any GW2 domain modelling beyond the minimal seed of R7. The domain graph, pricing, and optimizer are later specs; packages/domain here holds just enough to prove the wiring.
  • Publishing any package outside this repo. Every package is private.

Assumptions ​

This is a workbench spec: like spec 001, it names its tooling because choosing that tooling is the point, not an implementation leak. The following are fixed by prior decisions in the architecture docs and are taken as given here.

  • pnpm workspaces is the monorepo mechanism, with apps/* and packages/* already globbed in pnpm-workspace.yaml (docs/architecture/stack.md).
  • Node ≥22.18 runs TypeScript directly under unflagged type stripping, and tsconfig.json is noEmit — "TypeScript typechecks, Node executes" (docs/architecture/workflow-tooling.md). Source packages extend this exact model to packages/* rather than introducing a new one.
  • TypeScript 7 (^7.0.2), Vitest 4, and pnpm 11 are the pinned toolchain (root package.json). The two claims that depended on how these behave together were the verification questions on R1 and R5, both confirmed in research.md (V1, V2) before approval and resolved in place above.
  • No claim here rests on a measured number, so the only open questions were those two behavioural verifications; there were no [NEEDS CLARIFICATION] questions for the human.
  • The single root config and single tsc --noEmit (R2, R3) are correct because 002's scope is packages only, and every member — scripts/, tests/, packages/* — shares one Node profile. This is not a claim that one config serves the whole repo forever: when apps with divergent profiles arrive (each out of scope here, per "Out of scope"), they extend the same base with their own overrides. tsconfig.base.json therefore holds only environment-agnostic rules (strictness, syntax); the environment-specific keys (lib, jsx, module/moduleResolution, types) live in each profile's own config, starting with the root Node config today. This keeps the first app spec a clean "add apps/<name>/tsconfig.json extending base" with no base refactor.

Traceability ​

Each acceptance scenario and success criterion maps to a named test. Where a criterion's live proof is a command run at step 5 (pnpm typecheck / test / lint), the named test is the automated floor that guards it; the command run and the human's diff review are the rest, per the Test strategy in plan.md. SC7 (below) asserts this table has no empty cell and that every test it names exists.

CriterionTest
P1 #1P1 #1/#4, R8, SC3: @gw2priory/domain resolves by name across the workspace
P1 #2SC1/R1/R7/R9: packages are private, source-only via exports, under @gw2priory, and emit no dist
P1 #3P1 #3: netSellPrice takes the 15% TP tax off
P1 #4P1 #1/#4, R8, SC3: @gw2priory/domain resolves by name across the workspace
P2 #1R2/R3: tsconfig base split, root extends and stays noEmit with packages included
P2 #2P2 #2/R4: one Vitest config discovers packages and tests
P2 #3SC4/R5: Biome is the only linter and formatter
SC1SC1/R1/R7/R9: packages are private, source-only via exports, under @gw2priory, and emit no dist
SC2SC2: no any in the source this spec adds
SC3P1 #1/#4, R8, SC3: @gw2priory/domain resolves by name across the workspace
SC4SC4/R5: Biome is the only linter and formatter
SC5SC5/R6: no build or cache orchestration tool is present
SC6SC3: no workflow artifact is written outside specs/NNN-<slug>/
SC7SC7: the spec 002 traceability table is complete and its tests exist