Files
msd-core/tests/policy-160-route0-resume.test.cjs
Tom Boucher 463cffd894 chore(#604): rename get-shit-done/ runtime directory to gsd-core/ (#615)
* chore(#604): rename get-shit-done/ runtime directory to gsd-core/

Renames the installed runtime directory `get-shit-done/` to `gsd-core/` so the
on-disk name matches the package (`@opengsd/gsd-core`), repo, and binary
(`gsd-tools`). The npm package name and binary are unchanged; npx/npm consumers
are unaffected.

Mechanical (bulk, ~90% of the diff):
- `git mv get-shit-done gsd-core`
- Swept path/identifier references across the repo via
  `perl -pe 's/get-shit-done(?!-\w)/gsd-core/g'`. The negative lookahead
  preserves the five legitimate slug variants that are NOT the directory:
  get-shit-done-{OLD,cc,classic,cli,redux} (old package/repo names).
- Build/manifest wiring: package.json (bin, files, coverage globs),
  tsconfig.build.json (outDir), ~86 .gitignore build-output entries,
  stryker.config.mjs, scan-ignore files, install.js path strings.
- Frozen (not rewritten): CHANGELOG.md history; translated docs
  (README.<locale>.md and docs/{ja-JP,ko-KR,pt-BR,zh-CN}/).

New logic (review here):
- src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts: a proper
  ADR-0008 installer migration. On upgrade it walks the legacy
  `~/.claude/get-shit-done/` tree, classifies each file via the prior install
  manifest, and emits remove-managed / backup-and-remove for managed files
  while PRESERVING unknown user-added files. Symlink-safe (skips a symlinked
  root and symlinked entries; bounds-checks every path under configDir). The
  framework rolls back on install failure. Emptied dirs may remain (framework
  has no recursive dir-removal primitive) — documented.
- scripts/lint-legacy-dir-name.cjs: CI regression guard forbidding the bare
  `get-shit-done` directory token (split token to avoid self-match; case-
  insensitive; `(?!-\w)` lookahead allows the slug variants; allowlists
  CHANGELOG, translated docs, and `gsd-allow-legacy-name` marker lines).
  Wired into the lint-tests CI job.
- Restored scripts/lint-package-identity-drift.cjs detection regexes (the
  mechanical sweep had wrongly rewritten the old-name patterns it exists to
  detect) and marked them as intentional legacy references.
- TDD tests for the migration and the guard; do.md slash-command guard regex
  tightened so a `/gsd-core/bin` path segment is not mistaken for a command;
  changeset + docs/installer-migrations.md row added.

Breaking: the installed runtime path moves `~/.claude/get-shit-done/` ->
`~/.claude/gsd-core/`. Migration 003 removes the stale legacy dir's managed
files (preserving user files) on upgrade. Users with custom hooks/configs
hardcoding the old path must update them.

Closes #604

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): unsweep pending changesets + allowlist injection-example docs

CI fixes for the rename PR:
- Do not sweep pending .changeset/*.md (ephemeral release-note fragments,
  like CHANGELOG); reverted those body edits so 5 pre-existing malformed
  fragments (missing type/pr) no longer enter the PR diff and trip docs-lint.
  Allowlisted .changeset/ in the legacy-name guard accordingly.
- Allowlisted TEST-EXAMPLES.md and docs/explanation/security-model.md in
  prompt-injection-scan.sh: they contain intentional injection examples /
  security-model prose; the path-reference rewrites are kept.

CodeQL alerts on this PR are pre-existing (alert lines unchanged by this PR;
none in the new migration/guard) and are out of scope for the rename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): resolve CodeQL alerts surfaced on this PR

The rename diff touched files carrying pre-existing CodeQL findings; per the
no-pre-existing-dismissal rule, fixing every surfaced alert rather than waving
them off. All behavior-preserving:

- scripts/ci-test-scope.cjs: build the config-path match from string
  .includes() instead of a RegExp over an arg-derived value (js/regex-injection).
- src/profile-output.cts: escape backslashes before pipe-escaping desc/safeName
  so the table-cell escape is complete (js/incomplete-sanitization).
- tests/{bug-2643,bug-2808,docs-parity-live-registry}: two-pass HTML-comment
  strip so a bare/unclosed `<!--` cannot survive (js/incomplete-multi-character-sanitization).
- tests/inline-plan-threshold: drop the no-op `\s`->`\s` identity replace,
  keep the meaningful POSIX-class conversion (js/identity-replacement).

Verified: build:lib green; the touched test files + ci-test-scope + profile-output
suites pass; lint:legacy-name clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): correctly resolve remaining CodeQL alerts (regex-injection + sanitization)

The prior commit's fixes for two alerts were ineffective:
- ci-test-scope.cjs js/regex-injection: the alert is the CLI-arg-derived `file`
  reaching static regex `.test(file)` calls (not the config rule). Removed ALL
  regex over file/t — startsWith/includes/=== string checks + an isWindowsHint
  helper — so there is no regex sink for the tainted value.
- js/incomplete-multi-character-sanitization (3 test files): a single
  `.replace(/<!--...-->/g,'')` can let `<!--` re-form. Replaced with a fixpoint
  loop (replace until stable) plus a final bare-opener strip.

Verified: no regex over file/t remains; ci-test-scope + the 3 test suites pass;
lint:legacy-name clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): make ci-test-scope + comment-strippers regex-free to clear CodeQL

CodeQL flags the regex PATTERNS syntactically (regex-injection on the
--files arg split; incomplete-multi-character-sanitization on the <!--...-->
replace), so loop fixes do not satisfy it. Made these paths regex-free:
- ci-test-scope.cjs splitFiles: char-by-char separator tokenizer (no /[,\\s]+/).
- 3 test files: indexOf/slice HTML-comment stripper (no .replace(/<!--/)).
Behavior preserved; ci-test-scope + the 3 suites pass; guard clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): unblock security base64 scan on the large rename diff

The security job hit its 10m timeout: base64-scan.sh choked on the binary
test fixture tests/feat-3594-parser-property-style.test.cjs (embedded NUL/
non-UTF8 bytes -> thousands of bogus blobs + "ignored null byte" warnings),
and the ~800-file rename diff is slow to scan regardless.

- scripts/base64-scan.sh: skip binary-by-content files (grep -Iq .) — they
  can't carry base64-obfuscated *text* and feeding NUL bytes through the
  per-line scanner is pathologically slow. collect_files already filtered
  binary *extensions*; this catches binary *content* in text extensions.
- .github/workflows/security-scan.yml: raise the security job timeout 10m->30m
  to accommodate very large diffs (the scan itself is unchanged).

Verified locally: scan skips the fixture, 0 "ignored null byte" warnings,
0 findings, exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): sweep get-shit-done refs introduced by merging next

The branch was updated with next (#614/#384/#618 etc.), which reference the
get-shit-done/ dir (still named that on next). Swept the stale references in
the merged files to gsd-core so the rename stays consistent and lint:legacy-name
passes:
- commands/gsd/discuss-phase.md (runtime-launcher shim paths)
- src/core.cts (getAgentsDir layout comments)
- tests/bug-384-agents-runtime-aware.test.cjs (require path to runtime lib)

Verified: guard 0 violations; build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): exclude gsd-core/ path segments from bug-3683 command cross-ref invariant

The #614 runtime-launcher shim added to discuss-phase.md references
`${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`. bug-3683's REF_PATTERN excluded path-y
refs only via lookbehind, but `}` precedes `/gsd-core/` in the shim, so it
mis-read the directory path as a dangling `/gsd-core` command ref (same class as
the #604 bug-2954 fix). Added a trailing `(?![\w-]*\/)` so `/gsd-<x>/...` path
segments are not treated as slash-command references.

Verified locally on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22 image) full suite: 0 failures
- bug-3683 + bug-2954 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): lazily resolve findProjectRoot in gsd-tools (harden flaky CI)

CI intermittently failed state.test's gsd-tools subprocess with
"findProjectRoot is not a function" (flip-flopping across legs; not reproducible
on mac full suite, gsd-test linux full suite, test:unit, or state.test x8).
findProjectRoot is a re-export from core.cjs (sourced from project-root.cjs);
binding it via destructure at module-load can be undefined under a load-ordering
edge. Resolve it lazily at call time via a small wrapper so the lookup happens
after core.cjs is fully initialized.

Verified green on BOTH platforms before pushing:
- mac (node 26) full suite: 0 failures
- gsd-test-runner (linux, node22) full suite: 0 failures
- state.test.cjs: 106/106; gsd-tools loads cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#604): allowlist verification-patterns.md placeholder examples in secret scan

The rename git-mv'd references/verification-patterns.md into gsd-core/, pulling
it into the secret-scan diff. It documents stub/placeholder RED-FLAG env-var
examples (illustrative Stripe test-key / database-URL / API-key placeholders) —
not real credentials. Added it to .secretscanignore with the strict annotation,
mirroring the existing gsd-core/workflows/plan-phase.md exception.

Verified locally: secret-scan-lint --strict OK; secret-scan --diff origin/next
exits 0 with 0 findings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:35:29 -04:00

425 lines
20 KiB
JavaScript

// allow-test-rule: source-text-is-the-product
// Workflow .md / agent .md / command .md / reference .md files — their text
// IS what the runtime loads. Testing text content tests the deployed contract.
// Per CONTRIBUTING.md exception matrix.
/**
* GSD Tools Tests - Route 0 resume-incomplete-phase invariant (#160)
*
* Validates that BOTH next.md and progress.md contain the Route 0 cross-phase
* incomplete-execution scan that runs BEFORE any current_phase-based routing.
* This prevents the data-loss scenario where a crashed session advances
* current_phase past a phase that has PLAN.md files without matching
* SUMMARY.md files, causing /gsd-next or /gsd-progress to silently skip
* partially-executed work.
*
* Closes: #160
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
describe('Route 0: resume_incomplete_phase invariant (#160)', () => {
const nextMdPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'next.md');
const progressMdPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'progress.md');
// ── next.md ───────────────────────────────────────────────────────────────
describe('next.md', () => {
test('contains a resume_incomplete_phase step', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
assert.ok(
content.includes('name="resume_incomplete_phase"'),
'next.md must have a step named resume_incomplete_phase (Route 0)'
);
});
test('resume_incomplete_phase step appears BEFORE determine_next_action', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
const route0Idx = content.indexOf('name="resume_incomplete_phase"');
const routeIdx = content.indexOf('name="determine_next_action"');
assert.ok(route0Idx > -1, 'resume_incomplete_phase step must exist');
assert.ok(routeIdx > -1, 'determine_next_action step must exist');
assert.ok(
route0Idx < routeIdx,
'resume_incomplete_phase (Route 0) must appear before determine_next_action'
);
});
test('resume_incomplete_phase step appears AFTER safety_gates', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
const gatesIdx = content.indexOf('name="safety_gates"');
const route0Idx = content.indexOf('name="resume_incomplete_phase"');
assert.ok(gatesIdx > -1, 'safety_gates step must exist');
assert.ok(route0Idx > -1, 'resume_incomplete_phase step must exist');
assert.ok(
gatesIdx < route0Idx,
'resume_incomplete_phase must appear after safety_gates'
);
});
test('scans ALL phases (not just current_phase) for incomplete execution', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
// Must describe a cross-phase scan independent of current_phase
assert.ok(
content.includes('Scan ALL phases') || content.includes('scan ALL phases'),
'Route 0 in next.md must scan ALL phases, not just current_phase'
);
});
test('detects plans without summaries across all phases', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
assert.ok(
content.includes('plans.length > summaries.length') ||
content.includes('plans without summaries') ||
content.includes('plans-without-summaries'),
'Route 0 in next.md must detect phases where plans outnumber summaries'
);
});
test('routes to lowest-numbered incomplete phase via execute-phase', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
assert.ok(
content.includes('INCOMPLETE_PHASE'),
'Route 0 must record the lowest incomplete phase number'
);
assert.ok(
content.includes('gsd-execute-phase') || content.includes('gsd:execute-phase'),
'Route 0 must route to execute-phase to resume the incomplete phase'
);
});
test('provides --no-resume opt-out', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
assert.ok(
content.includes('--no-resume'),
'Route 0 in next.md must provide --no-resume opt-out'
);
});
test('--force also bypasses Route 0', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
// The --force skip must be mentioned within or near the Route 0 step
const route0Start = content.indexOf('name="resume_incomplete_phase"');
const route0End = content.indexOf('</step>', route0Start);
const route0Block = content.slice(route0Start, route0End);
assert.ok(
route0Block.includes('--force'),
'Route 0 step in next.md must mention --force as a bypass'
);
});
test('success_criteria includes Route 0 entry', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
assert.ok(
content.includes('Route 0') || content.includes('resume_incomplete_phase'),
'success_criteria must reference the Route 0 / resume_incomplete_phase invariant'
);
});
test('explains why Route 0 must precede current_phase routing', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
// Must contain the rationale: invariant independent of current_phase
assert.ok(
content.includes('independent of') ||
content.includes('before any routing rule that reads current_phase') ||
content.includes('before any current-phase'),
'Route 0 must explain it is independent of current_phase value'
);
});
// ── SHOULD-FIX 1: Route 0 ordering relative to prior-phase defer prompt ──
test('resume_incomplete_phase runs BEFORE the prior-phase defer prompt (no double-decision)', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
// Route 0 must be ordered BEFORE the step that contains the prior-phase defer prompt.
// After the rework, the prior-phase defer prompt lives in prior_phase_completeness.
const route0Idx = content.indexOf('name="resume_incomplete_phase"');
const deferPromptIdx = content.indexOf('name="prior_phase_completeness"');
assert.ok(route0Idx > -1, 'resume_incomplete_phase step must exist');
assert.ok(
deferPromptIdx > -1,
'prior_phase_completeness step must exist (holds the C/S/F defer prompt)'
);
assert.ok(
route0Idx < deferPromptIdx,
'resume_incomplete_phase (Route 0) must appear BEFORE prior_phase_completeness in next.md — prevents double-decision'
);
});
test('safety_gates contains ONLY Gates 1-3 (no prior-phase defer prompt)', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
// Extract just the safety_gates step body
const gatesStart = content.indexOf('<step name="safety_gates">');
const gatesEnd = content.indexOf('</step>', gatesStart);
const gatesBlock = content.slice(gatesStart, gatesEnd);
// The defer prompt's C/S/F options must NOT be inside safety_gates
assert.ok(
!gatesBlock.includes('[C] Continue and defer'),
'safety_gates must not contain the C/S/F prior-phase defer prompt — that belongs in prior_phase_completeness'
);
// Gates 1-3 must still be present
assert.ok(gatesBlock.includes('Gate 1'), 'safety_gates must still contain Gate 1');
assert.ok(gatesBlock.includes('Gate 2'), 'safety_gates must still contain Gate 2');
assert.ok(gatesBlock.includes('Gate 3'), 'safety_gates must still contain Gate 3');
});
test('--no-resume routes to prior_phase_completeness (not silently skips)', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
const route0Start = content.indexOf('name="resume_incomplete_phase"');
const route0End = content.indexOf('</step>', route0Start);
const route0Block = content.slice(route0Start, route0End);
// When --no-resume is passed, must send user to prior_phase_completeness (the defer prompt)
assert.ok(
route0Block.includes('prior_phase_completeness'),
'Route 0 step must reference prior_phase_completeness as the --no-resume/--force fallthrough path'
);
});
// ── --force flag-flow coherence (SHOULD-FIX: contradictory semantics) ──
test('prior_phase_completeness does NOT claim to run under --force', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
const ppcStart = content.indexOf('name="prior_phase_completeness"');
const ppcEnd = content.indexOf('</step>', ppcStart);
const ppcBlock = content.slice(ppcStart, ppcEnd);
// The step header must not say it runs "via --force" or "or --force"
assert.ok(
!ppcBlock.includes('--no-resume` or `--force`') &&
!ppcBlock.includes('--force`, or'),
'prior_phase_completeness must NOT claim to run when --force is passed ' +
'(--force jumps directly to determine_next_action at safety_gates)'
);
});
test('resume_incomplete_phase does NOT route --force through to prior_phase_completeness', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
const route0Start = content.indexOf('name="resume_incomplete_phase"');
const route0End = content.indexOf('</step>', route0Start);
const route0Block = content.slice(route0Start, route0End);
// Must NOT say "--force or --no-resume … proceed to prior_phase_completeness"
assert.ok(
!route0Block.includes('`--force` or `--no-resume`') &&
!route0Block.includes('--force` was passed.** On those flags, proceed directly to `prior_phase_completeness`'),
'resume_incomplete_phase must NOT route --force to prior_phase_completeness; ' +
'--force already jumped to determine_next_action at safety_gates'
);
});
test('safety_gates --force description makes explicit it skips Route 0 and prior_phase_completeness', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
const gatesStart = content.indexOf('<step name="safety_gates">');
const gatesEnd = content.indexOf('</step>', gatesStart);
const gatesBlock = content.slice(gatesStart, gatesEnd);
// The --force jump description must mention Route 0 / prior_phase_completeness skip
assert.ok(
(gatesBlock.includes('Route 0') || gatesBlock.includes('prior-phase completeness')) &&
gatesBlock.includes('determine_next_action'),
'safety_gates --force description must explicitly state it skips Route 0 and/or ' +
'prior_phase_completeness and routes to determine_next_action'
);
});
test('success_criteria --force entry routes straight to determine_next_action (not prior_phase_completeness)', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
const scStart = content.indexOf('<success_criteria>');
const scEnd = content.indexOf('</success_criteria>', scStart);
const scBlock = content.slice(scStart, scEnd);
// Must have a --force criterion pointing to determine_next_action
assert.ok(
scBlock.includes('--force') && scBlock.includes('determine_next_action'),
'success_criteria must document --force as routing to determine_next_action'
);
// Must NOT group --force with --no-resume as both triggering prior_phase_completeness
assert.ok(
!scBlock.includes('`--no-resume`/`--force`: Route 0 skipped, prior_phase_completeness'),
'success_criteria must not conflate --force and --no-resume as both running prior_phase_completeness'
);
});
// ── SHOULD-FIX 2: SDK form and fail-closed error surfacing ──
test('scan uses gsd_run (canonical resolver form, not bare gsd-sdk)', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
const route0Start = content.indexOf('name="resume_incomplete_phase"');
const route0End = content.indexOf('</step>', route0Start);
const route0Block = content.slice(route0Start, route0End);
// Must use gsd_run, not bare gsd-sdk
assert.ok(
route0Block.includes('gsd_run'),
'Route 0 scan must use gsd_run (canonical resolver), not bare gsd-sdk'
);
// Must NOT use bare gsd-sdk (without $)
const bareGsdSdkPattern = /(?<!\$)gsd-sdk/;
assert.ok(
!bareGsdSdkPattern.test(route0Block),
'Route 0 must not use bare gsd-sdk — use gsd_run to match the file convention'
);
});
test('scan does NOT silently suppress errors with 2>/dev/null on the main query', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
const route0Start = content.indexOf('name="resume_incomplete_phase"');
const route0End = content.indexOf('</step>', route0Start);
const route0Block = content.slice(route0Start, route0End);
// The roadmap.analyze call must not pipe to /dev/null (which causes fail-open data loss)
assert.ok(
!route0Block.includes('roadmap.analyze 2>/dev/null') &&
!route0Block.includes('roadmap.analyze --pick phases 2>/dev/null'),
'Route 0 must not suppress roadmap.analyze errors with 2>/dev/null — failure must surface, not fail open'
);
});
test('scan surfaces an error warning when roadmap query fails (fail-closed)', () => {
const content = fs.readFileSync(nextMdPath, 'utf8');
const route0Start = content.indexOf('name="resume_incomplete_phase"');
const route0End = content.indexOf('</step>', route0Start);
const route0Block = content.slice(route0Start, route0End);
// Must emit a warning when the scan cannot run — not silently no-op
assert.ok(
route0Block.includes('WARNING') || route0Block.includes('could not run') || route0Block.includes('could not be verified'),
'Route 0 must emit a warning when the incomplete-phase scan fails, not silently proceed as if no incomplete phase exists'
);
});
});
// ── progress.md ───────────────────────────────────────────────────────────
describe('progress.md', () => {
test('contains a Step 0 / Route 0 resume-incomplete-phase invariant', () => {
const content = fs.readFileSync(progressMdPath, 'utf8');
assert.ok(
content.includes('Step 0') || content.includes('Route 0'),
'progress.md route step must contain a Step 0 / Route 0 invariant'
);
});
test('Route 0 appears BEFORE Step 1 (current-phase counting)', () => {
const content = fs.readFileSync(progressMdPath, 'utf8');
const route0Idx = content.search(/Step 0[^9]/);
const step1Idx = content.search(/Step 1:/);
assert.ok(route0Idx > -1, 'Step 0 invariant must exist in progress.md');
assert.ok(step1Idx > -1, 'Step 1 current-phase counting must exist');
assert.ok(
route0Idx < step1Idx,
'Step 0 (Route 0) must appear before Step 1 in progress.md'
);
});
test('scans all phases for incomplete execution before current-phase routing', () => {
const content = fs.readFileSync(progressMdPath, 'utf8');
assert.ok(
content.includes('scan ALL phases') || content.includes('Scan ALL phases') ||
content.includes('scan all phases') || content.includes('all phases for incomplete'),
'Route 0 in progress.md must scan ALL phases (not just current_phase)'
);
});
test('detects plans without summaries across all phases', () => {
const content = fs.readFileSync(progressMdPath, 'utf8');
assert.ok(
content.includes('plans.length > summaries.length') ||
content.includes('plans without summaries') ||
content.includes('plans-without-summaries'),
'Route 0 in progress.md must detect phases where plans outnumber summaries'
);
});
test('routes to the lowest incomplete phase via execute-phase', () => {
const content = fs.readFileSync(progressMdPath, 'utf8');
assert.ok(
content.includes('INCOMPLETE_PHASE'),
'Route 0 in progress.md must record the lowest incomplete phase number'
);
assert.ok(
content.includes('gsd-execute-phase') || content.includes('gsd:execute-phase'),
'Route 0 in progress.md must route to execute-phase'
);
});
test('provides --no-resume opt-out', () => {
const content = fs.readFileSync(progressMdPath, 'utf8');
assert.ok(
content.includes('--no-resume'),
'Route 0 in progress.md must provide --no-resume opt-out'
);
});
test('--force also bypasses Route 0', () => {
const content = fs.readFileSync(progressMdPath, 'utf8');
// Find Route 0 block and verify --force is mentioned
const route0Start = content.indexOf('Step 0: Resume-incomplete-phase');
assert.ok(route0Start > -1, 'Route 0 step text must exist');
const route0End = content.indexOf('Step 1:', route0Start);
const route0Block = content.slice(route0Start, route0End);
assert.ok(
route0Block.includes('--force'),
'Route 0 in progress.md must mention --force as a bypass'
);
});
test('does not proceed to Step 1 when incomplete phase found', () => {
const content = fs.readFileSync(progressMdPath, 'utf8');
// Must explicitly state that Steps 1-F are skipped when Route 0 fires
assert.ok(
content.includes('Do NOT run Steps 1') ||
content.includes('exit the route step') ||
content.includes('Do not run Step 1'),
'Route 0 in progress.md must exit before Steps 1-F when an incomplete phase is found'
);
});
test('explains rationale: current_phase may have been advanced past unfinished work', () => {
const content = fs.readFileSync(progressMdPath, 'utf8');
assert.ok(
content.includes('advanced past') ||
content.includes('current_phase was advanced') ||
content.includes("current_phase' was advanced"),
'Route 0 in progress.md must explain the current_phase-advanced-past-unfinished scenario'
);
});
// ── SHOULD-FIX 2: SDK form and fail-closed error surfacing (progress.md) ──
test('scan uses $ROADMAP already loaded (not a fresh bare gsd-sdk call)', () => {
const content = fs.readFileSync(progressMdPath, 'utf8');
const route0Start = content.indexOf('Step 0: Resume-incomplete-phase');
const route0End = content.indexOf('Step 1:', route0Start);
const route0Block = content.slice(route0Start, route0End);
// progress.md convention: data comes from $ROADMAP already loaded by analyze_roadmap
assert.ok(
route0Block.includes('$ROADMAP'),
'Route 0 in progress.md must use the $ROADMAP variable already loaded, not issue a fresh bare SDK call'
);
});
test('scan surfaces an error warning when $ROADMAP is empty (fail-closed)', () => {
const content = fs.readFileSync(progressMdPath, 'utf8');
const route0Start = content.indexOf('Step 0: Resume-incomplete-phase');
const route0End = content.indexOf('Step 1:', route0Start);
const route0Block = content.slice(route0Start, route0End);
// Must NOT silently no-op when $ROADMAP is empty — must surface a warning
assert.ok(
route0Block.includes('WARNING') || route0Block.includes('could not run') || route0Block.includes('could not be verified'),
'Route 0 in progress.md must emit a warning when $ROADMAP is empty, not silently proceed as if no incomplete phase exists'
);
});
test('predicate uses plans-without-summaries consistent with determine_next_action Route 4', () => {
const content = fs.readFileSync(progressMdPath, 'utf8');
const route0Start = content.indexOf('Step 0: Resume-incomplete-phase');
const route0End = content.indexOf('Step 1:', route0Start);
const route0Block = content.slice(route0Start, route0End);
// Must use the same predicate: plans.length > summaries.length
assert.ok(
route0Block.includes('plans.length > summaries.length') ||
route0Block.includes('plans without summaries') ||
route0Block.includes('plans-without-summaries'),
'Route 0 in progress.md must use plans-without-summaries predicate consistent with determine_next_action Route 4'
);
});
});
});