T-L9WL-roadmap-idempotency-test-decouples-from-the-bun-ops-walk
Status: closed/done · Impact: medium · Complexity: small
site_roadmap.test.ts’s “two-pass idempotency” case has failed on main since
it landed (commit e1cf596e, D-0010 P7). It asserts expect(first).toBeDefined()
on a generateSite() call that can never return inside the test’s copied temp
root: generateSite shells out to run site/scripts/list-ops.ts under bun,
and that subprocess panics there (Bus error, SIGTRAP — a Bun crash). The throw leaves
first/second undefined, so the assertion always fails AND the real
idempotency check (if (second) …) never runs. Decouple the roadmap-idempotency
assertion from the fragile whole-site ops walk so the suite goes green and the
test actually verifies what its name claims.
The roadmap page is a pure function of the milestone corpus
(renderRoadmapPage(buildRoadmapData(root))), and the full-site pass writes
exactly that string to disk via writeIfChanged. The idempotency test ignores
that pure path and instead routes through generateSite, which carries an
unrelated hard dependency on a bun subprocess that crashes in the temp root.
| Location | Role today |
|---|---|
plugin/lib/services/docs/tests/site_roadmap.test.ts | Roadmap-page test suite (13 tests). Its last case, “two-pass idempotency: a second run reports roadmap.md unchanged”, wraps two generateSite(root, false) calls in try/catch, guards the real check with if (second), then unconditionally asserts expect(first).toBeDefined(). In the temp root the first generateSite throws, so first is always undefined → the assertion always fails and the if (second) block never executes. The other 12 tests pass (they read the on-disk roadmap.md the beforeAll pass wrote before its throw). |
plugin/lib/services/docs/site.ts#generateSite | Full-site pass. Writes entity-derived pages (including the roadmap) first “so they survive even when the ops walk fails”, then calls the ops walk and re-throws on its failure — discarding the SiteResult. Correct for production (docs generate --check must fail loudly); fatal for a test that asserts on its return value in an environment where the ops walk can’t run. |
plugin/lib/services/docs/site.ts#loadRegistryOps | The ops walk: spawnSync runs bun run against the copied site/scripts/list-ops.ts. In the copied temp root the subprocess exits null (SIGTRAP / Bus error — a Bun panic), so loadRegistryOps throws list-ops.ts exited null, which propagates out of generateSite. |
plugin/lib/services/docs/site.ts#regenRoadmapPage | Writes roadmap.md as renderRoadmapPage(buildRoadmapData(projectRoot)) via writeIfChanged, and runs BEFORE the ops walk — so the on-disk artifact is the deterministic render and is present even when the later ops walk throws. |
plugin/lib/services/docs/site/roadmap.ts#buildRoadmapData | Exported; already imported by the test. Builds the roadmap row data from the live milestone corpus. Pure — no subprocess. |
plugin/lib/services/docs/site/roadmap.ts#renderRoadmapPage | Exported; already imported by the test. Renders the bannered page string from buildRoadmapData’s output. Pure — no subprocess. |
Proposed
Section titled “Proposed”The “two-pass idempotency” test passes deterministically with the live corpus
present, and proves roadmap idempotency without depending on generateSite
returning normally or on the bun list-ops.ts subprocess. It asserts the
property at the artifact level: the roadmap page is a deterministic function of
the corpus, and the on-disk roadmap.md the full pass wrote is byte-identical
to that render — so a second pass’s writeIfChanged would find no change and
report unchanged. The other 12 tests and the beforeAll are untouched. No
production source changes; the diff is confined to the test file.
Approach
Section titled “Approach”- Rewrite the “two-pass idempotency” test to use the exported pure path
instead of
generateSite. Keep the leadingrmSync(... M-ZZZZ-brandnew.md)so the corpus matches what thebeforeAllpass rendered to disk. Then:- Render twice and assert determinism:
const a = renderRoadmapPage(buildRoadmapData(root!)),const b = renderRoadmapPage(buildRoadmapData(root!)),expect(b).toBe(a). - Cross-check the on-disk artifact equals that render (the full pass wrote
exactly
renderRoadmapPage(buildRoadmapData(root))viawriteIfChanged): readsite/src/content/docs/roadmap.mdandexpect(onDisk).toBe(a). Together these establish “a second run reports roadmap.md unchanged”: re-deriving is stable, and the on-disk file already equals the stable render, so a re-pass changes nothing.
- Render twice and assert determinism:
- Drop the unsatisfiable
generateSiteplumbing from this test — thetry/catcharound the twogenerateSitecalls, thefirst/secondlocals, theif (second)block, andexpect(first).toBeDefined(). Remove the now unusedgenerateSiteimport binding only if no other test in the file uses it (the rendering tests useGENERATED_BANNER_MARKER, which is a separate binding from the same module — keep it).tsc --noEmit/noUnusedLocalswill flag an orphaned import if one remains. - Confirm the file still has 13 tests and the live-corpus
beforeAll(and its tolerated ops-walk throw) is unchanged — only the final test body and, if applicable, one import binding change.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
plugin/lib/services/docs/tests/site_roadmap.test.ts | modify | Rewrite the “two-pass idempotency” test to assert roadmap idempotency via the exported buildRoadmapData/renderRoadmapPage determinism plus an on-disk equality check; remove the generateSite-return assertions and the now-dead try/catch/if (second) plumbing; drop the generateSite import binding if it becomes unused. |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
bun testoverplugin/lib/services/docs/tests/site_roadmap.test.tspasses all 13 tests (0 fail) with the live milestone corpus present. - AC-2: The rewritten “two-pass idempotency” test contains no unguarded
assertion on a
generateSite()return value and does not gate on thebun runofsite/scripts/list-ops.tssubprocess — it passes even when that subprocess crashes (i.e. its pass/fail is independent of the ops walk). - AC-3: A full
bun testrun shows the previously-failingsite_roadmap.test.ts > … two-pass idempotency …case now passing — the suite’s failure count drops by exactly this one (no new failures introduced). - AC-4:
git diff --name-only origin/mainlists onlyplugin/lib/services/docs/tests/site_roadmap.test.ts— no production source changed — and the test still asserts idempotency (a re-derived roadmap page is byte-identical and matches the on-disk generated artifact).
Out of scope
Section titled “Out of scope”- Making
generateSite/loadRegistryOpsswallow or tolerate an ops-walk subprocess failure — productionsdlc docs generate --checkis a quality gate and MUST fail loudly when the ops walk fails. This task changes test code only. - Fixing the upstream Bun
Bus error/ SIGTRAP panic whenlist-ops.tsruns in a copied temp root — that is a Bun crash, not our code. - Making the temp-root fixture self-sufficient for the full ops walk (copying
tsconfig.json/node_modules/bunfig.toml) — heavier, and would not stop a Bun panic anyway. - The other 12 tests in
site_roadmap.test.ts(bucketing, rendering, empty buckets, live-corpus placement) — already green; left untouched.
Dependencies
Section titled “Dependencies”- none
Discovery context
Section titled “Discovery context”Surfaced during the find-worktree-init work (PR #445): a full bun test flagged
site_roadmap.test.ts > … two-pass idempotency … as failing, and it reproduces
identically on a clean main. git log -S shows the case was introduced
unmodified in commit e1cf596e (“feat(site): roadmap page generated from
milestone entities (D-0010 P7)”, 2026-06-07) — it has never passed. Reproduced
the throw directly: bun run of site/scripts/list-ops.ts inside a copied temp
root exits with signal: SIGTRAP and a panic(main thread): Bus error Bun crash
report, so generateSite’s ops walk throws list-ops.ts exited null and the
test’s expect(first).toBeDefined() can never hold there.
Post-mortem
Section titled “Post-mortem”Captured by /sdlc:task-work on 2026-06-15. PR: pending.
Acceptance criteria coverage
Section titled “Acceptance criteria coverage”- AC-1: auto —
bun test plugin/lib/services/docs/tests/site_roadmap.test.ts→ 13 pass / 0 fail with the live milestone corpus present. - AC-2: agent-manual — read the rewritten test: it asserts
expect(b).toBe(a)over tworenderRoadmapPage(buildRoadmapData(root))derivations plusexpect(onDisk).toBe(a)against the on-diskroadmap.md. NogenerateSite()-return assertion remains and nothing gates on thebun runofsite/scripts/list-ops.ts, so the test’s verdict is independent of the ops walk. - AC-3: auto — full
bun test→ 1409 pass / 0 fail (the previously-failing two-pass idempotency case is now green, no new failures); the baseline-gatedquality runreportsOK 8/8with zeronew-drift. - AC-4: auto — the implementation commit (
b475878f) touches onlyplugin/lib/services/docs/tests/site_roadmap.test.ts; no production source changed and idempotency is still asserted (deterministic re-render + on-disk byte equality).
What worked
Section titled “What worked”- The fix was the pure-path decouple the spec already pinned:
regenRoadmapPagewrites exactlyrenderRoadmapPage(buildRoadmapData(root)), both functions exported and already imported by the test, so the rewrite needed no new exports and no production change. - Step 3a’s baseline recorded the 10 pre-existing
bun testroadmap findings; Step 7’s--diff-against-baselinecleanly showed them resolved with zero new drift, so the gate needed no manual triage.
Friction and automation gaps
Section titled “Friction and automation gaps”- The
project-check-task-state-originpre-commit lint (sdlc task lint-state-origin) flagged the canonical in-flight verify-stamp commit (docs(tasks): verify <basename> implementation-ready— a state-onlyreadiness_verified_atchange ensure-ready lands onmainvia--commit-on main) as a “task-state-only commit on a task branch”, forcing--no-verifyon every worktree commit for the whole in-flight window. Root cause: the lint’s base ref defaults toorigin/main(plugin/lib/model/entities/task/ops/lint-state-origin.ts), but--commit-on mainlands state commits on LOCALmain, which is ahead oforigin/mainuntil the PR merges — so the lint reads a main-lineage commit as branch-exclusive. The verify-stamp commit is purely state-only (onlyreadiness_verified_at), so unlike the start-commit (multi-file since T-PA51’s site-regen) it can’t dodgeisStateOnlyCommit. Fix: base the lint on localmain(origin/main fallback only whenmainis absent), or exempt commits also reachable from localmain. → Upstream-plugin (sdlc-meta); tracked by T-PA51-task-state-commits-regen-site-page (making the verify commit regen+stage its site page makes it multi-file, so the lint no longer classifies it state-only). - task-work Step 7’s quality-gate example omits
--baseline-dir, so a gate run from the worktree resolves the default to<worktree>/.sdlc/quality-baselines/and fails withbaseline not found— because Step 3a necessarily captures the baseline pre-worktree from the main checkout (<main-repo>/.sdlc/quality-baselines/). The run only succeeded once--baseline-dir <main-repo>/.sdlc/quality-baselineswas passed explicitly. Fix: Step 7 (and the pre-PR dogfood block) should pass--baseline-dirpointing at the main checkout, orquality runshould resolve the baseline against the worktree’s superproject. → Upstream-plugin (sdlc-meta); tracked by T-5X6Y-task-work-step7-explicit-baseline-dir (closed/superseded, superseded by T-44OO-plugin-scripts-self-discover-project-root). - task-work Step 8 instructs “stage only the task file”, but appending the
## Post-mortemsection drifts the per-task site page (site/src/content/docs/planning/tasks/<slug>.md), which both theproject-check-docs-driftpre-commit hook and thedocs generate --checkquality gate then reject. The post-mortem commit must also regenerate + stage that page — the same site-regenstart_task.tsalready bundles for the start-commit (T-PA51). Fix: Step 8 should regen+stage the per-task site page (reuse start_task’s regen helper) rather than staging the task file alone. → Upstream-plugin (sdlc-meta); tracked by T-PA51-task-state-commits-regen-site-page — the post-mortem commit is a 5th commit point (a body commit, not in T-PA51’s current 4-state-commit list) worth folding into its scope.
Linked follow-up tasks
Section titled “Linked follow-up tasks”Dedup search routed all three friction bullets to existing trackers (no new PR spawned — a consolidated task would duplicate in-progress T-PA51):
- T-PA51-task-state-commits-regen-site-page (in-progress, PR #443) — gaps #1
and #3: regenerating + staging the derived site page on each task-state/body
commit makes the verify-stamp commit multi-file (so
project-check-task-state-originno longer flags it) and closes the post-mortem-commit drift. The Step 8 post-mortem commit is an additional commit point worth folding into its scope. - T-5X6Y-task-work-step7-explicit-baseline-dir (closed/superseded) — gap #2, superseded by T-44OO-plugin-scripts-self-discover-project-root (planning/needs-definition), which would resolve the baseline against the main checkout automatically.