Files
msd-core/tests/drift-detection.test.cjs
Tom Boucher 067a4d1c6c fix(#2650): bound and auto-recover plan-phase planner/plan-checker stalls (#3015)
* test(#2650): add failing-first regression for plan-phase stall detection

Regression test for gsd_stall_should_recover / gsd_stall_watch and the
planner.stall_* config keys, none of which exist yet — proves RED before
the fix lands in the next commit.

* fix(#2650): bound and auto-recover plan-phase planner/plan-checker stalls

Mirrors the already-shipped executor.stall_* pattern (execute-phase.md, bug
#3212) but with a dispatch change the executor's prose-only surveillance
lacks: the standard planner spawn, chunked-outline planner spawn,
chunked-per-plan planner spawn, plan-checker spawn, and revision-loop
planner respawn now dispatch with run_in_background=true and are followed
by a real, bounded bash poll (gsd_stall_watch) that returns control to the
orchestrator on its own schedule instead of waiting indefinitely on a
subagent that may never return. On stall, the existing accept-plans/retry/
stop recovery menu (9a/11a) is auto-surfaced instead of requiring a manual
interrupt.

New config keys planner.stall_detect_interval_minutes (default 5) /
planner.stall_threshold_minutes (default 10) mirror executor.stall_*.

The helper functions (gsd_stall_should_recover, gsd_stall_watch) live in a
new lazily-loaded gsd-core/workflows/plan-phase/steps/stall-detection-
helpers.md rather than inline, and per-site prose is kept minimal, because
plan-phase.md is frozen under the ADR-857 Phase 6 PRE_PHASE6 gate
(tests/phase6-capstone-conformance.test.cjs) with ~36 bytes of headroom at
baseline; the net effect is plan-phase.md.md ships slightly SMALLER than
before (the old unconditional-wait ORCHESTRATOR RULE sentences are gone at
the five touched sites, superseded by the bounded watcher).

Also fixes a stale doc comment in tests/workflow-size-budget.test.cjs that
still described the per-file workflow-size-baseline.json guard removed by
#2724 (ADR-2719 Phase 4) as if it were still the enforcement mechanism —
discovered while verifying this fix's own byte budget.

Researcher and pattern-mapper spawns are untouched (out of scope per the
issue's Agent Brief).

* fix(#2650): make gsd_stall_watch single-cycle; harden numeric config inputs

Two review findings addressed on top of the prior commit:

1. gsd_stall_watch previously looped internally for the full
   threshold+interval duration inside ONE Bash tool call (up to 15 min at
   defaults) — a single call blocking that long risks the host tool's own
   timeout killing it before it ever prints a result, silently defeating the
   fix. Redesigned to a single sleep-and-check cycle per call, taking an
   explicit dispatch_ts so the orchestrator prose can repeat the (short,
   default 5 min) call until it resolves; the outer threshold is now
   enforced by dispatch_ts accumulating across calls, not by one call's
   duration. Documented the resulting trade-off (up to one interval of
   added latency on the success path) in the changeset and reference doc.

2. PLANNER_STALL_INTERVAL_MINUTES/THRESHOLD_MINUTES are config-controlled
   values that flow into bash arithmetic ($(( ))). A review flagged this as
   command injection; empirically verified against both macOS bash 3.2.57
   and Docker bash:5 that this is NOT actually exploitable (bash hard-errors
   on a `$(cmd)`-shaped arithmetic operand rather than invoking it) — but an
   unvalidated malformed value WOULD abort the stall-watcher itself with
   that bash error, silently defeating the exact hang-recovery this issue
   ships. Added integer validation with safe-default fallback, both at the
   config-resolution point and defensively inside gsd_stall_should_recover.

Also adds the previously-missing integration coverage for gsd_stall_watch's
real execution (grep/find/date plumbing), not just the pure classifier.

* fix(#2650): correct AC2 self-test — helpers doc may name teams-status in prose

The AC2 regression test asserted the stall-detection-helpers.md step file
never contains the substring "teams-status" at all, but the file's own
prose explicitly documents its independence from that guard (containing
the word by design). Narrowed the assertion to what actually matters: no
second `query teams-status` call site and no gating on it, not a blanket
absence of the word.

* test(#2650): regenerate golden install-tree fixtures for the new step file

gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md is an
emitted file (installed for every runtime), so adding it changes the
install tree even though it is invisible to docs/INVENTORY.md and
docs/INVENTORY-MANIFEST.json (both explicitly scope to non-recursive
gsd-core/workflows/*.md — verified against the execute-phase #2930 and
pre-existing plan-phase step-file precedent, which are equally absent from
both inventory artifacts). The golden install tree snapshots the sorted
list of emitted relative paths per runtime, so a file invisible to the
inventory is still visible here. Regenerated via `npm run gen:install-tree`
— one line added per runtime fixture (19 files), no other drift.

* fix(#2650): restore 7 ORCHESTRATOR RULE labels; sync runtime-launcher preamble

Two more consequences of extracting helper bodies out of plan-phase.md,
both caught by verification (0017e1a78, 9 unique failures):

1. tests/plan-phase-drift-guard.test.cjs (#913) requires at least 7
   "ORCHESTRATOR RULE — ALL RUNTIMES" labels in plan-phase.md itself, one
   per agent spawn site. Moving the full explanatory blocks to
   plan-phase/steps/stall-detection-helpers.md carried 5 of the 7 labels
   out with them (only the untouched researcher/pattern-mapper sites kept
   theirs). Restored a short label at each of the 5 stall-watch sites,
   trimmed a few more redundant words ("Per 7.99, " — already established
   by the adjacent step-7.99 pointer) to stay under the frozen
   PRE_PHASE6 cap (94497 bytes, 21 bytes headroom).

2. tests/runtime-launcher-parity.test.cjs (#373) requires exactly one
   canonical gsd_run preamble, byte-equal to
   gsd-core/workflows/_runtime-launcher.snippet.sh, before the first
   gsd_run call in any workflow .md that calls it (recursive scan under
   gsd-core/workflows/, unlike the non-recursive inventory/step-tag-balance
   checks). The new step file's config-get calls use gsd_run without one.
   Fixed via `node scripts/sync-runtime-launcher.cjs`, verified: exactly 1
   preamble occurrence, before the first call, including the .claude/ and
   .codex/ home fallback arms.

Also verified (no fix needed, evidence recorded): the generic
`gsd-core-verbatim` identity rule in tests/helpers/emitted-provenance.cjs
(roots: ['gsd-core'], pattern matching workflows/.+) self-attributes any
new gsd-core/workflows/** path to itself, so the new step file needs no
drift-ack entry — consistent with plan-phase.md's own net shrinkage
requiring none either.

* test(#2650): acknowledge plan-phase.md's +14 byte drift

Restoring the 5 ORCHESTRATOR RULE — ALL RUNTIMES labels (#913) flipped
plan-phase.md from -142 bytes (post-extraction) to +14 bytes net growth
against baseline (94483 -> 94497), which the differential attribution
size ratchet (tests/emitted-attribution.test.cjs) correctly flags as
unacknowledged growth. Added tests/emitted-drift-acks/2650-plan-phase-
stall-detection.json, keyed on the bare filename plan-phase.md per the
existing fragment schema (see tests/emitted-drift-acks/2649-diagnose-
execute-plan-base-check.json), explaining the growth as exactly the 5
restored labels — still verified under the PRE_PHASE6 cap (94497 < 94519)
and satisfying #913's 7-label requirement.

* fix(#2650): bind {outputFile} from the real Agent() return — was dead code

Independent review blocker: PLANNER_OUTPUT_FILE/CHECKER_OUTPUT_FILE were
read by every gsd_stall_watch call but never assigned anywhere in the
diff. With the variable permanently empty, `[ -f "$output_file" ]` was
always false, marker_found could never become true, and marker_received
was unreachable — the marker-based detection path was permanently dead.

Worse for the plan-checker spawn specifically: a checker that PASSES
touches no *-PLAN.md files, so it had no working completion signal at
all without the marker path. A healthy plan-checker finishing cleanly in
two minutes would be declared stalled once planner.stall_threshold_minutes
elapsed and the recovery menu would fire on an already-succeeded agent —
worse than the original unbounded hang.

Fixed by replacing the dead bash variable with the `{outputFile}`
orchestrator-substitution token, the same convention docs-update.md:471
already uses for a real run_in_background=true Agent() return ("Read
tool: file_path: `{outputFile from README agent result}`"). This is a
net BYTE SAVING at each site (`"{outputFile}"` is shorter than
`"$PLANNER_OUTPUT_FILE"`), which funded moving the full binding
explanation — including why plan-checker's *-PLAN.md glob alone is not
a working completion signal — into the lazily-loaded reference file to
stay under the frozen PRE_PHASE6 cap (94496 bytes, 22 headroom; net +13
over baseline, acknowledged in tests/emitted-drift-acks/2650-plan-phase-
stall-detection.json).

Added a regression test asserting plan-phase.md itself binds {outputFile}
at all 5 spawn sites and contains no dangling $PLANNER_OUTPUT_FILE /
$CHECKER_OUTPUT_FILE reference — the previous test suite only exercised
gsd_stall_watch's behavior when handed a valid argument, which is why
the dead production wiring survived two rounds of review. Also fixed
tests/fix-2650-plan-phase-stall-detection.test.cjs:170-195's raw
try/finally to use t.after(), per CONTRIBUTING's test-cleanup convention.

* chore(#2650): backfill changeset PR number to 3015

* fix: normalize CRLF at the read boundary in all .md-bash-extraction tests

Maintainer-authorized scope expansion, folded into this PR rather than
deferred: the Windows CI lane on this PR's own tests/fix-2650-plan-phase-
stall-detection.test.cjs exposed DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE
(CONTEXT.md; recurring since #1700) as a repo-wide latent class, not a
one-off. Ten test files parse a fenced ```bash block out of a workflow
.md file and execute it via spawnSync/execFileSync; a Windows checkout
can yield CRLF line endings despite .gitattributes eol=lf, and bash then
treats the trailing \r on every extracted line as part of the token —
"unexpected EOF while looking for matching `"'" or a bare syntax error,
partway through the script.

Added tests/helpers.cjs:readFileNormalized() — strips \r\n -> \n at the
read boundary, before any fence-slicing or regex runs, so every
downstream operation is correct by construction. Migrated all ten call
sites to it:

Previously broken (fs.readFileSync with no normalization anywhere
between read and spawn):
- tests/worktree-cleanup.test.cjs (extractCwdGuardBash) — also fixes a
  misleading comment claiming the fence regex alone was "CRLF-safe"; it
  protected only the fence delimiters, never the captured body.
- tests/new-milestone-clear-phases.test.cjs (extractFenceBetween,
  extractFenceContaining)
- tests/code-review-pipeline-regression.test.cjs (extractPostProcessingScript)
- tests/drift-detection.test.cjs (readGate/bashBlock, plus the snippet-file
  comparison read in the same test)
- tests/graphify-visualization.test.cjs (extractStep3Block)
- tests/pause-work-improvements.test.cjs (extractCheckBlock)
- tests/plan-review-convergence.test.cjs (extractReviewerFlagsParseBlock
  and the inline post-config-gate resolution-block slices)

Already correct (split(/\r?\n/) then join('\n')), migrated to the shared
helper for consistency rather than a fourth/fifth/sixth copy of the same
fix:
- tests/git-base-branch.test.cjs (extractHandleBranchingBash)
- tests/quick-branching.test.cjs (extractStep25Bash)
- tests/runtime-launcher-parity.test.cjs (extractResolverSnippet)

Verified against a simulated Windows CRLF checkout (not assumed): for
both the worktree-cleanup.test.cjs and new-milestone-clear-phases.test.cjs
extraction shapes, confirmed the pre-fix code produces a real bash syntax
error on CRLF input and the post-fix code does not.

One eslint follow-up: local/no-crlf-fragile-split statically flags any
bare `\n` inside a markdown-fence-shaped regex, regardless of whether the
receiver was already normalized — it cannot see the readFileNormalized()
data-flow. Kept `\r?\n` in extractCwdGuardBash's fence regex (redundant
but harmless on pre-normalized input) rather than fight the rule.

Scope note: this diff is broader than issue #2650's own change (plan-
phase.md stall detection) because the Windows lane surfaced a genuine
repo-wide defect class while verifying that fix, and the maintainer
authorized fixing it here rather than filing it separately and shipping
a known-broken pattern.

Runtime impact: none — this is a test-harness-only defect. The live
orchestrator (Claude Code or another runtime) does not do a byte-exact
extract-and-pipe of .md content into a shell the way these tests do; it
reads the instructions and generates its own bash invocation text, which
does not reproduce a raw CRLF pass-through the same way.

Not touched: tests/plan-review-convergence.test.cjs's separate, tracked
spawnSync ETIMEDOUT flake under bench load (#3005, reproduced on
unmodified next) — unrelated load-sensitivity, not a CRLF symptom.

* fix(#2650): remove stale drift-ack fragment — plan-phase.md is self-explaining

tests/emitted-drift-acks/2650-plan-phase-stall-detection.json acknowledged
plan-phase.md's own emitted-path hash move, but plan-phase.md is directly
edited in this diff. Per the emitted-attribution law (ADR-2719,
tests/emitted-attribution.test.cjs), a workflow's emitted key equals its
own source path (gsd-core-verbatim identity rule), so a direct edit to the
source is self-explaining and auto-attributed — no ack was ever needed.

Verified via the pre-merge lint (scripts/lint-emitted-drift-ack.cjs, run
through npm run lint:ci with a fully cleared eslint cache): it passes clean
with the fragment removed, confirming no contradiction between the lint and
the runtime attribution gate — this was simply an unnecessary fragment.

* fix(#2650): restore plan-phase.md drift-ack — size ratchet demands it against next

tests/emitted-drift-acks/2650-plan-phase-stall-detection.json was deleted in
the previous commit because, against an earlier verification base, it was
inert: it explained a moved emitted hash that a direct edit to plan-phase.md
already self-attributes. Against origin/next@f1af47766a the demand is
different: plan-phase.md is 13 bytes larger than the base copy, which trips
the emitted-attribution size ratchet — a job this same ack also performs.

Recreated in the documented shape, keyed on the bare filename plan-phase.md
(not the full path, and not restating the byte delta per review guidance),
describing the actual change: the {outputFile} binding fix for the dead
PLANNER_OUTPUT_FILE/CHECKER_OUTPUT_FILE variables and the 5 restored
ORCHESTRATOR RULE labels required by #913, both at the stall-watch spawn
sites, with explanatory bodies living in the lazily-loaded
gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md reference.

Confirmed no other fragment (on this branch or on next) claims the bare key
"plan-phase.md" before recreating — scripts/lint-emitted-drift-ack.cjs's
duplicate check is an exact string match, and the only other mention of
plan-phase.md in tests/emitted-drift-acks/ (2658-trae-instruction-file-path.json)
uses the full path as its key, so there is no collision.

* fix(#2650): real cause of Windows CI failure — bash -c argv-transport, not CRLF

The CRLF diagnosis for PR #3015's Windows failure was wrong. Proven wrong,
not assumed: .gitattributes' blanket `* text=auto eol=lf` means a Windows
checkout never receives CRLF for stall-detection-helpers.md, and the
extracted fence's line 64 is byte-identical and correctly balanced on every
platform. The real cause: runShouldRecover() passed a 70+ line, quote-dense
script as ONE argv element to `spawnSync('bash', ['-c', script, arg0, ...])`
PLUS four more positional args. Windows has no execve — Node serializes
that whole argv into a single CreateProcess command-line string, and Git
Bash's MSYS layer re-splits and unescapes it with its own rules. The
boundary between the script and the trailing args was not stable across
that round trip (live evidence: one failure's stderr was prefixed
`gsd_stall_should_recover_test:` — arg0 arrived — another `/usr/bin/bash:`
— arg0 did not).

Fixed by writing the script to a temp file and running `bash <file> <args>`
instead — the four values are now normal, quote-free positional args, and
the script itself never enters argv transport at all. Mirrors
tests/quick-branching.test.cjs's extractStep25Bash/runStep, which already
uses this exact shape and is green on Windows on `next`.
tests/worktree-cleanup.test.cjs's extractCwdGuardBash/runGuard stays on
`bash -c` but never appends extra positional args beyond the script itself,
so it never hits the same boundary — checked both siblings per review, not
assumed.

Corrected the now-actively-misleading CRLF comment in
extractStallHelpersBash(), and corrected the changeset's claim that the
repo-wide CRLF-normalization fix (folded into this branch, maintainer-
authorized) explains this PR's own Windows failure — it doesn't, though it
remains defensible on its own merits as general test-portability hardening.

Separately, while auditing the shipped (non-test) gsd_stall_watch for
Windows portability per review request, found and fixed a second, real
user-facing defect: the artifact-freshness check used GNU find's
`-newermt "@<epoch>"` shorthand, which the BSD find(1) actually shipped on
macOS does NOT understand ("Can't parse date/time: @<epoch>", verified live
against /usr/bin/find on both a stale and a genuinely fresh file). With the
adjacent `2>/dev/null`, that failed silently and permanently degraded
artifact_fresh to false on every macOS run — a plan-checker or planner
actively writing plan files could still be reported "stalled." Replaced
with `find $glob -mmin -N` ("modified less than N minutes ago"), which
needs no date-string parsing and is supported identically by GNU find and
BSD find; verified live that the old shape fails and the new shape passes
against the same real fresh file. Added a real-execution regression test
(gsd_stall_watch with `sleep` stubbed to a no-op so the test doesn't
actually wait, but the real `find ... -mmin` line still runs) proving the
fix, replacing the prior "not integration-tested" note for that path.

Note: the remote gsd-test runner is Linux-only, so it cannot itself confirm
the Windows fix — only the actual windows-latest CI lane can.

* fix(#2650): route the third bash -c call site through the same temp-file seam

runWatch() and a `-mmin` regression test still passed their script via
`bash -c <script>` after the previous commit only converted
runShouldRecover() — live Windows CI on 4b86cc57f confirmed the mechanism:
failures went 11 -> 4, and `full test (windows-latest, 22, shard 1/3)` and
`shard 2/3` flipped from fail to pass, but the remaining 4 failures (all in
this file, all still `bash: -c:`) were exactly the gsd_stall_watch describe
block, which runWatch() serves. runWatch() passes NO extra positional args
at all, so this also rules out the trailing-args theory from the prior
commit: the ~73-line, quote-dense script itself is what does not survive
Windows argv serialization when passed as a single `-c` element, regardless
of how many (if any) further argv elements follow it.

Extracted one shared runBashScript(script, args, opts) helper — write to a
fs.mkdtempSync'd file, run `bash <file> [args...]`, clean up in `finally` —
and routed all three bash-invoking call sites in this file through it
(runShouldRecover, runWatch, and the -mmin freshness test that builds its
own script inline for the `sleep` stub). One transport seam means a fourth
call site in this file cannot silently reintroduce the bug in isolation,
which is exactly what happened here with a second call site.

Corrected extractStallHelpersBash()'s doc comment a second time to state
the mechanism precisely (script content, not argv-element count) and cite
the live evidence (11->4 failures, shards 1 and 2 flipping green) so the
next reader does not have to rediscover it.

Audited every other bash-invoking call site in files this branch touches,
per review request:
- tests/code-review-pipeline-regression.test.cjs (runPostProcessing),
  tests/graphify-visualization.test.cjs (runBlock), and
  tests/drift-detection.test.cjs (two execFileSync('bash', ['-c', ...])
  sites, one of them carrying the same giant runtime-launcher preamble
  text) — all pre-existing, UNCHANGED by this branch (only touched for the
  readFileNormalized() CRLF swap), and already exercised on `next`'s last
  six Windows CI runs per the reviewer's own citation. Left as-is: no
  evidence of failure, and converting untested pre-existing code outside
  #2650's scope on an unverifiable guess would be its own risk.
- tests/git-base-branch.test.cjs (runHandleBranchingStep) and
  tests/quick-branching.test.cjs (runStep) already use the same temp-file
  pattern. No action needed.
- tests/runtime-launcher-parity.test.cjs (runResolver) uses `bash -c` but
  is explicitly `if (process.platform === 'win32') return '';` guarded off
  on Windows entirely, for an unrelated extension-less-PATH-stub reason —
  never reaches Windows argv transport at all. No action needed.
- tests/worktree-cleanup.test.cjs (runGuard) confirmed by the reviewer as
  correct and verified; not touched, per instruction.

Do not touch: the -mmin fix, the drift-ack fragment, the changeset — all
three confirmed correct in prior rounds and left untouched here.

Note: the remote gsd-test runner is Linux-only and cannot confirm this;
only the windows-latest lanes on #3015 can.

* fix(#2650): give runBashScript a default timeout

runShouldRecover() was the only one of the three call sites through
runBashScript() with no timeout — runWatch() and the -mmin test both pass
timeout: 10000 explicitly. Not a regression (this path never had a bound
before), but CONTEXT.md's unbounded-subprocess guidance applies directly,
and runShouldRecover() is driven repeatedly by a fast-check property test:
one pathological input that fails to terminate would hang CI indefinitely
instead of failing.

timeout: 10000 is now the helper's own default, with ...opts spread after
it so the two existing explicit timeout: 10000 call sites are unchanged
and any future caller inherits a bound automatically.

* fix(#2650): build the -mmin freshness test's glob with forward slashes

Windows CI on d6ddda6ea reported the last failure: the -mmin regression
test expected 'active' but got 'waiting' — find matched nothing, the same
silent-degradation shape as the macOS -newermt defect, but this time in the
test's own fixture rather than the shipped bash.

Traced what production actually passes: every gsd_stall_watch call site in
plan-phase.md builds artifact_glob as `"${PHASE_DIR}"'/*-PLAN.md'` —
PHASE_DIR is a POSIX-style .planning/phases/NN-slug value, and the whole
thing runs under Git Bash regardless of host OS, so production's glob is
always forward-slash. The test instead built it with
`path.join(tmp, '*-PLAN.md')`, which on Windows yields a backslash path
(C:\Users\RUNNER~1\...\*-PLAN.md). In bash pathname expansion a backslash
escapes the next character, so that pattern can never match a real path —
find silently returns empty under the existing 2>/dev/null, same shape as
the macOS bug. Confirmed as a test artifact, not a production defect:
production never constructs the glob this way, so no Windows user is
affected.

Fixed by forward-slashing the tmp dir before appending the glob suffix,
matching production's own convention, with a comment recording why (so a
future "simplify this back to path.join" edit doesn't silently reintroduce
the failure). The shipped bash's unquoted $artifact_glob is untouched —
quoting it would break the multi-file glob expansion it exists for.

Note: the remote runner is Linux-only and already passed clean at
d6ddda6ea (0/29,603, both node lanes); only the windows-latest lanes on
#3015 can confirm this fix.

* fix(#2650): forward-slash the three remaining runWatch globs (vacuous-pass CR)

The :353 fix (833c11da9) only converted the -mmin freshness test's glob.
Three sibling tests in the same describe block still built theirs with
path.join(tmp, '*-PLAN.md'), which yields a backslash path on Windows.

Two of those three were silently passing for the wrong reason: the
'-> stalled' and '-> waiting' tests both expect the glob to match nothing,
and on Windows a backslash path matches nothing regardless of whether the
directory is actually empty (bash eats each backslash as an escape before
the pattern is even evaluated). They would have passed identically with
glob expansion completely broken, which is a vacuous pass — not exercising
what they claim to. The third ('-> marker_received') is outcome-independent
of the glob, so it was merely inconsistent rather than wrong.

Converted all three to the same `${tmp.replace(/\\/g, '/')}/*-PLAN.md`
construction already used at the -mmin test, so every glob in the file now
matches production's own forward-slash `"${PHASE_DIR}"'/*-PLAN.md'` shape,
and the two negative tests are meaningful on Windows instead of accidentally
correct. Reworded the trailing comment on the 'stalled' test's glob line:
it now describes the fixture (the tmp dir contains no *-PLAN.md files)
rather than the pattern, since "matches nothing" read as a property of the
glob syntax when it's a property of what's on disk.

No assertion, the sleep stub, runBashScript, or the shipped bash changed.
Smoke-tested all three updated tests manually before committing (not via
node --test): marker_received / stalled / waiting, all correct.

* fix(#2650): fix own regression tests for #2993's plan-phase.md relocation

531101843's merge with origin/next brought in #2993 (unrelated, epic #1671
Phase 6.2), which extracted plan-phase.md's whole "Chunked Planning Mode"
section into gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md,
leaving a <!-- gsd:section --> pointer behind. tests/plan-phase-drift-guard.
test.cjs (#913) was already updated to read the combined surface (host file
+ every steps/*.md) so its label count didn't go blind — my own #2650
regression tests were not, and searched plan-phase.md alone for the two
chunked spawn sites' headings, which no longer exist there. Two tests
failed outright (indexOf returning -1); a third ("standard planner spawn")
was silently weakened to an unbounded slice-to-EOF by the same relocation,
since its own end-boundary heading also moved — passing by accident rather
than by testing what it claimed.

Promoted the drift guard's local readPlanPhaseCombined() to a shared,
exported tests/helpers.cjs readWorkflowCombined(workflowPath) (host file +
sorted steps/*.md, CRLF-normalized at the read boundary) so a second,
divergent implementation is never written — the drift guard now delegates
to it via a same-named local wrapper, unchanged at every existing call site.

Fixed the three affected tests in tests/fix-2650-plan-phase-stall-detection.
test.cjs:
- "standard planner spawn (step 8)": end boundary changed from the now-gone
  "## 8.5. Chunked Planning Mode" heading to "## 9. Handle Planner Return",
  which still exists in plan-phase.md.
- "chunked outline spawn (8.5.1)" / "chunked per-plan spawn (8.5.2)": now
  read gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md
  directly (not the generic multi-file combined blob, whose file-sort
  ordering would put unrelated step files between 8.5.2's slice and any
  downstream anchor) — the same heading-to-heading slicing as before still
  works because the file is small and self-contained.
- Extended the "no unbound $PLANNER_OUTPUT_FILE/$CHECKER_OUTPUT_FILE" check
  to also scan chunked-planning-mode.md, since two of the five spawn sites
  now live there.
- Added a new count-based test asserting exactly 5 (not "at least one")
  `gsd_stall_watch "$TS" "{outputFile}"` invocations across the combined
  surface, mirroring #913's own label-count guard, so every one of the five
  spawns stays provably bounded and a future relocation can't silently drop
  one without a test noticing.

Also added a small positive test that plan-phase.md's <!-- gsd:section -->
pointer to chunked-planning-mode.md exists (#2993 is unrelated to #2650 but
its presence is now load-bearing for where 2 of the 5 spawn sites live).

Audited every other test file in the repo for a stale reference to content
#2993 relocated (searched for the moved headings/prose and for
"chunked-planning-mode"/"CHUNKED_MODE" across all *.test.cjs): only this
file and the drift guard needed changes.
tests/issue-2762-plan-reviews-chunked.test.cjs already reads
chunked-planning-mode.md directly (brought in correct by the same merge).
gen-section-manifest.test.cjs, init.test.cjs, and workflow-fragments.test.cjs
reference "chunked-planning-mode" only as a manifest/section-id fixture
value for #2993 itself, not as a stale pointer to relocated content.

Did not touch: the ported ORCHESTRATOR RULE lines, run_in_background=true,
the glob constructions, runBashScript, the -mmin change, the timeout
default, or the drift-ack fragment (confirmed correct against the stale
local `next` ref two rounds ago and left alone).

---------

Co-authored-by: sim <sim@local>
2026-08-03 10:46:22 -04:00

952 lines
36 KiB
JavaScript

// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* GSD Tools Tests — Codebase Drift Detection (#2003)
*
* Unit tests for bin/lib/drift.cjs plus CLI surface via verify codebase-drift.
* Exercises the four drift categories (new dir, barrel, migration, route),
* threshold gating, warn vs. auto-remap, last_mapped_commit round-trip,
* config validation, mapper --paths passthrough, and graceful failure paths.
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const {
createTempProject,
createTempGitProject,
cleanup,
runGsdTools,
} = require('./helpers.cjs');
const DRIFT_PATH = path.join(
__dirname,
'..',
'gsd-core',
'bin',
'lib',
'drift.cjs',
);
const CONFIG_SCHEMA_PATH = path.join(
__dirname,
'..',
'gsd-core',
'bin',
'lib',
'config-schema.cjs',
);
const {
detectDrift,
classifyFile,
readMappedCommit,
writeMappedCommit,
chooseAffectedPaths,
sanitizePaths,
DRIFT_CATEGORIES,
} = require(DRIFT_PATH);
// Small wrapper around execFileSync so tests don't sprinkle shell=true calls.
function git(cwd, ...args) {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
}
// ─── Unit: classifyFile ──────────────────────────────────────────────────────
describe('classifyFile', () => {
test('classifies packages barrel export', () => {
assert.strictEqual(classifyFile('packages/foo/src/index.ts'), 'barrel');
});
test('classifies apps barrel export', () => {
assert.strictEqual(classifyFile('apps/web/src/index.tsx'), 'barrel');
});
test('classifies supabase migration', () => {
assert.strictEqual(
classifyFile('supabase/migrations/20240101_init.sql'),
'migration',
);
});
test('classifies prisma migration folder', () => {
assert.strictEqual(
classifyFile('prisma/migrations/20240101_init/migration.sql'),
'migration',
);
});
test('classifies drizzle meta migration', () => {
assert.strictEqual(classifyFile('drizzle/meta/_journal.json'), 'migration');
});
test('classifies route module', () => {
assert.strictEqual(
classifyFile('apps/web/src/routes/journal.ts'),
'route',
);
assert.strictEqual(
classifyFile('src/api/users.ts'),
'route',
);
});
test('returns null for ordinary source file', () => {
assert.strictEqual(classifyFile('src/lib/util.ts'), null);
});
});
// ─── Unit: detectDrift categories ────────────────────────────────────────────
describe('detectDrift — categories', () => {
const baseStructure = [
'# Codebase Structure',
'',
'- `src/lib/` — helpers',
'- `bin/` — CLIs',
'',
].join('\n');
test('identifies new directory outside mapped paths', () => {
const result = detectDrift({
addedFiles: ['newpkg/src/thing.ts'],
modifiedFiles: [],
deletedFiles: [],
structureMd: baseStructure,
});
const newDirs = result.elements.filter((e) => e.category === 'new_dir');
assert.ok(newDirs.length >= 1, 'should find at least one new directory');
assert.ok(
newDirs.some((e) => e.path.startsWith('newpkg')),
'should flag newpkg as new',
);
});
test('does not flag files in already-mapped paths', () => {
const result = detectDrift({
addedFiles: ['src/lib/newhelper.ts'],
modifiedFiles: [],
deletedFiles: [],
structureMd: baseStructure,
});
const newDirs = result.elements.filter((e) => e.category === 'new_dir');
assert.strictEqual(
newDirs.length,
0,
'src/lib is mapped — no new_dir drift',
);
});
test('identifies new barrel export', () => {
const result = detectDrift({
addedFiles: ['packages/widgets/src/index.ts'],
modifiedFiles: [],
deletedFiles: [],
structureMd: baseStructure,
});
assert.ok(result.elements.some((e) => e.category === 'barrel'));
});
test('identifies new migration', () => {
const result = detectDrift({
addedFiles: ['supabase/migrations/20240501_add_accounts.sql'],
modifiedFiles: [],
deletedFiles: [],
structureMd: baseStructure,
});
assert.ok(result.elements.some((e) => e.category === 'migration'));
});
test('identifies new route module', () => {
const result = detectDrift({
addedFiles: ['apps/accounting/src/routes/journal.ts'],
modifiedFiles: [],
deletedFiles: [],
structureMd: baseStructure,
});
assert.ok(result.elements.some((e) => e.category === 'route'));
});
test('prioritizes higher-specificity category per file', () => {
const result = detectDrift({
addedFiles: ['supabase/migrations/20240101_init.sql'],
modifiedFiles: [],
deletedFiles: [],
structureMd: baseStructure,
});
const perFile = result.elements.filter(
(e) => e.path === 'supabase/migrations/20240101_init.sql',
);
assert.strictEqual(perFile.length, 1, 'file counted once');
assert.strictEqual(perFile[0].category, 'migration');
});
});
// ─── Unit: threshold gating ──────────────────────────────────────────────────
describe('detectDrift — threshold gating', () => {
test('2 elements under default threshold → no action', () => {
const result = detectDrift({
addedFiles: [
'packages/a/src/index.ts',
'packages/b/src/index.ts',
],
modifiedFiles: [],
deletedFiles: [],
structureMd: '# only src/ mapped',
threshold: 3,
});
assert.strictEqual(result.elements.length >= 2, true);
assert.strictEqual(result.actionRequired, false);
});
test('3 elements at threshold → action required', () => {
const result = detectDrift({
addedFiles: [
'packages/a/src/index.ts',
'packages/b/src/index.ts',
'packages/c/src/index.ts',
],
modifiedFiles: [],
deletedFiles: [],
structureMd: '# only src/ mapped',
threshold: 3,
});
assert.strictEqual(result.actionRequired, true);
});
test('4 elements exceeds threshold → action required', () => {
const result = detectDrift({
addedFiles: [
'packages/a/src/index.ts',
'packages/b/src/index.ts',
'packages/c/src/index.ts',
'supabase/migrations/1.sql',
],
modifiedFiles: [],
deletedFiles: [],
structureMd: '# only src/ mapped',
threshold: 3,
});
assert.strictEqual(result.actionRequired, true);
});
test('respects custom threshold value', () => {
const result = detectDrift({
addedFiles: ['packages/a/src/index.ts', 'packages/b/src/index.ts'],
modifiedFiles: [],
deletedFiles: [],
structureMd: '# only src/ mapped',
threshold: 2,
});
assert.strictEqual(result.actionRequired, true);
});
});
// ─── Unit: action routing ────────────────────────────────────────────────────
describe('detectDrift — action routing', () => {
const over = {
addedFiles: [
'packages/a/src/index.ts',
'packages/b/src/index.ts',
'packages/c/src/index.ts',
],
modifiedFiles: [],
deletedFiles: [],
structureMd: '# only src/ mapped',
threshold: 3,
};
test('warn action yields warn directive and no mapper spawn request', () => {
const result = detectDrift({ ...over, action: 'warn' });
assert.strictEqual(result.directive, 'warn');
assert.strictEqual(result.spawnMapper, false);
assert.ok(result.message.includes('drift'), 'message mentions drift');
});
test('auto-remap action yields spawn directive with affected paths', () => {
const result = detectDrift({ ...over, action: 'auto-remap' });
assert.strictEqual(result.directive, 'auto-remap');
assert.strictEqual(result.spawnMapper, true);
assert.ok(Array.isArray(result.affectedPaths));
assert.ok(result.affectedPaths.length > 0);
for (const p of result.affectedPaths) {
assert.ok(!p.startsWith('/'), 'no absolute paths');
assert.ok(!p.includes('..'), 'no traversal');
}
});
test('below-threshold inputs produce no directive', () => {
const result = detectDrift({
addedFiles: ['packages/a/src/index.ts'],
modifiedFiles: [],
deletedFiles: [],
structureMd: '# only src/ mapped',
threshold: 3,
action: 'auto-remap',
});
assert.strictEqual(result.actionRequired, false);
assert.strictEqual(result.spawnMapper, false);
assert.strictEqual(result.directive, 'none');
});
});
// ─── Unit: affected-paths scoping ────────────────────────────────────────────
describe('chooseAffectedPaths', () => {
test('collapses files into top-level prefixes', () => {
const paths = chooseAffectedPaths([
'apps/accounting/src/routes/a.ts',
'apps/accounting/src/routes/b.ts',
'packages/ui/src/index.ts',
]);
assert.ok(paths.includes('apps/accounting'));
assert.ok(paths.includes('packages/ui'));
});
test('deduplicates and sorts', () => {
const paths = chooseAffectedPaths([
'zzz/a.ts',
'aaa/b.ts',
'zzz/c.ts',
]);
assert.deepStrictEqual(paths, ['aaa', 'zzz']);
});
test('returns [] for empty input', () => {
assert.deepStrictEqual(chooseAffectedPaths([]), []);
});
});
// ─── Unit: sanitizePaths ─────────────────────────────────────────────────────
describe('sanitizePaths', () => {
test('rejects traversal', () => {
assert.deepStrictEqual(sanitizePaths(['../evil']), []);
assert.deepStrictEqual(sanitizePaths(['foo/../evil']), []);
});
test('rejects absolute paths', () => {
assert.deepStrictEqual(sanitizePaths(['/etc/passwd']), []);
});
test('rejects shell metacharacters', () => {
assert.deepStrictEqual(sanitizePaths(['foo;rm -rf /']), []);
assert.deepStrictEqual(sanitizePaths(['foo`id`']), []);
assert.deepStrictEqual(sanitizePaths(['foo$(id)']), []);
});
test('accepts normal repo-relative paths', () => {
assert.deepStrictEqual(
sanitizePaths(['apps/web', 'packages/ui']),
['apps/web', 'packages/ui'],
);
});
});
// ─── Unit: last_mapped_commit frontmatter round-trip ─────────────────────────
describe('last_mapped_commit frontmatter', () => {
let tmp;
beforeEach(() => {
tmp = createTempProject('gsd-drift-');
fs.mkdirSync(path.join(tmp, '.planning', 'codebase'), { recursive: true });
});
afterEach(() => cleanup(tmp));
test('writeMappedCommit creates frontmatter on fresh file', () => {
const file = path.join(tmp, '.planning', 'codebase', 'STRUCTURE.md');
fs.writeFileSync(file, '# Codebase Structure\n\nBody\n');
writeMappedCommit(file, 'deadbeef00000000000000000000000000000000', '2026-04-22');
const content = fs.readFileSync(file, 'utf8');
assert.ok(content.startsWith('---\n'));
assert.ok(content.includes('last_mapped_commit: deadbeef00000000000000000000000000000000'));
assert.ok(content.includes('# Codebase Structure'));
});
test('writeMappedCommit updates existing frontmatter', () => {
const file = path.join(tmp, '.planning', 'codebase', 'STRUCTURE.md');
fs.writeFileSync(
file,
'---\nlast_mapped_commit: aaaa\nother: keep-me\n---\n# body\n',
);
writeMappedCommit(file, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', '2026-04-22');
const content = fs.readFileSync(file, 'utf8');
assert.ok(content.includes('last_mapped_commit: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'));
assert.ok(content.includes('other: keep-me'), 'preserves other keys');
assert.ok(content.includes('# body'));
});
test('readMappedCommit round-trips via write', () => {
const file = path.join(tmp, '.planning', 'codebase', 'STRUCTURE.md');
fs.writeFileSync(file, '# body\n');
writeMappedCommit(file, 'cafebabe00000000000000000000000000000000', '2026-04-22');
assert.strictEqual(
readMappedCommit(file),
'cafebabe00000000000000000000000000000000',
);
});
test('readMappedCommit returns null when file missing', () => {
assert.strictEqual(readMappedCommit('/nonexistent/path.md'), null);
});
test('readMappedCommit returns null when frontmatter absent', () => {
const file = path.join(tmp, '.planning', 'codebase', 'STRUCTURE.md');
fs.writeFileSync(file, '# No frontmatter\n');
assert.strictEqual(readMappedCommit(file), null);
});
test('writeMappedCommit creates the file when it does not exist (symmetry with readMappedCommit)', () => {
const file = path.join(tmp, '.planning', 'codebase', 'NEW-DOC.md');
assert.strictEqual(fs.existsSync(file), false, 'precondition: file absent');
// Must not throw — readMappedCommit returns null for missing files,
// writeMappedCommit must defensively create them.
writeMappedCommit(file, 'feedface00000000000000000000000000000000', '2026-04-22');
assert.strictEqual(fs.existsSync(file), true, 'file created');
assert.strictEqual(
readMappedCommit(file),
'feedface00000000000000000000000000000000',
);
});
});
// ─── Unit: negative / defensive ──────────────────────────────────────────────
describe('detectDrift — defensive paths', () => {
test('missing structureMd → skipped result, no throw', () => {
const result = detectDrift({
addedFiles: ['foo/bar.ts'],
modifiedFiles: [],
deletedFiles: [],
structureMd: null,
});
assert.strictEqual(result.skipped, true);
assert.strictEqual(result.actionRequired, false);
assert.ok(result.reason);
});
test('empty inputs → no drift', () => {
const result = detectDrift({
addedFiles: [],
modifiedFiles: [],
deletedFiles: [],
structureMd: '# structure',
});
assert.strictEqual(result.elements.length, 0);
assert.strictEqual(result.actionRequired, false);
});
test('categories constant is exposed and stable', () => {
assert.ok(Array.isArray(DRIFT_CATEGORIES));
assert.deepStrictEqual(
[...DRIFT_CATEGORIES].sort(),
['barrel', 'migration', 'new_dir', 'route'],
);
});
});
// ─── Unit: non-blocking guarantee ────────────────────────────────────────────
describe('detectDrift — non-blocking guarantee', () => {
test('never throws on malformed input', () => {
assert.doesNotThrow(() => detectDrift({}));
assert.doesNotThrow(() => detectDrift({ addedFiles: null }));
assert.doesNotThrow(() => detectDrift({ addedFiles: ['x'], structureMd: undefined }));
});
test('malformed input returns a skipped result (never crashes the phase)', () => {
const r = detectDrift({});
assert.strictEqual(r.skipped, true);
assert.strictEqual(r.actionRequired, false);
});
});
// ─── Config validation: drift keys owned by the drift capability ──────────────
//
// After ADR-857 phase-6 migration, workflow.drift_threshold and workflow.drift_action
// are no longer in the central config schema manifest (VALID_CONFIG_KEYS). They are
// federated config keys owned exclusively by the `drift` capability in the registry.
// VALID_CONFIG_KEYS covers central-only keys; capability-owned keys resolve through
// the federated config overlay (loadConfig still returns them at their defaults).
const CAPABILITY_REGISTRY_PATH = path.join(
__dirname,
'..',
'gsd-core',
'bin',
'lib',
'capability-registry.cjs',
);
describe('config-schema — drift keys', () => {
test('workflow.drift_threshold owned by drift capability (not central)', () => {
const { isCentralConfigKey } = require(CONFIG_SCHEMA_PATH);
const registry = require(CAPABILITY_REGISTRY_PATH);
// Must be owned by the drift capability
assert.strictEqual(registry.configKeys['workflow.drift_threshold'], 'drift',
'workflow.drift_threshold must be owned by the drift capability');
// Must NOT be in central schema (migration complete)
assert.strictEqual(isCentralConfigKey('workflow.drift_threshold'), false,
'workflow.drift_threshold must not be a central config key after capability migration');
});
test('workflow.drift_action owned by drift capability (not central)', () => {
const { isCentralConfigKey } = require(CONFIG_SCHEMA_PATH);
const registry = require(CAPABILITY_REGISTRY_PATH);
// Must be owned by the drift capability
assert.strictEqual(registry.configKeys['workflow.drift_action'], 'drift',
'workflow.drift_action must be owned by the drift capability');
// Must NOT be in central schema (migration complete)
assert.strictEqual(isCentralConfigKey('workflow.drift_action'), false,
'workflow.drift_action must not be a central config key after capability migration');
});
});
describe('config-set drift validation via CLI', () => {
let tmp;
beforeEach(() => {
tmp = createTempGitProject('gsd-drift-cfg-');
});
afterEach(() => cleanup(tmp));
test('accepts warn', () => {
const r = runGsdTools(['config-set', 'workflow.drift_action', 'warn'], tmp);
assert.strictEqual(r.success, true, r.error);
});
test('accepts auto-remap', () => {
const r = runGsdTools(['config-set', 'workflow.drift_action', 'auto-remap'], tmp);
assert.strictEqual(r.success, true, r.error);
});
test('rejects bogus drift_action value', () => {
const r = runGsdTools(['config-set', 'workflow.drift_action', 'sometimes'], tmp);
assert.strictEqual(r.success, false);
});
test('drift_threshold accepts integer', () => {
const r = runGsdTools(['config-set', 'workflow.drift_threshold', '5'], tmp);
assert.strictEqual(r.success, true, r.error);
});
test('drift_threshold rejects non-numeric', () => {
const r = runGsdTools(['config-set', 'workflow.drift_threshold', 'many'], tmp);
assert.strictEqual(r.success, false);
});
});
// ─── Docs parity for CONFIGURATION.md ────────────────────────────────────────
describe('docs parity', () => {
test('workflow.drift_threshold mentioned in docs/CONFIGURATION.md', () => {
const md = fs.readFileSync(
path.join(__dirname, '..', 'docs', 'CONFIGURATION.md'),
'utf8',
);
assert.ok(md.includes('`workflow.drift_threshold`'));
});
test('workflow.drift_action mentioned in docs/CONFIGURATION.md', () => {
const md = fs.readFileSync(
path.join(__dirname, '..', 'docs', 'CONFIGURATION.md'),
'utf8',
);
assert.ok(md.includes('`workflow.drift_action`'));
});
});
// ─── Mapper --paths flag documented ──────────────────────────────────────────
describe('gsd-codebase-mapper --paths flag', () => {
test('agent doc mentions --paths', () => {
const doc = fs.readFileSync(
path.join(__dirname, '..', 'agents', 'gsd-codebase-mapper.md'),
'utf8',
);
assert.ok(/--paths/.test(doc));
});
test('AGENTS.md mentions --paths for mapper', () => {
const doc = fs.readFileSync(
path.join(__dirname, '..', 'docs', 'AGENTS.md'),
'utf8',
);
assert.ok(/--paths/.test(doc));
});
test('map-codebase workflow documents --paths passthrough', () => {
const doc = fs.readFileSync(
path.join(
__dirname,
'..',
'gsd-core',
'workflows',
'map-codebase.md',
),
'utf8',
);
assert.ok(/--paths/.test(doc));
});
});
// ─── Execute-phase workflow integration ──────────────────────────────────────
//
// After ADR-857 phase-6 migration, codebase_drift_gate is no longer an inline
// step in execute-phase.md. Instead, it is declared as a gate in the `drift`
// capability at the `execute:wave:post` hook point. The execute-phase.md
// dispatches capability gates via `gsd_run loop render-hooks execute:wave:post`,
// which fires the drift gates automatically.
describe('execute-phase integrates codebase_drift_gate', () => {
test('workflow references a codebase drift step', () => {
// After capability migration: the drift gate fires via execute:wave:post
// render-hooks dispatch. Verify two things:
// 1. execute-phase.md has the execute:wave:post render-hooks call site.
// 2. The drift capability declares a codebase-drift gate at execute:wave:post.
const doc = fs.readFileSync(
path.join(
__dirname,
'..',
'gsd-core',
'workflows',
'execute-phase.md',
),
'utf8',
);
// execute-phase.md must dispatch execute:wave:post hooks (the call site that fires drift gates)
assert.ok(
/loop render-hooks execute:wave:post/.test(doc),
'execute-phase.md must dispatch execute:wave:post hooks (drift capability gate call site)',
);
// The drift capability must declare a codebase-drift gate at execute:wave:post
const registry = require(CAPABILITY_REGISTRY_PATH);
const driftCap = registry.capabilities['drift'];
assert.ok(driftCap, 'drift capability must be registered');
const codebaseDriftGate = (driftCap.gates || []).find(
(g) => g.check && /codebase.drift/i.test(g.check.query),
);
assert.ok(
codebaseDriftGate,
'drift capability must declare a codebase-drift gate at execute:wave:post',
);
assert.strictEqual(codebaseDriftGate.point, 'execute:wave:post');
assert.strictEqual(codebaseDriftGate.blocking, false,
'codebase-drift gate must be non-blocking by contract');
});
test('workflow documents non-blocking guarantee for drift', () => {
const doc = fs.readFileSync(
path.join(
__dirname,
'..',
'gsd-core',
'workflows',
'execute-phase.md',
),
'utf8',
);
assert.ok(/non[- ]blocking/i.test(doc) || /continue on (error|failure)/i.test(doc));
});
});
// ─── CLI: verify codebase-drift subcommand ───────────────────────────────────
describe('verify codebase-drift CLI', () => {
let tmp;
beforeEach(() => {
tmp = createTempGitProject('gsd-drift-cli-');
fs.mkdirSync(path.join(tmp, '.planning', 'codebase'), { recursive: true });
});
afterEach(() => cleanup(tmp));
test('returns skipped JSON when STRUCTURE.md missing', () => {
const r = runGsdTools(['verify', 'codebase-drift'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, true);
assert.strictEqual(data.action_required, false);
});
test('returns no-drift result when STRUCTURE.md is fresh', () => {
const structure = path.join(tmp, '.planning', 'codebase', 'STRUCTURE.md');
fs.writeFileSync(structure, '# Codebase Structure\n\n- `src/`\n');
const head = git(tmp, 'rev-parse', 'HEAD');
writeMappedCommit(structure, head, '2026-04-22');
const r = runGsdTools(['verify', 'codebase-drift'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.action_required, false);
});
test('detects drift when new files added after last_mapped_commit', () => {
const structure = path.join(tmp, '.planning', 'codebase', 'STRUCTURE.md');
fs.writeFileSync(structure, '# Codebase Structure\n\n- `src/`\n');
const head = git(tmp, 'rev-parse', 'HEAD');
writeMappedCommit(structure, head, '2026-04-22');
git(tmp, 'add', '-A');
git(tmp, 'commit', '-m', 'map codebase');
for (const pkg of ['alpha', 'beta', 'gamma']) {
const dir = path.join(tmp, 'packages', pkg, 'src');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.ts'), 'export {};\n');
}
git(tmp, 'add', '-A');
git(tmp, 'commit', '-m', 'add packages');
const r = runGsdTools(['verify', 'codebase-drift'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.action_required, true);
assert.strictEqual(data.directive, 'warn');
assert.ok(data.elements.length >= 3);
});
test('never exits non-zero when git repo is missing (non-blocking)', () => {
const nonGit = createTempProject('gsd-drift-nongit-');
try {
const r = runGsdTools(['verify', 'codebase-drift'], nonGit);
assert.strictEqual(r.success, true, 'must exit 0 even without git');
const data = JSON.parse(r.output);
assert.strictEqual(data.skipped, true);
} finally {
cleanup(nonGit);
}
});
});
// ─── Regression #1493 — workflow.drift_action / drift_threshold read from nested config shape ───
//
// loadConfig() returns a flattened object; config?.workflow was always undefined,
// making drift_action permanently 'warn' and drift_threshold always 3 regardless
// of .planning/config.json contents. Fix reads the raw nested JSON directly.
describe('verify codebase-drift — workflow config read from nested shape (#1493)', () => {
let tmp;
beforeEach(() => {
tmp = createTempGitProject('gsd-drift-1493-');
fs.mkdirSync(path.join(tmp, '.planning', 'codebase'), { recursive: true });
});
afterEach(() => cleanup(tmp));
test('workflow.drift_action=auto-remap in config.json is honored (not always warn) (#1493)', () => {
// Write config with nested workflow shape — the flat loadConfig() path would
// have silently dropped this, leaving action === 'warn'.
fs.writeFileSync(
path.join(tmp, '.planning', 'config.json'),
JSON.stringify({ workflow: { drift_action: 'auto-remap', drift_threshold: 1 } }, null, 2),
);
// Map codebase to current HEAD so anything committed next is "new" drift.
const structure = path.join(tmp, '.planning', 'codebase', 'STRUCTURE.md');
fs.writeFileSync(structure, '# Codebase Structure\n\n- `src/`\n');
writeMappedCommit(structure, git(tmp, 'rev-parse', 'HEAD'), '2026-04-22');
git(tmp, 'add', '-A');
git(tmp, 'commit', '-m', 'map codebase');
// Add one structural barrel file — enough to exceed drift_threshold of 1.
const dir = path.join(tmp, 'packages', 'ui', 'src');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.ts'), 'export {};\n');
git(tmp, 'add', '-A');
git(tmp, 'commit', '-m', 'add package barrel');
const r = runGsdTools(['verify', 'codebase-drift'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(
data.action, 'auto-remap',
'workflow.drift_action=auto-remap must flow through from nested config; "warn" means the flat-shape bug is still active',
);
});
test('workflow.drift_threshold in config.json gates triggering (#1493)', () => {
// Threshold of 100 — 1 structural file should not trigger action_required.
fs.writeFileSync(
path.join(tmp, '.planning', 'config.json'),
JSON.stringify({ workflow: { drift_action: 'auto-remap', drift_threshold: 100 } }, null, 2),
);
const structure = path.join(tmp, '.planning', 'codebase', 'STRUCTURE.md');
fs.writeFileSync(structure, '# Codebase Structure\n\n- `src/`\n');
writeMappedCommit(structure, git(tmp, 'rev-parse', 'HEAD'), '2026-04-22');
git(tmp, 'add', '-A');
git(tmp, 'commit', '-m', 'map codebase');
const dir = path.join(tmp, 'packages', 'ui', 'src');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'index.ts'), 'export {};\n');
git(tmp, 'add', '-A');
git(tmp, 'commit', '-m', 'add one package barrel');
const r = runGsdTools(['verify', 'codebase-drift'], tmp);
assert.strictEqual(r.success, true, r.error);
const data = JSON.parse(r.output);
assert.strictEqual(data.threshold, 100,
'workflow.drift_threshold=100 must be read from nested config; 3 means the flat-shape bug is still active');
assert.strictEqual(data.action_required, false,
'1 structural file must not exceed threshold of 100');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-619-codebase-drift-gate-shim.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-619-codebase-drift-gate-shim (consolidation epic #1969 B6 #1975)", () => {
// allow-test-rule: source-text-is-the-product (see #619)
// codebase-drift-gate.md is the shipped orchestration step contract. Bug #619:
// the initial drift check ran the bare PATH binary `gsd-tools verify codebase-drift`.
// On a shim-only install (gsd-tools.cjs present, `gsd-tools` not on PATH) that exits
// 127, `2>/dev/null` hides it, and the `|| echo` fallback marks the gate skipped —
// so post-execution drift detection silently never runs. The fix resolves gsd-tools
// through the runtime shim launcher (gsd_run), defining the canonical preamble once in
// this always-run block so the file stays compliant with the single-preamble parity
// invariant (the conditional auto-remap block reuses the launcher via shared shell scope).
//
// This file locks the source contract AND behaviorally proves the shim resolves: it runs
// the exact shipped drift-check block against a shim-only topology and asserts the shim
// actually executes, where the old bare-binary form would have skipped.
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup, readFileNormalized } = require('./helpers.cjs');
const GATE_MD = path.join(
__dirname, '..', 'gsd-core', 'workflows', 'execute-phase', 'steps', 'codebase-drift-gate.md',
);
const SNIPPET_FILE = path.join(__dirname, '..', 'gsd-core', 'workflows', '_runtime-launcher.snippet.sh');
// readFileNormalized() strips \r\n -> \n before bashBlock() slices a fence
// out of the result and hands it to execFileSync('bash', ...) below — an
// un-normalized read on a Windows checkout would break bash mid-script
// (DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE, #2650).
function readGate() {
return readFileNormalized(GATE_MD);
}
// Extract the Nth (0-based) ```bash fenced block body from the file.
function bashBlock(content, n) {
const blocks = [];
const re = /```bash\r?\n([\s\S]*?)```/g;
let m;
while ((m = re.exec(content)) !== null) blocks.push(m[1]);
assert.ok(blocks.length > n, `expected at least ${n + 1} bash blocks, found ${blocks.length}`);
return blocks[n];
}
describe('bug #619 — codebase-drift-gate resolves gsd-tools via the runtime shim, not the bare PATH binary', () => {
test('codebase-drift-gate.md is readable', () => {
assert.ok(readGate().length > 0, 'codebase-drift-gate.md must not be empty');
});
// ── Source contract (the .md is the product) ──────────────────────────────
test('the drift check resolves gsd-tools via the shim launcher (gsd_run), not the bare binary (#619)', () => {
const content = readGate();
assert.match(
content,
/DRIFT=\$\(gsd_run verify codebase-drift 2>\/dev\/null \|\| echo '\{"skipped":true,"reason":"sdk-failed"\}'\)/,
'drift check must call `gsd_run verify codebase-drift` with the non-blocking skip fallback',
);
assert.doesNotMatch(
content,
/\bgsd-tools verify codebase-drift\b/,
'the bare `gsd-tools verify codebase-drift` PATH-binary call (the #619 bug) must be gone',
);
});
test('non-blocking contract preserved: the skip JSON fallback is intact (#619)', () => {
const content = readGate();
assert.match(
content,
/\|\| echo '\{"skipped":true,"reason":"sdk-failed"\}'/,
'an internal drift-command failure must still fall through to the skip JSON',
);
});
test('exactly one canonical launcher preamble, in the drift-check block, before any launcher call (#619)', () => {
const content = readGate();
const snippet = readFileNormalized(SNIPPET_FILE).replace(/\n$/, '');
// Count canonical preamble occurrences across the whole file (parity: exactly one).
let count = 0;
let pos = 0;
for (;;) {
const idx = content.indexOf(snippet, pos);
if (idx === -1) break;
count++;
pos = idx + snippet.length;
}
assert.equal(count, 1, `expected exactly one canonical preamble; found ${count}`);
// The preamble must live in the first (drift-check) bash block, before the DRIFT call.
const block0 = bashBlock(content, 0);
assert.ok(block0.includes(snippet), 'the canonical preamble must be in the drift-check block');
assert.ok(
block0.indexOf(snippet) < block0.indexOf('gsd_run verify codebase-drift'),
'the preamble must precede the gsd_run drift call in the same block',
);
// The auto-remap block reuses gsd_run but must NOT carry its own preamble.
const content2 = content.slice(content.indexOf('AGENT_SKILLS_MAPPER'));
assert.ok(!content2.includes(snippet), 'the auto-remap block must not re-declare the preamble (single-preamble parity)');
});
// ── Behavioral proof: the shim resolves on a shim-only topology ───────────
test('shipped drift-check block runs the shim (gsd-tools.cjs), not skip, on a shim-only install (#619)', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-619-'));
try {
// Shim-only topology: gsd-tools.cjs present under RUNTIME_DIR; no `gsd-tools` on PATH.
const binDir = path.join(tmp, 'gsd-core', 'bin');
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(
path.join(binDir, 'gsd-tools.cjs'),
'if (process.argv[2] === "verify" && process.argv[3] === "codebase-drift") {\n' +
' process.stdout.write(JSON.stringify({ action_required: false, sentinel: "SHIM_RAN" }));\n' +
'}\n',
);
const block = bashBlock(readGate(), 0) + '\nprintf "%s" "$DRIFT"\n';
const out = execFileSync('bash', ['-c', block], {
env: { ...process.env, RUNTIME_DIR: tmp },
encoding: 'utf8',
});
assert.match(out, /SHIM_RAN/, 'the drift check must execute the resolved shim, proving gsd_run resolution');
assert.doesNotMatch(out, /sdk-failed/, 'the gate must NOT silently skip when the shim is present (#619)');
} finally {
cleanup(tmp);
}
});
test('red-proof: the old bare `gsd-tools` form would skip when gsd-tools is not on PATH', () => {
// Documents the #619 bug: the pre-fix bare-binary call, with no `gsd-tools` on PATH,
// hits the 127 → `|| echo` skip path even though the shim (gsd-tools.cjs) exists.
const oldForm =
'DRIFT=$(gsd-tools verify codebase-drift 2>/dev/null || echo \'{"skipped":true,"reason":"sdk-failed"}\'); printf "%s" "$DRIFT"';
const out = execFileSync('bash', ['-c', 'export PATH=/nonexistent-empty-path; ' + oldForm], {
env: { ...process.env },
encoding: 'utf8',
});
assert.match(out, /sdk-failed/, 'sanity: the bare-binary form skips without gsd-tools on PATH — the bug the fix removes');
});
});
});
}