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.jsonmirroringpackages/legendary-recipes(type: module,exports: { ".": "./src/index.ts" },zoddependency,@gw2priory/recipe-graphname). - [ ] Verify the workspace picks it up (pnpm
packages/*glob) and the@gw2priory/recipe-graphpath alias resolves, mirroringlegendary-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 withz.infertypes.src/index.tsre-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-builtResolvedGraphliterals —"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: noprojectTree. - [ ] GREEN:
packages/recipe-graph/src/project-tree.ts—projectTree(graph, chooseRecipe?)walks fromgraph.nodes[graph.rootId],chooseRecipedefault = first recipe option (ornullwhen none), recursing item edges intochildrenwith each edge'scount. Pure, no I/O. Export viaindex.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"(mockGw2Service.recipesto return a recipe withtype: 'LegendaryComponent'; assert the mappedStationRecipe.typeequals it). Fails: notype. - [ ] GREEN:
apps/api/src/static-data/station-data.service.ts— addtype: stringtoStationRecipeand mapr.typeingetRecipes. 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 aRecord);recipe-graph.module.ts(importsStaticDataModule, provides+exports the service). - [ ] REFACTOR green. Confirm teeth: return a
Mapinstead of aRecord, 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—resolvedmap + ancestorpathset (path used in T6); uniongetRecipe(0..1) withgetRecipes(0..N); recurse each item edge; map curated/station recipes toRecipeOptionpreserving ingredient order; leave metadatanull(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
pathset — throwCycleError(id)when an id recurs on its own path; distinct from theresolvedset so legitimate DAG reuse is untouched (plan Risks). - [ ] REFACTOR green. Confirm teeth: guard on
resolvedinstead ofpath, watchP1 #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"(mockStationDataService.getRecipes(precursor)→ one recipe withtype:'LegendaryComponent'; assertrecipes: [],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"(assertItemDataService.metadatacalled 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 fillname/rarity/vendorValue+classification(viaItemDataService.classify); ids the API omits staynull(no throw). - [ ] REFACTOR green. Confirm teeth: call
metadataper-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 realStaticDataModule+Gw2Serviceon a fakefetchthat 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(mockedRecipeGraphService) —"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". Extendrecipe-graph.module.test.tswith"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,404when the resolved root node's metadata isnull, elseprojectTree(await svc.resolve(id))); declare the controller inrecipe-graph.module.ts; registerRecipeGraphModuleinapp.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 againstbuildOpenApiDocument()). Fails until the path is documented and the recursive tree schema emits a named$ref. - [ ] GREEN: ensure the
TreeNodeSchemais a named component (plan Risks); regenerate the committedopenapi.jsonviagenerate-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 (SC11live) andopenapi.jsonserves 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
implementedstatus 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.