Skip to content

Tasks 007 — Recipe graph resolver ​

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. 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. (Format note: this file follows the project's tasks.md template — named-test TDD checklists — rather than the writing-plans skill's "full code inlined per step"; the constitution's template wins, and the code emerges through each task's RED/GREEN cycle in Step 4.)

F12 correction (2026-07-29): @gw2priory/recipe-graph is types-only; projectTree (T2) lives at apps/api/src/recipe-graph/project-tree.ts (+ test there), and the endpoint's Zod (T10/T11) is defined in apps/api/src/recipe-graph/recipe-graph.schema.ts — not the package (research.md F9, R11). Where tasks below say "package" for projectTree/Zod, use these apps/api paths.

Test-name convention: each test is named for the criterion it proves (e.g. "P1 #2 / SC2: shared item is one node, queried once"), so filling spec.md's traceability table (T12) is transcription, not excavation. Fixtures mirror the real ids in research.md.


T1 — The shared @gw2priory/recipe-graph package and its schema ​

Satisfies: R11 (shared package placement); establishes the R2/R3/R4/R6 type contract as Zod schemas + inferred types.

  • [ ] Create packages/recipe-graph/package.json mirroring packages/legendary-recipes (type: module, exports: { ".": "./src/index.ts" }, zod dependency, @gw2priory/recipe-graph name).
  • [ ] Verify the workspace picks it up (pnpm packages/* glob) and the @gw2priory/recipe-graph path alias resolves, mirroring legendary-recipes. Add the alias only if it isn't glob-covered.
  • [ ] RED: packages/recipe-graph/src/schema.test.ts — "SC9: a valid ResolvedGraph and TreeNode parse" and "schema rejects a negative count / unknown recipe source". Fails: schemas don't exist.
  • [ ] GREEN: packages/recipe-graph/src/schema.ts — RecipeEdgeSchema (discriminated union item/currency), RecipeOptionSchema, GraphNodeSchema, ResolvedGraphSchema (nodes: z.record(...)), TreeNodeSchema (z.lazy, named for OpenAPI $ref — plan Risks), each with z.infer types. src/index.ts re-exports schema (and, after T2, project-tree).
  • [ ] REFACTOR with tests green. Confirm teeth: flip a field optional, watch a reject test fail, restore.
  • [ ] Commit.

Verified by: packages/recipe-graph/src/schema.test.ts.


T2 — projectTree (pure DAG → nested display tree) ​

Satisfies: R10, P2 #1, P2 #2, P2 #3, SC6.

  • [ ] RED: packages/recipe-graph/src/project-tree.test.ts, on hand-built ResolvedGraph literals — "P2 #1: nests {node,count,recipe,children} from rootId", "P2 #2: a shared item is rendered at each usage and projection terminates", "P2 #3 / SC6: default chooser picks one recipe; a passed chooser overrides". Fails: no projectTree.
  • [ ] GREEN: packages/recipe-graph/src/project-tree.ts — projectTree(graph, chooseRecipe?) walks from graph.nodes[graph.rootId], chooseRecipe default = first recipe option (or null when none), recursing item edges into children with each edge's count. Pure, no I/O. Export via index.ts.
  • [ ] REFACTOR green. Confirm teeth: return children: [] unconditionally, watch P2 #1 fail, restore.
  • [ ] Commit.

Verified by: packages/recipe-graph/src/project-tree.test.ts.


T3 — Surface recipe type on 006's StationRecipe (enables the R12 filter) ​

Satisfies: prerequisite for R12 (the resolver needs type to filter LegendaryComponent).

  • [ ] RED: extend apps/api/src/static-data/station-data.service.test.ts — "StationRecipe surfaces the recipe type" (mock Gw2Service.recipes to return a recipe with type: 'LegendaryComponent'; assert the mapped StationRecipe.type equals it). Fails: no type.
  • [ ] GREEN: apps/api/src/static-data/station-data.service.ts — add type: string to StationRecipe and map r.type in getRecipes. Additive only.
  • [ ] REFACTOR green. Confirm teeth: drop the mapped field, watch the new test fail, restore.
  • [ ] Commit.

Verified by: apps/api/src/static-data/station-data.service.test.ts — "StationRecipe surfaces the recipe type".


T4 — Resolver base: CycleError, module, and the leaf / unknown-id base cases ​

Satisfies: R1 (module wiring), R8 (no throw on not-found), SC9 (plain Record on the wire).

  • [ ] RED: apps/api/src/recipe-graph/recipe-graph.service.test.ts (mocked 006 services all returning "nothing") — "R8: resolve(leaf id) returns a single-node graph, leaf true", "R8: resolve(unknown id) yields a null-metadata leaf, no throw", "SC9: graph JSON round-trips and nodes is a plain object". Fails: no service.
  • [ ] RED: apps/api/src/recipe-graph/recipe-graph.module.test.ts — "R1: RecipeGraphService resolves via DI from StaticDataModule".
  • [ ] GREEN: recipe-graph.errors.ts (CycleError extends Error); recipe-graph.service.ts (RecipeGraphService.resolve(itemId): Promise<ResolvedGraph> — for now: one node, recipes: [], returns a Record); recipe-graph.module.ts (imports StaticDataModule, provides+exports the service).
  • [ ] REFACTOR green. Confirm teeth: return a Map instead of a Record, watch SC9 fail, restore.
  • [ ] Commit.

Verified by: recipe-graph.service.test.ts (base cases), recipe-graph.module.test.ts.


T5 — The walk: recursion, curated ∪ station union, memoised dedup, order, propagation ​

Satisfies: R2 (each item once), R4 (union, all options kept), R5 (expand while any recipe), R6 (ingredient order), R9 (propagation), P1 #2/SC2, P1 #3/SC5, P1 #5/SC3, P1 #7 (structure).

  • [ ] RED: in recipe-graph.service.test.ts — "P1 #1: root carries the Bifrost forge recipe (precursor+Fortune+Mastery+weapon-gift)" (structure), "P1 #2 / SC2: a shared child is one node, each 006 service queried once per id" (call-counts), "P1 #3 / SC5: resolve(46741) expands the station recipe past a buyable item", "P1 #5 / SC3: resolve(Eternity) expands Sunrise+Twilight, shared sub-nodes single", "P1 #7: an output with two station recipes keeps both options", "R4: an id with both a curated and a station recipe keeps both sources", "R6: ingredient order matches the source", "R9: a rejecting 006 service makes resolve reject". Fail: resolver returns only base cases.
  • [ ] GREEN: implement the memoised DFS in recipe-graph.service.ts — resolved map + ancestor path set (path used in T6); union getRecipe (0..1) with getRecipes (0..N); recurse each item edge; map curated/station recipes to RecipeOption preserving ingredient order; leave metadata null (T8 enriches).
  • [ ] REFACTOR green. Confirm teeth: resolve a child twice (break memoisation), watch SC2's call-count fail, restore.
  • [ ] Commit.

Verified by: the named tests above in recipe-graph.service.test.ts.


T6 — Cycle guard ​

Satisfies: R7, SC8.

  • [ ] RED: "SC8: a cyclic dataset makes resolve throw CycleError naming the id" (mock curated so A→B→A). Fails (or hangs/overflows) without a guard.
  • [ ] GREEN: use the ancestor path set — throw CycleError(id) when an id recurs on its own path; distinct from the resolved set so legitimate DAG reuse is untouched (plan Risks).
  • [ ] REFACTOR green. Confirm teeth: guard on resolved instead of path, watch P1 #2/P1 #5 (shared nodes) break, restore.
  • [ ] Commit.

Verified by: recipe-graph.service.test.ts — "SC8: …CycleError…".


T7 — Filter LegendaryComponent recipes (precursors stay buyable leaves) ​

Satisfies: R12, P1 #4, SC4 (precursor branch).

  • [ ] RED: "P1 #4 / SC4: a precursor whose only recipe is LegendaryComponent resolves as a leaf" (mock StationDataService.getRecipes(precursor) → one recipe with type:'LegendaryComponent'; assert recipes: [], leaf: true). Fails: the recipe is currently kept.
  • [ ] GREEN: in the union step, drop station recipes whose type === 'LegendaryComponent'.
  • [ ] REFACTOR green. Confirm teeth: remove the filter, watch P1 #4 fail, restore.
  • [ ] Commit.

Verified by: recipe-graph.service.test.ts — "P1 #4 / SC4: …LegendaryComponent…leaf".


T8 — Enrichment: batched metadata + buyable/gated classification ​

Satisfies: R3, P1 #1/SC1 (classification half), P1 #6, SC4 (leaf-kind classification), P1 #7 (classification half — F8).

  • [ ] RED: in recipe-graph.service.test.ts — "P1 #6: nodes are enriched via one batched metadata call; omitted ids stay null" (assert ItemDataService.metadata called once with all distinct ids), "SC1: every leaf carries a classification; non-leaves carry ≥1 recipe", "SC4: a gated input classifies gated, a buyable leaf classifies buyable", "P1 #7 / F8: a gated-but-craftable node keeps its recipes and classifies gated". Fail: metadata null.
  • [ ] GREEN: after the walk, collect distinct ids, call ItemDataService.metadata(ids) once, and fill name/rarity/vendorValue + classification (via ItemDataService.classify); ids the API omits stay null (no throw).
  • [ ] REFACTOR green. Confirm teeth: call metadata per-node instead of once, watch P1 #6's call-count fail, restore.
  • [ ] Commit.

Verified by: the named enrichment tests in recipe-graph.service.test.ts.


T9 — Warm-cache: zero new GW2 calls on a repeat resolve ​

Satisfies: SC7.

  • [ ] RED: apps/api/src/recipe-graph/recipe-graph.service.warm-cache.test.ts — build the real StaticDataModule + Gw2Service on a fake fetch that counts calls; resolve a small fixture tree twice; "SC7: the second resolve issues 0 new GW2 fetches". Fails if the resolver bypasses the 005 cache or refetches.
  • [ ] GREEN: no new production code expected (the 005 no-TTL cache — research V2 — already guarantees it); if the test fails, the resolver is fetching something it shouldn't — fix that, don't weaken the test.
  • [ ] Confirm teeth: give prices (60s TTL) a role, or clear the cache between resolves — watch it fail.
  • [ ] Commit.

Verified by: recipe-graph.service.warm-cache.test.ts — "SC7: …0 new GW2 fetches".


T10 — HTTP endpoint: GET /recipe-graph/:itemId → the projected tree ​

Satisfies: R13, P3 #1, P3 #2, SC11; extends R1.

  • [ ] RED: apps/api/src/recipe-graph/recipe-graph.controller.test.ts (mocked RecipeGraphService) — "P3 #1 / SC11: GET returns 200 with projectTree(resolve(id))", "P3 #2: a non-integer param returns 400", "P3 #2: an unknown item (null root metadata) returns 404". Extend recipe-graph.module.test.ts with "R1: the controller resolves via DI". Fails: no controller.
  • [ ] GREEN: recipe-graph.schema.ts (RecipeTreeDto = createZodDto(TreeNodeSchema)); recipe-graph.controller.ts (@Controller('recipe-graph'), @Get(':itemId'), @ZodResponse({ status: 200, type: RecipeTreeDto }), param validated to a positive int → 400, 404 when the resolved root node's metadata is null, else projectTree(await svc.resolve(id))); declare the controller in recipe-graph.module.ts; register RecipeGraphModule in app.module.ts.
  • [ ] REFACTOR green. Confirm teeth: return the raw graph instead of projectTree, watch P3 #1 fail, restore.
  • [ ] Commit.

Verified by: recipe-graph.controller.test.ts, recipe-graph.module.test.ts.


T11 — OpenAPI: document the endpoint and regenerate openapi.json ​

Satisfies: P3 #3, SC12.

  • [ ] RED: extend apps/api/src/generate-openapi.test.ts — "SC12 / P3 #3: the OpenAPI doc has GET /recipe-graph/{itemId} with the tree response schema" (assert against buildOpenApiDocument()). Fails until the path is documented and the recursive tree schema emits a named $ref.
  • [ ] GREEN: ensure the TreeNodeSchema is a named component (plan Risks); regenerate the committed openapi.json via generate-openapi.cli.ts. The deterministic-emit guard must stay green (no diff on re-run).
  • [ ] Confirm teeth: hand-edit the doc's path away, watch SC12 fail, regenerate.
  • [ ] Commit (including the regenerated openapi.json).

Verified by: generate-openapi.test.ts — "SC12 / P3 #3: …GET /recipe-graph/{itemId}…".


T12 — Verify the whole and transcribe traceability ​

Satisfies: the definition of done; fills spec.md's traceability table (SC10 is covered by the existing docs/superpowers/ zero-write invariant — no new test).

  • [ ] Run the full suite + typecheck across the workspace; all green, no any, no escape hatches.
  • [ ] Run the app and hit GET /recipe-graph/30698; confirm a real projected Bifrost tree comes back (SC11 live) and openapi.json serves at /api-docs.
  • [ ] Fill spec.md's traceability table from the named tests above (each row → its test).
  • [ ] Commit. (The human reviews the diff; the implemented status is set by the human at Step 5/merge, inside the branch — not here.)

Verified by: green CI (lint + typecheck + test + build) and the transcribed traceability table.


Notes ​

Staging area for decisions and surprises found during implementation — including anything that turns out differently from what plan.md assumed. Move each into spec.md, research.md, or docs/ before closing the feature (the F6/F7/F8 graduation candidates in research.md are already queued for Step 6). This section is not a home.