Skip to content

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.

LocationRole today
plugin/lib/services/docs/tests/site_roadmap.test.tsRoadmap-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#generateSiteFull-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#loadRegistryOpsThe 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#regenRoadmapPageWrites 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#buildRoadmapDataExported; 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#renderRoadmapPageExported; already imported by the test. Renders the bannered page string from buildRoadmapData’s output. Pure — no subprocess.

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.

  1. Rewrite the “two-pass idempotency” test to use the exported pure path instead of generateSite. Keep the leading rmSync(... M-ZZZZ-brandnew.md) so the corpus matches what the beforeAll pass 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)) via writeIfChanged): read site/src/content/docs/roadmap.md and expect(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.
  2. Drop the unsatisfiable generateSite plumbing from this test — the try/catch around the two generateSite calls, the first/second locals, the if (second) block, and expect(first).toBeDefined(). Remove the now unused generateSite import binding only if no other test in the file uses it (the rendering tests use GENERATED_BANNER_MARKER, which is a separate binding from the same module — keep it). tsc --noEmit / noUnusedLocals will flag an orphaned import if one remains.
  3. 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.
LocationKindChange
plugin/lib/services/docs/tests/site_roadmap.test.tsmodifyRewrite 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.
  • AC-1: bun test over plugin/lib/services/docs/tests/site_roadmap.test.ts passes 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 the bun run of site/scripts/list-ops.ts subprocess — it passes even when that subprocess crashes (i.e. its pass/fail is independent of the ops walk).
  • AC-3: A full bun test run shows the previously-failing site_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/main lists only plugin/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).
  • Making generateSite / loadRegistryOps swallow or tolerate an ops-walk subprocess failure — production sdlc docs generate --check is 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 when list-ops.ts runs 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.
  • none

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.

Captured by /sdlc:task-work on 2026-06-15. PR: pending.

  • 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 two renderRoadmapPage(buildRoadmapData(root)) derivations plus expect(onDisk).toBe(a) against the on-disk roadmap.md. No generateSite()-return assertion remains and nothing gates on the bun run of site/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-gated quality run reports OK 8/8 with zero new-drift.
  • AC-4: auto — the implementation commit (b475878f) touches only plugin/lib/services/docs/tests/site_roadmap.test.ts; no production source changed and idempotency is still asserted (deterministic re-render + on-disk byte equality).
  • The fix was the pure-path decouple the spec already pinned: regenRoadmapPage writes exactly renderRoadmapPage(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 test roadmap findings; Step 7’s --diff-against-baseline cleanly showed them resolved with zero new drift, so the gate needed no manual triage.
  • The project-check-task-state-origin pre-commit lint (sdlc task lint-state-origin) flagged the canonical in-flight verify-stamp commit (docs(tasks): verify <basename> implementation-ready — a state-only readiness_verified_at change ensure-ready lands on main via --commit-on main) as a “task-state-only commit on a task branch”, forcing --no-verify on every worktree commit for the whole in-flight window. Root cause: the lint’s base ref defaults to origin/main (plugin/lib/model/entities/task/ops/lint-state-origin.ts), but --commit-on main lands state commits on LOCAL main, which is ahead of origin/main until the PR merges — so the lint reads a main-lineage commit as branch-exclusive. The verify-stamp commit is purely state-only (only readiness_verified_at), so unlike the start-commit (multi-file since T-PA51’s site-regen) it can’t dodge isStateOnlyCommit. Fix: base the lint on local main (origin/main fallback only when main is absent), or exempt commits also reachable from local main. → 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 with baseline 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-baselines was passed explicitly. Fix: Step 7 (and the pre-PR dogfood block) should pass --baseline-dir pointing at the main checkout, or quality run should 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-mortem section drifts the per-task site page (site/src/content/docs/planning/tasks/<slug>.md), which both the project-check-docs-drift pre-commit hook and the docs generate --check quality gate then reject. The post-mortem commit must also regenerate + stage that page — the same site-regen start_task.ts already 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.

Dedup search routed all three friction bullets to existing trackers (no new PR spawned — a consolidated task would duplicate in-progress T-PA51):


← Back to Tasks