Files
msd-core/tests/roadmap.test.cjs
Tom Boucher 929e02cb2c enhance(#3885): no silent swallow, and no verdict manufactured from dropped data (#3925)
* test(#3885): failing-first coverage for the depth bound and the manufactured wave verdict

ADR-3473 §8.5 says a swallowed failure may not become an authoritative-looking
answer. Three families do exactly that today; this commit pins each one RED.

Measured on this tree, 2026-08-27:

  intel query, .planning/intel/file-roles.json nested 12000 deep
    -> exit 1, "Error: Maximum call stack size exceeded"
       searchJsonEntries / matchesInValue carry no depth parameter at all.
       The MAX_JSON_SEARCH_DEPTH = 48 bound existed in the retired SDK lineage
       (sdk/src/query/intel.ts at 11918dcc3^) and the surviving .cts lineage
       never received it.

  same fixture nested 48 and 49 deep
    -> both return total=1 at exit 0, truncated=undefined
       Nothing distinguishes "searched to the bottom" from "stopped looking".

  query phase-plan-index, a plan whose depends_on names an unresolvable token
    -> warnings: ["Plan 03-02: declared wave: 2 but depends_on DAG places it
                  in wave 1"]
       The token is never mentioned. computeDependencyLevels drops the edge
       with `if (!resolvedDep) continue;`, every plan becomes a root, and the
       tool then reports the author's correct wave: as the thing that is wrong.

  countPhasePlansAndSummaries with fs.readdirSync throwing EACCES
    -> hasContext:false, indistinguishable from a phase that simply has no
       CONTEXT.md. context_read_error is undefined.

The shapes these tests assert against, chosen here so the implementation has a
target rather than inventing one later: `truncated: boolean` on the intel query
result, `unresolved: Array<{plan, token}>` from computeDependencyLevels, and
`context_read_error: string | null` per analyzed phase.

Deliberately green, and they must stay that way — each stops the fix from
over-firing:

  depth 48 is found and NOT flagged truncated (the ceiling is inclusive)
  a shallow miss reports no truncation           (noise control, N1)
  10,000 siblings at depth 2 are unaffected      (the bound is DEPTH, N2)
  a genuine wave: mismatch on a fully-resolved DAG still warns (N3)
  a genuinely missing directory is absent, not an error
  the emitted depends_on display mapping still passes an unresolved token
    through verbatim — already pinned by the existing #3785 test, so no
    duplicate was added

T31 asserts at the consumer's output per ADR-3180 Decision 4(b): it runs the
real CLI and reads the emitted JSON, because a unit assertion on
computeDependencyLevels would have passed throughout #3427's life.

Design:      .gsd/phase/feat-3885-no-silent-swallow/40-design.md
Test matrix: .gsd/phase/feat-3885-no-silent-swallow/50-test-matrix.md

Refs #3885

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

* enhance(#3885): no silent swallow, and no verdict manufactured from dropped data

Implements ADR-3473 §8.5. A failure or a gap in the input stops being absorbed
into an output that reads as authoritative.

The recursion bound, restored but NOT verbatim (src/intel.cts)

  MAX_JSON_SEARCH_DEPTH = 48 is threaded through searchJsonEntries and
  matchesInValue, which carried no depth parameter at all. The bound existed in
  the retired SDK lineage (sdk/src/query/intel.ts at 11918dcc3^) and the
  surviving .cts lineage never received it — §8.3's "a consolidation may not
  delete an invariant along with the surface that held it", demonstrated.

  Measured before: a .planning/intel file nested 12000 deep exits 1 with
  "Error: Maximum call stack size exceeded". Reachable from a project document.

  The original returned a bare `false` at the ceiling. Restoring that verbatim
  would trade a crash for a silent "no match" when the truth is "I stopped
  looking" — the same class this epic exists to close, and ADR-3473 Decision 4
  forbids it. So the bound carries a truncation signal:

    nesting 47 -> found,     truncated false
    nesting 48 -> found,     truncated false      (the ceiling is inclusive)
    nesting 49 -> not found, truncated TRUE
    nesting 12000 -> exit 0, truncated TRUE, no RangeError

  A shallow document that simply has no match reports truncated FALSE — the
  flag means "I stopped early", never "I found nothing", or it would be noise.
  The bound is on DEPTH: 10,000 siblings at depth 2 are unaffected.

The dropped edge is named, and stops being blamed on the author (src/phase.cts)

  computeDependencyLevels dropped every unresolvable depends_on token with a
  bare `continue`. Each drop makes a plan a root, so the whole phase collapses
  to wave 1 — and cmdPhasePlanIndex then reported the author's CORRECT wave: as
  the thing that was wrong.

  Before:
    warnings: ["Plan 03-02: declared wave: 2 but depends_on DAG places it in
                wave 1"]
  After:
    warnings: ["Plan 03-02: depends_on token \"nonexistent-token-3427\" does not
                resolve to any plan in this phase — edge dropped, wave placement
                for this plan may be unreliable"]

  The suppression is PER PLAN, never blanket: a plan with a fully-resolved DAG
  and a genuinely wrong wave: still gets the mismatch warning. resolveDependencyId
  stays two-tier — the shortFormToId third tier is §8.3/Phase 6's rule and is
  deliberately not built here. The emitted depends_on display mapping still
  passes an unresolved token through verbatim (#3785).

No artifact from failed inputs (gsd-core/workflows/review.md, #3352)

  A failed lane leaves no result file, so "every lane failed" is exactly "the
  aggregate JSONL has zero lines" — the gate condition already existed as a
  byproduct. REVIEWS.md is no longer written in that case, and the commit step
  is skipped with it. A budget-SKIPPED lane also leaves no file and is NOT
  counted as a failure. Per-lane output and non-empty .err are preserved to
  .review-diagnostics/ before `rm -rf "{run_dir}"` destroys the only record that
  the lanes failed at all; the commit step names one file, never a glob, so the
  diagnostics are not swept in.

Unreadable is not absent (roadmap.cts, gap-checker.cts, init.cts x2)

  Four callers collapsed an EACCES on a phase directory into [] and reported
  hasContext:false — byte-identical to a phase that simply has no CONTEXT.md.
  Each now names the directory it could not read. A genuinely missing directory
  stays absent rather than becoming an error, which is what keeps the fix from
  over-firing.

Fatal errno folded into a retry set: audited, no defect found

  Reported as a verified negative rather than padded with a change.
  withPlanningLock was fixed by #1884/PR #3472; acquireStateLock by #3776;
  atomicRenameWithRetry and estimate-cli's renameWithRetry are correct by
  construction — bounded set {EPERM,EBUSY,EACCES}, bounded attempts, and they
  return or rethrow the final error rather than swallowing it. estimate-cli's
  sole caller surfaces that rethrow as write_error in its JSON output.
  Manufacturing a diff to make the checkbox look worked-on is the Goodhart
  outcome Decision 6 exists to prevent.

Disclosed: R46 (the commit step names one file, never a glob) is a real
regression guard but is NOT independently failing-first — the commit fence is
byte-identical pre- and post-fix, so it only fails pre-fix through its shared
extraction dependency. Recorded rather than claimed as fail-first.

Design:      .gsd/phase/feat-3885-no-silent-swallow/40-design.md
Test matrix: .gsd/phase/feat-3885-no-silent-swallow/50-test-matrix.md

Refs #3885

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

* fix(#3885): escape untrusted tokens, and stop cleanup destroying unpreserved evidence

Two review findings, both real, both in my own change.

An isolated adversarial review found the evidence-preservation block never
checked mkdir/cp exit status while `rm -rf "{run_dir}"` ran unconditionally in
a SEPARATE fenced block. A disk-full or unwritable phase directory therefore
still destroyed the only copy of the failed lanes' output — reintroducing the
exact #3352 data loss this item exists to stop, inside the fix for it.

Preservation and cleanup are now one block, because each fenced block is a
separate execution and a shell variable cannot carry between them. mkdir -p and
each cp are exit-checked; cleanup runs only when preservation succeeded, and a
failure warns naming the intact run directory. "Nothing to preserve" is not a
failure and still cleans up. Driven three ways: success removes run_dir, failure
leaves it intact with the warning, nothing-to-preserve removes it. The failure is
induced by a file-vs-directory conflict rather than chmod 0o000, which root
bypasses.

The new unresolved-depends_on warning embedded a user-authored token verbatim:

  warnings: ["Plan 03-02: depends_on token \"evil
  Plan 03-01: FORGED WARNING\" does not resolve ..."]

The JSON wire form is safe, and the security reviewer judged it non-exploitable
for that reason. It is escaped anyway through formatDiagnosticToken — the helper
#3884 added one phase earlier for exactly this class. warnings[] is an array a
consumer naturally prints line by line, and not reusing the sibling fix is the
generative-fix-divergence shape this epic exists to close. The same treatment is
applied to context_read_error / phase_dir_read_error, which embed a phase
directory path a repository can choose, and to the fs error message, which
echoes the raw path itself.

Known limit L5 recorded: the bound is on DEPTH only. A 300,000-element shallow
array yields a 14.5MB reply with truncated:false. Correct per §8.5 and per
negative space N2, disclosed rather than left to be discovered.

Refs #3885

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

* fix(#3885): unreadable is not absent in intel.cts either, and a corrupt snapshot is not "no snapshot"

Blocker from the round-2 isolated review, and it is my own inconsistency:
this phase applied "unreadable is not absent" to phase directories and left it
broken in the file it was already editing.

  chmod 000 .planning/intel/file-roles.json
  gsd-tools intel query <term>
  -> {"matches":[],"total":0,"truncated":false}  exit 0

safeReadJson swallowed every read failure and returned null, so an EACCES was
byte-indistinguishable from an absent file AND from a genuine no-match. Now it
separates three states: ENOENT stays silently absent, because not every project
has every intel file and intelQuery loops over all of them expecting misses;
EACCES/EIO and malformed JSON are both surfaced naming the file. A corrupt intel
file previously read as "no matches" too — same defect, same fix.

Threading that outcome through the other three callers found something worse
than the reported case. intelDiff returned no_baseline:true for a corrupt or
unreadable snapshot — not a silent failure but an actively FALSE verdict, telling
the caller they never took a snapshot when they did. That is §8.5's headline
case, so it is fixed and tested rather than noted. intelStatus and
intelApiSurface collapsed the same way; intelApiSurface additionally printed a
"not yet populated" banner that was simply untrue.

Every row is failing-first, including the absent-file ones — the field is new,
so it does not exist pre-fix at all. Those rows are not pre-fix pins; they pin
that the fix does not OVER-fire on the ordinary absent case, which is what would
turn this into noise on every project lacking an intel file. IO failure is
injected by monkeypatching fs and restoring in finally, never chmod 0o000 — root
bypasses mode bits, so the reviewer's manual chmod repro is not reproducible as
a test.

Refs #3885

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

* test(#3885): build the pathological intel fixture as text, not by stringifying a nested object

The remote runner came back red on Linux with two failures, both
T4: deeplyNestedIntelDoesNotOverflowTheStack, while the same test passed on
macOS. The product was never at fault.

writeNestedFixture(12000) built a 12,000-deep JavaScript OBJECT and then
JSON.stringify'd it. JSON.stringify recurses once per level, so it overflowed
the TEST PROCESS's stack — the error was thrown before the CLI was ever spawned.
Linux's container stack is smaller than macOS's, which is the whole of the
platform difference.

Measured, with the same document built as JSON TEXT so nothing in the building
process recurses:

  depth=100    rc=0 truncated=true
  depth=5000   rc=0 truncated=true
  depth=12000  rc=0 truncated=true
  depth=60000  rc=0 truncated=true

V8 parses this shape iteratively; only stringify recurses. The bound works at
every depth tried.

The fixture is now built by string concatenation. That is also the more faithful
input — a real deeply nested JSON document on disk is exactly what the bound
guards, where a stringified object was only ever a way to produce one.

The depth stays 12000. Lowering it would have made the test pass by weakening it
to accommodate a fixture bug, and 12000 is a legitimate pathological input the
product handles. T4 remains a genuine fail-first: rebuilt against the parent of
the commit that added the bound, the string-built depth-12000 fixture still
drives the CLI to rc=1 with "Error: Maximum call stack size exceeded".

A comment records why the fixture is text, so it is not "simplified" back into a
macOS-green / Linux-red test.

Refs #3885

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

* chore(#3885): backfill the changeset PR number

Refs #3885

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

* test(#3885): normalize path separators before splicing into the workflow's bash

CI red on one lane — test (windows-latest, 24, shard 3/3). macOS, Linux and the
remote runner were all green.

  AssertionError: commit must name the single REVIEWS.md file; got:
    --files C:UsersRUNNER~1AppDataLocalTempgsd-3352-phasedir-mOKmuy/03-REVIEWS.md

Every backslash in C:\Users\RUNNER~1\AppData\Local\Temp\... was eaten. The
harness spliced an OS-native temp path into the extracted bash, and bash consumes
\U, \A, \L and \T as escapes on an unquoted expansion. The same loss broke
RUN_DIR, so "rm -rf" targeted a path that never existed and the run directory
survived — which is the other two assertions.

This is a fixture defect, not a product one, and that was checked rather than
assumed. In production the phase directory is toPosixPath-normalized at every
call site that serializes it (bin/lib/init.cjs:951, 1381, 1461, 1529, 1595), and
the run directory is created by "mktemp -d" running inside the bash block itself
(gsd-core/workflows/review.md:163), which emits POSIX-style output even under
Git-Bash on Windows. Neither ever carries a backslash where the workflow reads it.

The file's pre-existing #3034 harness splices raw native paths too, but only ever
inside double-quoted assignments, so it never tripped this — my new harness
followed that convention faithfully into the one place where it does not hold.
Both now splice through toPosixPath from shell-command-projection, the
established seam, which is a no-op on POSIX and mirrors what production does.

No assertion was weakened. "commit must name the single REVIEWS.md file" and
"the run dir must still be destroyed" still assert exactly that; only how the
fixture supplies its path changed. Nothing is skipped on Windows — a t.skip()
here would have hidden the question of whether the exposure was real, which is
the question that mattered.

Driven both ways: a synthetic C:\Users\RUNNER~1\... input reproduces the exact CI
string when unfixed and yields C:/Users/RUNNER~1/... when fixed; a POSIX input
produces a byte-identical shape, proving the normalization is idempotent.

Refs #3885

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

* test(#3885): stop the harness making the deleted run dir its own cwd

Windows shard 3/3 stayed red after the separator fix, on two assertions the
separator fix never touched:

  AssertionError: the run dir must still be destroyed
  AssertionError: nothing to preserve is not a failure — run dir must still be removed

The separators were a real bug and fixing them fixed the --files assertion. They
were not this bug, and two CI cycles went into the wrong axis before I stopped
converting path forms and looked at what the harness actually does.

runWriteReviewsFlow passed cwd: runDir to runHook, so the child bash process's
working directory WAS the directory the block under test then removes with
rm -rf "$RUN_DIR". POSIX allows a process to delete its own cwd — verified
locally, cd "$d"; rm -rf "$d" removes it cleanly — and Windows does not: a live
process's working directory cannot be removed. So on Windows the directory
survived and both assertions failed, on macOS and Linux it vanished and they
passed. Nothing to do with slashes.

Harness-only. Production never cd's into the run directory; every reference is by
absolute path, and RUN_DIR is created by mktemp -d inside the bash block itself
(gsd-core/workflows/review.md:165) rather than injected. review.md is unchanged.

Fix: the child now runs with its cwd in an unrelated temp directory that the
block under test never deletes. Neither assertion was weakened, and nothing is
skipped on Windows — the tests in this file carry no platform guard and run
there unconditionally, which is how this surfaced at all.

Honest limit: the Windows failure mode cannot be reproduced on macOS, because
POSIX permits the very thing Windows refuses. The diagnosis is grounded in that
documented divergence and in the fact that only the Windows lane failed, but the
green outcome on windows-latest is unverified until CI runs it.

Refs #3885

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

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 04:12:47 -04:00

4312 lines
177 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 - Roadmap
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('roadmap get-phase command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('extracts phase section from ROADMAP.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
## Phases
### Phase 1: Foundation
**Goal:** Set up project infrastructure
**Plans:** 2 plans
Some description here.
### Phase 2: API
**Goal:** Build REST API
**Plans:** 3 plans
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'phase should be found');
assert.strictEqual(output.phase_number, '1', 'phase number correct');
assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted');
assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted');
});
test('returns not found for missing phase', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
### Phase 1: Foundation
**Goal:** Set up project
`
);
const result = runGsdTools('roadmap get-phase 5', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, false, 'phase should not be found');
});
test('handles decimal phase numbers', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 2: Main
**Goal:** Main work
### Phase 2.1: Hotfix
**Goal:** Emergency fix
`
);
const result = runGsdTools('roadmap get-phase 2.1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'decimal phase should be found');
assert.strictEqual(output.phase_name, 'Hotfix', 'phase name correct');
assert.strictEqual(output.goal, 'Emergency fix', 'goal extracted');
});
test('extracts full section content', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Setup
**Goal:** Initialize everything
This phase covers:
- Database setup
- Auth configuration
- CI/CD pipeline
### Phase 2: Build
**Goal:** Build features
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(output.section.includes('Database setup'), 'section includes description');
assert.ok(output.section.includes('CI/CD pipeline'), 'section includes all bullets');
assert.ok(!output.section.includes('Phase 2'), 'section does not include next phase');
});
test('handles missing ROADMAP.md gracefully', () => {
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, false, 'should return not found');
assert.strictEqual(output.error, 'ROADMAP.md not found', 'should explain why');
});
test('accepts ## phase headers (two hashes)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
## Phase 1: Foundation
**Goal:** Set up project infrastructure
**Plans:** 2 plans
## Phase 2: API
**Goal:** Build REST API
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'phase with ## header should be found');
assert.strictEqual(output.phase_name, 'Foundation', 'phase name extracted');
assert.strictEqual(output.goal, 'Set up project infrastructure', 'goal extracted');
});
test('extracts goal when colon is outside bold (**Goal**: format)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.24
### Phase 5: Skill Scaffolding
**Goal**: The autonomous skill files exist following project conventions
**Plans:** 2 plans
### Phase 6: Smart Discuss
**Goal**: Grey area resolution works with proposals
`
);
const result = runGsdTools('roadmap get-phase 5', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'phase should be found');
assert.strictEqual(output.goal, 'The autonomous skill files exist following project conventions', 'goal extracted with colon outside bold');
});
test('extracts goal for both colon-inside and colon-outside bold formats', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Alpha
**Goal:** Colon inside bold format
### Phase 2: Beta
**Goal**: Colon outside bold format
`
);
const result1 = runGsdTools('roadmap get-phase 1', tmpDir);
const output1 = JSON.parse(result1.output);
assert.strictEqual(output1.goal, 'Colon inside bold format', 'colon-inside-bold goal extracted');
const result2 = runGsdTools('roadmap get-phase 2', tmpDir);
const output2 = JSON.parse(result2.output);
assert.strictEqual(output2.goal, 'Colon outside bold format', 'colon-outside-bold goal extracted');
});
test('detects malformed ROADMAP with summary list but no detail sections', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.0
## Phases
- [ ] **Phase 1: Foundation** - Set up project
- [ ] **Phase 2: API** - Build REST API
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, false, 'phase should not be found');
assert.strictEqual(output.error, 'malformed_roadmap', 'should identify malformed roadmap');
assert.ok(output.message.includes('missing'), 'should explain the issue');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase next-decimal command
// ─────────────────────────────────────────────────────────────────────────────
describe('roadmap analyze command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('missing ROADMAP.md returns error', () => {
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command should succeed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.error, 'ROADMAP.md not found');
});
test('parses phases with goals and disk status', () => {
// #3217 (ADR-3180 §7.6 rule 4): no version token — no STATE.md exists to
// resolve which milestone "v1.0" names, so a version-bearing title here
// would window as UNSCOPED (§7.1 row 4), not the free-form COMPLETE
// window this test's disk-status counting depends on.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Foundation
**Goal:** Set up infrastructure
### Phase 2: Authentication
**Goal:** Add user auth
### Phase 3: Features
**Goal:** Build core features
`
);
// Create phase dirs with varying completion
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
// Disk-strict completion (ADR-3180 §7.4, #3186): a passing
// *-VERIFICATION.md is what makes phase 1 count as complete now.
fs.writeFileSync(path.join(p1, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
const p2 = path.join(tmpDir, '.planning', 'phases', '02-authentication');
fs.mkdirSync(p2, { recursive: true });
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan');
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_count, 3, 'should find 3 phases');
assert.strictEqual(output.phases[0].disk_status, 'complete', 'phase 1 complete');
assert.strictEqual(output.phases[1].disk_status, 'planned', 'phase 2 planned');
assert.strictEqual(output.phases[2].disk_status, 'no_directory', 'phase 3 no directory');
assert.strictEqual(output.completed_phases, 1, '1 phase complete');
assert.strictEqual(output.total_plans, 2, '2 total plans');
assert.strictEqual(output.total_summaries, 1, '1 total summary');
assert.strictEqual(output.progress_percent, 50, '50% complete');
assert.strictEqual(output.current_phase, '2', 'current phase is 2');
});
test('extracts goals and dependencies', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Setup
**Goal:** Initialize project
**Depends on:** Nothing
### Phase 2: Build
**Goal:** Build features
**Depends on:** Phase 1
`
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].goal, 'Initialize project');
assert.strictEqual(output.phases[0].depends_on, 'Nothing');
assert.strictEqual(output.phases[1].goal, 'Build features');
assert.strictEqual(output.phases[1].depends_on, 'Phase 1');
});
test('extracts goals and depends_on with colon outside bold (**Goal**: format)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap v1.24
### Phase 5: Skill Scaffolding
**Goal**: The autonomous skill files exist following project conventions
**Depends on**: Phase 4 (v1.23 complete)
### Phase 6: Smart Discuss
**Goal**: Grey area resolution works with proposals
**Depends on**: Phase 5
`
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].goal, 'The autonomous skill files exist following project conventions', 'goal extracted with colon outside bold');
assert.strictEqual(output.phases[0].depends_on, 'Phase 4 (v1.23 complete)', 'depends_on extracted with colon outside bold');
assert.strictEqual(output.phases[1].goal, 'Grey area resolution works with proposals', 'second phase goal extracted');
assert.strictEqual(output.phases[1].depends_on, 'Phase 5', 'second phase depends_on extracted');
});
test('handles mixed colon-inside and colon-outside bold formats in analyze', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Alpha
**Goal:** Colon inside bold
**Depends on:** Nothing
### Phase 2: Beta
**Goal**: Colon outside bold
**Depends on**: Phase 1
`
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].goal, 'Colon inside bold', 'colon-inside goal works');
assert.strictEqual(output.phases[0].depends_on, 'Nothing', 'colon-inside depends_on works');
assert.strictEqual(output.phases[1].goal, 'Colon outside bold', 'colon-outside goal works');
assert.strictEqual(output.phases[1].depends_on, 'Phase 1', 'colon-outside depends_on works');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap analyze disk status variants
// ─────────────────────────────────────────────────────────────────────────────
describe('roadmap analyze disk status variants', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('returns researched status for phase dir with only RESEARCH.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Exploration
**Goal:** Research the domain
`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-exploration');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-RESEARCH.md'), '# Research notes');
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].disk_status, 'researched', 'disk_status should be researched');
assert.strictEqual(output.phases[0].has_research, true, 'has_research should be true');
});
test('#3511-class: roadmap analyze has_research/has_context ignore another phase\'s misplaced artifact', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Setup
**Goal:** Build the foundation
### Phase 2: API
**Goal:** Build the API
`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
fs.mkdirSync(p1, { recursive: true });
// Phase 02's directory holds ONLY a stray artifact whose filename token
// ("01-") belongs to phase 01, not to this directory's own phase (02).
const p2 = path.join(tmpDir, '.planning', 'phases', '02-api');
fs.mkdirSync(p2, { recursive: true });
fs.writeFileSync(path.join(p2, '01-RESEARCH.md'), '# Research for phase 01');
fs.writeFileSync(path.join(p2, '01-CONTEXT.md'), '# Context for phase 01');
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
const phase2 = output.phases.find((p) => p.number === '2');
assert.strictEqual(phase2.has_research, false,
'phase 2 must not report has_research from a file that belongs to phase 1');
assert.strictEqual(phase2.has_context, false,
'phase 2 must not report has_context from a file that belongs to phase 1');
});
test('returns discussed status for phase dir with only CONTEXT.md', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Discussion
**Goal:** Gather context
`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-discussion');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-CONTEXT.md'), '# Context notes');
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].disk_status, 'discussed', 'disk_status should be discussed');
assert.strictEqual(output.phases[0].has_context, true, 'has_context should be true');
});
test('returns empty status for phase dir with no recognized files', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Empty
**Goal:** Nothing yet
`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-empty');
fs.mkdirSync(p1, { recursive: true });
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].disk_status, 'empty', 'disk_status should be empty');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap analyze milestone extraction
// ─────────────────────────────────────────────────────────────────────────────
describe('roadmap analyze milestone extraction', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('extracts milestone headings and version numbers', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
## v1.0 Test Infrastructure
### Phase 1: Foundation
**Goal:** Set up base
## v1.1 Coverage Hardening
### Phase 2: Coverage
**Goal:** Add coverage
`
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.milestones), 'milestones should be an array');
assert.strictEqual(output.milestones.length, 2, 'should find 2 milestones');
assert.strictEqual(output.milestones[0].version, 'v1.0', 'first milestone version');
assert.ok(output.milestones[0].heading.includes('v1.0'), 'first milestone heading contains v1.0');
assert.strictEqual(output.milestones[1].version, 'v1.1', 'second milestone version');
assert.ok(output.milestones[1].heading.includes('v1.1'), 'second milestone heading contains v1.1');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap analyze missing phase details
// ─────────────────────────────────────────────────────────────────────────────
describe('roadmap analyze missing phase details', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('detects checklist-only phases missing detail sections', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] **Phase 1: Foundation** - Set up project
- [ ] **Phase 2: API** - Build REST API
### Phase 2: API
**Goal:** Build REST API
`
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(Array.isArray(output.missing_phase_details), 'missing_phase_details should be an array');
assert.ok(output.missing_phase_details.includes('1'), 'phase 1 should be in missing details');
assert.ok(!output.missing_phase_details.includes('2'), 'phase 2 should not be in missing details');
});
test('returns null when all checklist phases have detail sections', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] **Phase 1: Foundation** - Set up project
- [ ] **Phase 2: API** - Build REST API
### Phase 1: Foundation
**Goal:** Set up project
### Phase 2: API
**Goal:** Build REST API
`
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.missing_phase_details, null, 'missing_phase_details should be null');
});
test('does not report phantom missing details for milestone-prefixed (M-NN) phase IDs', () => {
// The checklist scanner truncated dash-separated IDs at the dash (1-01 -> 1)
// while the detail-heading scanner kept the full ID, so every milestone-prefixed
// ROADMAP spuriously reported the truncated major as a missing detail section.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] **Phase 1-01: Foundation** - Set up project
- [ ] **Phase 1-02: API** - Build REST API
- [ ] **Phase 2-01: Ship** - Release
### Phase 1-01: Foundation
**Goal:** Set up project
### Phase 1-02: API
**Goal:** Build REST API
### Phase 2-01: Ship
**Goal:** Release
`
);
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.missing_phase_details,
null,
'milestone-prefixed phases with matching detail sections should report no missing details'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap get-phase success criteria
// ─────────────────────────────────────────────────────────────────────────────
describe('roadmap get-phase success criteria', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('extracts success_criteria array from phase section', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Test
**Goal:** Test goal
**Success Criteria** (what must be TRUE):
1. First criterion
2. Second criterion
3. Third criterion
### Phase 2: Other
**Goal:** Other goal
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'phase should be found');
assert.ok(Array.isArray(output.success_criteria), 'success_criteria should be an array');
assert.strictEqual(output.success_criteria.length, 3, 'should have 3 criteria');
assert.ok(output.success_criteria[0].includes('First criterion'), 'first criterion matches');
assert.ok(output.success_criteria[1].includes('Second criterion'), 'second criterion matches');
assert.ok(output.success_criteria[2].includes('Third criterion'), 'third criterion matches');
});
test('#2522: wrapped success-criteria lines fold into their criterion (not dropped/truncated)', () => {
// A criterion that wraps onto an indented continuation line must fold INTO its
// criterion (retaining its trailing [REQ-ID]), and every criterion below the wrap
// must still be parsed. The old regex broke the run at the wrap, truncating
// criterion 2 mid-sentence (losing [A-02]) and silently dropping criterion 3.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'### Phase 9: T',
'**Goal**: g',
'**Success Criteria** (what must be TRUE):',
' 1. alpha. [A-01]',
' 2. beta which is long and',
' wraps onto a second line. [A-02]',
' 3. gamma. [A-03]',
'**Plans**: TBD',
'',
].join('\n')
);
const result = runGsdTools('roadmap get-phase 9', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'phase should be found');
assert.strictEqual(output.success_criteria.length, 3, 'all 3 criteria must parse — wrapped continuations must not drop/truncate (#2522)');
assert.ok(output.success_criteria[1].includes('wraps onto a second line'), 'the wrapped continuation must fold into criterion 2');
assert.ok(output.success_criteria[1].includes('[A-02]'), 'the wrapped criterion must retain its trailing [REQ-ID] tag (#2522)');
assert.ok(output.success_criteria[2].includes('[A-03]'), 'criterion 3 (below the wrap) must not be dropped (#2522)');
// Boundary: the parse must not bleed past the Success Criteria block.
assert.ok(!output.success_criteria.some(c => c.includes('TBD')), 'parse must not consume the **Plans** line');
});
test('#2522: blank-line-separated success criteria still parse (the continuation fix must not regress blank lines)', () => {
// Guard against the naive-form trap the issue flags: narrowing the leading
// whitespace to [ \t]* and splitting on \n would trade wrapped-line truncation
// for blank-line truncation. Blank lines between criteria must keep working.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'### Phase 1: T',
'**Goal**: g',
'**Success Criteria** (what must be TRUE):',
' 1. alpha. [A-01]',
'',
' 2. beta. [A-02]',
'',
' 3. gamma. [A-03]',
'**Plans**: TBD',
'',
].join('\n')
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.success_criteria.length, 3, 'blank-line-separated criteria must all parse (#2522 guard)');
assert.ok(output.success_criteria[0].includes('[A-01]'));
assert.ok(output.success_criteria[1].includes('[A-02]'));
assert.ok(output.success_criteria[2].includes('[A-03]'));
});
test('returns empty array when no success criteria present', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Simple
**Goal:** No criteria here
`
);
const result = runGsdTools('roadmap get-phase 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.found, true, 'phase should be found');
assert.ok(Array.isArray(output.success_criteria), 'success_criteria should be an array');
assert.strictEqual(output.success_criteria.length, 0, 'should have empty criteria');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// roadmap update-plan-progress command
// ─────────────────────────────────────────────────────────────────────────────
describe('roadmap update-plan-progress command', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('missing phase number returns error', () => {
const result = runGsdTools('roadmap update-plan-progress', tmpDir);
assert.strictEqual(result.success, false, 'should fail without phase number');
assert.ok(result.error.includes('phase number required'), 'error should mention phase number required');
});
test('nonexistent phase returns error', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Test
**Goal:** Test goal
`
);
const result = runGsdTools('roadmap update-plan-progress 99', tmpDir);
assert.strictEqual(result.success, false, 'should fail for nonexistent phase');
assert.ok(result.error.includes('not found'), 'error should mention not found');
});
test('no plans found returns updated false', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Test
**Goal:** Test goal
`
);
// Create phase dir with only a context file (no plans)
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-CONTEXT.md'), '# Context');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, false, 'should not update');
assert.ok(output.reason.includes('No plans'), 'reason should mention no plans');
assert.strictEqual(output.plan_count, 0, 'plan_count should be 0');
});
test('updates progress for partial completion', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Test
**Goal:** Test goal
**Plans:** TBD
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Test | v1.0 | 0/2 | Planned | - |
`
);
// Create phase dir with 2 plans, 1 summary
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, true, 'should update');
assert.strictEqual(output.plan_count, 2, 'plan_count should be 2');
assert.strictEqual(output.summary_count, 1, 'summary_count should be 1');
assert.strictEqual(output.status, 'In Progress', 'status should be In Progress');
assert.strictEqual(output.complete, false, 'should not be complete');
// Verify file was actually modified
const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmapContent.includes('1/2'), 'roadmap should contain updated plan count');
});
test('counts plans and summaries from plans/ subdirectory layout (#3053)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Test
**Goal:** Test goal
`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test', 'plans');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, 'PLAN-01.md'), '# Plan 1');
fs.writeFileSync(path.join(p1, 'PLAN-02.md'), '# Plan 2');
fs.writeFileSync(path.join(p1, 'SUMMARY-01.md'), '# Summary 1');
const result = runGsdTools('roadmap analyze', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phases[0].plan_count, 2);
assert.strictEqual(output.phases[0].summary_count, 1);
assert.strictEqual(output.phases[0].disk_status, 'partial');
});
test('#1988 — stray non-plan *-SUMMARY.md files do not inflate summary_count or flip phase to Complete', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] **Phase 30: Big** - description
### Phase 30: Big
**Goal:** big goal
**Plans:** TBD
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 30. Big | v3.0 | 0/4 | Planned | - |
`
);
// 4 plans, only 1 has a real summary; plus 3 stray non-plan summaries
// (the exact names from the #1988 report: FIX-CR02, FIX-WR02-04, GAPCLOSURE).
const p = path.join(tmpDir, '.planning', 'phases', '30-big');
fs.mkdirSync(p, { recursive: true });
fs.writeFileSync(path.join(p, '30-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(p, '30-02-PLAN.md'), '# Plan 2');
fs.writeFileSync(path.join(p, '30-03-PLAN.md'), '# Plan 3');
fs.writeFileSync(path.join(p, '30-04-PLAN.md'), '# Plan 4');
fs.writeFileSync(path.join(p, '30-01-SUMMARY.md'), '# Summary 1');
// Strays — these must NOT count:
fs.writeFileSync(path.join(p, '30-FIX-CR02-SUMMARY.md'), '# fix summary');
fs.writeFileSync(path.join(p, '30-FIX-WR02-04-SUMMARY.md'), '# fix summary');
fs.writeFileSync(path.join(p, '30-GAPCLOSURE-SUMMARY.md'), '# gap closure summary');
const result = runGsdTools('roadmap update-plan-progress 30', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.plan_count, 4, 'plan_count is the 4 real plans');
assert.strictEqual(output.summary_count, 1, 'stray summaries must not inflate the count');
assert.strictEqual(output.status, 'In Progress', 'must not flip to Complete');
assert.strictEqual(output.complete, false, 'must not be complete');
// The ROADMAP row must NOT show 4/4 Complete.
const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmapContent.includes('1/4'), 'roadmap row must show the real 1/4 progress');
assert.ok(!/4\/4\s*\|\s*Complete/.test(roadmapContent), 'must not stamp 4/4 Complete');
assert.ok(!/\[x\] \*\*Phase 30/.test(roadmapContent), 'phase checkbox must not be checked');
});
test('#2022 — all summaries present but verification NOT passed → checkbox NOT checked', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] **Phase 1: Test** - description
### Phase 1: Test
**Goal:** Test goal
**Plans:** TBD
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Test | v1.0 | 0/1 | Planned | - |
`
);
// 1 plan + 1 summary (all summaries present) but NO VERIFICATION.md → the
// verification gate (#2022) must prevent the checkbox from being checked.
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.complete, false, 'must NOT be complete without verification');
assert.strictEqual(output.status, 'In Progress', 'status should be In Progress (not Complete)');
const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmapContent.includes('[ ] **Phase 1'), 'phase checkbox must remain unchecked');
assert.ok(!/\[x\] \*\*Phase 1/.test(roadmapContent), 'phase checkbox must NOT be checked without verification');
assert.ok(roadmapContent.includes('1/1'), 'plan count should still be updated');
});
test('#2022 — verification status NOT passed → checkbox NOT checked', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] **Phase 1: Test**
### Phase 1: Test
**Goal:** Test goal
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Test | v1.0 | 0/1 | Planned | - |
`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1');
// Verification exists but status is gaps_found (not passed)
fs.writeFileSync(path.join(p1, '01-VERIFICATION.md'), '---\nstatus: gaps_found\n---\n# Verification\n');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.complete, false, 'must NOT be complete (verification gaps_found)');
assert.strictEqual(output.status, 'In Progress');
const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(!roadmapContent.includes('[x]'), 'checkbox must NOT be checked (verification not passed)');
});
test('updates progress and checks checkbox on completion', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] **Phase 1: Test** - description
### Phase 1: Test
**Goal:** Test goal
**Plans:** TBD
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Test | v1.0 | 0/1 | Planned | - |
`
);
// Create phase dir with 1 plan, 1 summary (complete) + verification passed (#2022 gate)
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1');
fs.writeFileSync(path.join(p1, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, true, 'should update');
assert.strictEqual(output.complete, true, 'should be complete');
assert.strictEqual(output.status, 'Complete', 'status should be Complete');
// Verify file was actually modified
const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmapContent.includes('[x]'), 'checkbox should be checked');
assert.ok(roadmapContent.includes('completed'), 'should contain completion date text');
assert.ok(roadmapContent.includes('1/1'), 'roadmap should contain updated plan count');
});
test('updates unpadded ROADMAP phase entries when called with padded phase argument', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
- [ ] **Phase 3: Build** - description
### Phase 3: Build
**Goal:** Test goal
**Plans:** 0 plans
- [ ] 03-01-PLAN.md
## Progress
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 3. Build | 0/1 | Planned | |
`
);
const p3 = path.join(tmpDir, '.planning', 'phases', '03-build');
fs.mkdirSync(p3, { recursive: true });
fs.writeFileSync(path.join(p3, '03-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(p3, '03-01-SUMMARY.md'), '# Summary 1');
fs.writeFileSync(path.join(p3, '03-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
const result = runGsdTools('roadmap update-plan-progress 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmapContent = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.match(roadmapContent, /- \[x\] \*\*Phase 3: Build\*\* - description \(completed \d{4}-\d{2}-\d{2}\)/);
assert.ok(roadmapContent.includes('**Plans:** 1/1 plans complete'), 'phase detail plan count should be updated');
assert.match(roadmapContent, /\| 3\. Build \| 1\/1 \| Complete\s+\| \d{4}-\d{2}-\d{2} \|/);
assert.ok(roadmapContent.includes('- [x] 03-01-PLAN.md'), 'completed plan checkbox should still be marked');
});
test('missing ROADMAP.md returns updated false', () => {
// Create phase dir with plans and summaries but NO ROADMAP.md
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1');
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.updated, false, 'should not update');
assert.ok(output.reason.includes('ROADMAP.md not found'), 'reason should mention missing ROADMAP.md');
});
test('marks completed plan checkboxes', () => {
const roadmapContent = `# Roadmap
- [ ] Phase 50: Build
- [ ] 50-01-PLAN.md
- [ ] 50-02-PLAN.md
### Phase 50: Build
**Goal:** Build stuff
**Plans:** 2 plans
## Progress
| Phase | Plans Complete | Status | Completed |
|-------|---------------|--------|-----------|
| 50. Build | 0/2 | Planned | |
`;
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent);
const p50 = path.join(tmpDir, '.planning', 'phases', '50-build');
fs.mkdirSync(p50, { recursive: true });
fs.writeFileSync(path.join(p50, '50-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(p50, '50-02-PLAN.md'), '# Plan 2');
// Only plan 1 has a summary (completed)
fs.writeFileSync(path.join(p50, '50-01-SUMMARY.md'), '# Summary 1');
const result = runGsdTools('roadmap update-plan-progress 50', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('[x] 50-01-PLAN.md') || roadmap.includes('[x] 50-01'),
'completed plan checkbox should be marked');
assert.ok(roadmap.includes('[ ] 50-02-PLAN.md') || roadmap.includes('[ ] 50-02'),
'incomplete plan checkbox should remain unchecked');
});
test('preserves Milestone column in 5-column progress table', () => {
const roadmapContent = `# Roadmap
### Phase 50: Build
**Goal:** Build stuff
**Plans:** 1 plans
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 50. Build | v2.0 | 0/1 | Planned | |
`;
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent);
const p50 = path.join(tmpDir, '.planning', 'phases', '50-build');
fs.mkdirSync(p50, { recursive: true });
fs.writeFileSync(path.join(p50, '50-01-PLAN.md'), '# Plan');
fs.writeFileSync(path.join(p50, '50-01-SUMMARY.md'), '# Summary');
fs.writeFileSync(path.join(p50, '50-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verification\n');
const result = runGsdTools('roadmap update-plan-progress 50', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// eslint-disable-next-line local/no-unbounded-quantifier -- parses ROADMAP.md the test itself wrote via a fixed fixture string, bounded, not adversarial input
const rowMatch = roadmap.match(/^\|[^\r\n]*50\. Build[^\r\n]*$/m);
assert.ok(rowMatch, 'table row should exist');
const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim());
assert.strictEqual(cells.length, 5, 'should have 5 columns');
assert.strictEqual(cells[1], 'v2.0', 'Milestone column should be preserved');
assert.ok(cells[3].includes('Complete'), 'Status column should show Complete');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #3057 B3: cmdRoadmapUpdatePlanProgress surfaces an indeterminate staleness check
//
// readVerificationStatus's internal staleness check can fail (fs /
// scanPhasePlans / clock error). Pre-#3057 B3 wiring, that failure was
// dropped here — `verificationPassed` used only `.status`, never
// `.staleCheckIndeterminate` — so this command's JSON output could never
// distinguish "checked; nothing is stale" from "could not check". These
// tests require roadmap.cjs directly (in-process) so an `fs.statSync` fault
// can be injected via the same deterministic path-scoped seam #3057 B4 uses
// for git-base-branch.cts (see tests/git-base-branch.test.cjs).
// ─────────────────────────────────────────────────────────────────────────────
describe('#3057 B3: roadmap update-plan-progress — verification staleness-check indeterminate is surfaced', () => {
const roadmapMod = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'roadmap.cjs'));
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('gsd-3057-b3-roadmap-');
});
afterEach(() => {
cleanup(tmpDir);
});
/**
* In-process capture of cmdRoadmapUpdatePlanProgress's stdout JSON, stderr
* discarded.
*
* io.cts's `output()` writes via `writeAllSync` → `fs.writeSync(1, ...)`
* directly (bug #1008's non-blocking-pipe fix), NOT `process.stdout.write`
* — so mocking `process.stdout.write` here silently captured nothing and
* every assertion below saw `JSON.parse('')` ("Unexpected end of JSON
* input") regardless of what the command actually produced. The fix is the
* fd-level seam tests/io.test.cjs already established for exactly this
* function (bug #1008's `t.mock.method(fs, 'writeSync', ...)` pattern):
* intercept fd 1, discard fd 2, and pass every OTHER fd through to the
* real writeSync — this command takes the planning lock, which writes its
* own JSON via a separate fd that must not be swallowed as if it were
* stdout (confirmed: without the pass-through, its lock-acquire JSON
* corrupts the captured payload into two concatenated JSON objects).
*/
function captureUpdatePlanProgress(t, cwd, phaseNum) {
const chunks = [];
const origWriteSync = fs.writeSync.bind(fs);
t.mock.method(fs, 'writeSync', (fd, data, offset, length) => {
if (fd === 2) return Buffer.isBuffer(data) ? data.length : String(data).length;
if (fd !== 1) return origWriteSync(fd, data, offset, length);
const chunk = Buffer.isBuffer(data)
? data.subarray(offset ?? 0, length === undefined ? data.length : (offset ?? 0) + length).toString('utf8')
: String(data);
chunks.push(chunk);
return Buffer.byteLength(chunk, 'utf8');
});
roadmapMod.cmdRoadmapUpdatePlanProgress(cwd, phaseNum, false);
const captured = chunks.join('');
assert.ok(captured.length > 0, 'cmdRoadmapUpdatePlanProgress produced no stdout output');
return captured;
}
function seedVerifiedPhase() {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
`# Roadmap
### Phase 1: Test
**Goal:** Test goal
**Plans:** TBD
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Test | v1.0 | 0/1 | Planned | - |
`
);
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(p1, { recursive: true });
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary 1');
fs.writeFileSync(path.join(p1, '01-VERIFICATION.md'), '---\nstatus: passed\n---\n\n# Verification\n');
const summaryPath = path.join(p1, '01-01-SUMMARY.md');
const verificationPath = path.join(p1, '01-VERIFICATION.md');
// Deterministic mtime ordering — never rely on write-order clock ties.
const older = new Date('2026-01-01T00:00:00.000Z');
const newer = new Date('2026-01-01T00:01:00.000Z');
fs.utimesSync(summaryPath, older, older);
fs.utimesSync(verificationPath, newer, newer);
return { summaryPath, verificationPath };
}
test('an fs failure inside the staleness check sets verification_stale_check_indeterminate:true; routing unchanged', (t) => {
const { summaryPath, verificationPath } = seedVerifiedPhase();
const origStatSync = fs.statSync;
t.mock.method(fs, 'statSync', function injectedStaleCheckFault(target, ...args) {
const targetPath = String(target);
if (targetPath === verificationPath || targetPath === summaryPath) {
throw new Error('injected stat failure (#3057 B3)');
}
return origStatSync.call(fs, target, ...args);
});
const output = JSON.parse(captureUpdatePlanProgress(t, tmpDir, '1'));
// Pre-existing no-throw fail-open routing is UNCHANGED: the phase still
// resolves complete, exactly as it would without the injected fault.
assert.strictEqual(output.complete, true, 'routing unchanged — phase still marked complete');
assert.strictEqual(output.verification_stale_check_indeterminate, true);
});
test('a completed staleness check that finds nothing stale reports verification_stale_check_indeterminate:false', (t) => {
seedVerifiedPhase();
const output = JSON.parse(captureUpdatePlanProgress(t, tmpDir, '1'));
assert.strictEqual(output.complete, true);
assert.strictEqual(output.verification_stale_check_indeterminate, false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// phase add command
// ─────────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────────
// regressions: insert missing plan rows (#1163)
// ─────────────────────────────────────────────────────────────────────────────
// Phase numbers are zero-padded by normalizePhaseName so the .planning/phases/
// directory must use the padded form (e.g. "05-test-phase" for phase 5).
const PHASE_NUM_1163 = '5'; // what we pass on the command line
const PHASE_DIR_SLUG_1163 = '05'; // what ends up on disk after normalization
/**
* ROADMAP.md with a phase-5 detail section that uses bold **Plans:** and has
* NO per-plan checkbox rows yet.
*/
function buildRoadmapBoldPlans(phaseNum = PHASE_NUM_1163, planCount = '0/3 plans executed') {
return [
'# ROADMAP',
'',
'## Milestone v1.0',
'',
'| Phase | Plans | Status | Completed |',
'| --- | --- | --- | --- |',
`| Phase ${phaseNum}: test phase | 0/3 | Planned | |`,
'',
`### Phase ${phaseNum}: test phase`,
'',
'Goal: build something',
'',
`**Plans:** ${planCount}`,
'',
'(No individual plan rows yet — template freshly generated)',
'',
].join('\n');
}
/**
* ROADMAP.md using the CANONICAL template shape:
* **Plans**: N plans ← bold summary metadata line
* (blank line)
* Plans: ← checklist header
* - [ ] NN-XX-PLAN.md ← per-plan checkboxes
*
* The template (gsd-core/templates/roadmap.md) always uses this two-line form.
* `**Plans**:` has the colon OUTSIDE the bold markers.
*/
function buildRoadmapCanonicalTemplate(phaseNum = PHASE_NUM_1163, existingRows = []) {
const rowLines = existingRows.length > 0
? ['Plans:', ...existingRows.map(r => `- [ ] ${r}`), '']
: ['Plans:', ''];
return [
'# ROADMAP',
'',
'## Milestone v1.0',
'',
'| Phase | Plans | Status | Completed |',
'| --- | --- | --- | --- |',
`| Phase ${phaseNum}: test phase | 0/3 | Planned | |`,
'',
`### Phase ${phaseNum}: test phase`,
'',
'**Goal**: build something',
`**Plans**: 0/3 plans`,
'',
...rowLines,
].join('\n');
}
/**
* ROADMAP.md with a duplicate phase heading in an archived <details> section
* plus the same phase as the ACTIVE milestone section.
*/
function buildRoadmapWithArchivedDuplicate(phaseNum = PHASE_NUM_1163) {
return [
'# ROADMAP',
'',
'<details>',
'<summary>v0.9 — shipped</summary>',
'',
`### Phase ${phaseNum}: test phase`,
'',
'**Plans**: 2/2 plans complete',
'',
'Plans:',
`- [x] ${phaseNum}-01-PLAN.md`,
`- [x] ${phaseNum}-02-PLAN.md`,
'',
'</details>',
'',
'## Milestone v1.0',
'',
`### Phase ${phaseNum}: test phase`,
'',
'**Plans**: 0/3 plans',
'',
'Plans:',
'',
].join('\n');
}
/**
* ROADMAP.md with a phase-5 detail section that uses plain `Plans:` (not bold)
* and NO per-plan checkbox rows yet.
*/
function buildRoadmapPlainPlans(phaseNum = PHASE_NUM_1163) {
return [
'# ROADMAP',
'',
'## Milestone v1.0',
'',
'| Phase | Plans | Status | Completed |',
'| --- | --- | --- | --- |',
`| Phase ${phaseNum}: test phase | 0/3 | Planned | |`,
'',
`### Phase ${phaseNum}: test phase`,
'',
'Goal: build something',
'',
`Plans: 0/3 plans executed`,
'',
'(No individual plan rows yet)',
'',
].join('\n');
}
/**
* Create plan files for a given phase in the .planning/phases tree.
* Uses the normalized (zero-padded) directory name so findPhaseInternal can
* locate the phase. Returns the phase directory path.
*/
function createPhaseWithPlans(tmpDir, phaseNum, planNames) {
// normalizePhaseName('5') → '05', so use zero-padded slug on disk
const paddedNum = String(phaseNum).padStart(2, '0');
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${paddedNum}-test-phase`);
fs.mkdirSync(phaseDir, { recursive: true });
for (const name of planNames) {
fs.writeFileSync(path.join(phaseDir, name), `# ${name}\n`);
}
return phaseDir;
}
describe('regressions: insert missing plan rows (#1163)', () => {
let tmpDir;
let roadmapPath;
beforeEach(() => {
tmpDir = createTempProject('gsd-1163-');
roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
});
afterEach(() => {
cleanup(tmpDir);
});
// ── Bold **Plans:** — insert missing rows ────────────────────────────────
test('inserts plan checkbox rows under bold **Plans:** when none exist', () => {
fs.writeFileSync(roadmapPath, buildRoadmapBoldPlans('5'));
createPhaseWithPlans(tmpDir, '5', [
'5-01-PLAN.md',
'5-02-PLAN.md',
'5-03-PLAN.md',
]);
const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.equal(parsed.updated, true, 'expected updated:true');
const written = fs.readFileSync(roadmapPath, 'utf-8');
assert.ok(written.includes('- [ ] 5-01-PLAN.md'), '5-01-PLAN.md row not inserted');
assert.ok(written.includes('- [ ] 5-02-PLAN.md'), '5-02-PLAN.md row not inserted');
assert.ok(written.includes('- [ ] 5-03-PLAN.md'), '5-03-PLAN.md row not inserted');
});
// ── Plain Plans: — insert missing rows ──────────────────────────────────
test('inserts plan checkbox rows under plain Plans: when none exist', () => {
fs.writeFileSync(roadmapPath, buildRoadmapPlainPlans('5'));
createPhaseWithPlans(tmpDir, '5', [
'5-01-PLAN.md',
'5-02-PLAN.md',
]);
const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.equal(parsed.updated, true, 'expected updated:true');
const written = fs.readFileSync(roadmapPath, 'utf-8');
assert.ok(written.includes('- [ ] 5-01-PLAN.md'), '5-01-PLAN.md row not inserted (plain Plans:)');
assert.ok(written.includes('- [ ] 5-02-PLAN.md'), '5-02-PLAN.md row not inserted (plain Plans:)');
});
// ── Plan count update with plain Plans: ─────────────────────────────────
test('plan count is updated when section uses plain Plans: (not bold)', () => {
fs.writeFileSync(roadmapPath, buildRoadmapPlainPlans('5'));
// createPhaseWithPlans returns the padded dir path (05-test-phase)
const phaseDir = createPhaseWithPlans(tmpDir, '5', [
'5-01-PLAN.md',
'5-02-PLAN.md',
]);
// Create a summary for plan 1 so it's not 0/2
fs.writeFileSync(path.join(phaseDir, '5-01-SUMMARY.md'), '# Summary\n');
const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const written = fs.readFileSync(roadmapPath, 'utf-8');
// Plan count should reflect 1 completed out of 2
assert.ok(
written.match(/Plans:\s*1\/2 plans executed/),
'Plain Plans: count not updated. ROADMAP.md:\n' + written,
);
});
// ── Bold **Plans:** count update ─────────────────────────────────────────
test('plan count is updated when section uses bold **Plans:**', () => {
fs.writeFileSync(roadmapPath, buildRoadmapBoldPlans('5', '0/3 plans executed'));
const phaseDir = createPhaseWithPlans(tmpDir, '5', [
'5-01-PLAN.md',
'5-02-PLAN.md',
'5-03-PLAN.md',
]);
// Complete 1 plan
fs.writeFileSync(path.join(phaseDir, '5-01-SUMMARY.md'), '# Summary\n');
const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const written = fs.readFileSync(roadmapPath, 'utf-8');
assert.ok(
written.includes('**Plans:** 1/3 plans executed'),
'**Plans:** count not updated. ROADMAP.md:\n' + written,
);
});
// ── Existing rows are checked off, not duplicated ───────────────────────
test('existing plan rows are marked complete, not duplicated when SUMMARY exists', () => {
const roadmapWithRows = [
'# ROADMAP',
'',
'## Milestone v1.0',
'',
'### Phase 5: test phase',
'',
'**Plans:** 0/2 plans executed',
'',
'- [ ] 5-01-PLAN.md',
'- [ ] 5-02-PLAN.md',
'',
].join('\n');
fs.writeFileSync(roadmapPath, roadmapWithRows);
const phaseDir = createPhaseWithPlans(tmpDir, '5', [
'5-01-PLAN.md',
'5-02-PLAN.md',
]);
fs.writeFileSync(path.join(phaseDir, '5-01-SUMMARY.md'), '# Summary\n');
const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const written = fs.readFileSync(roadmapPath, 'utf-8');
// 5-01 should be checked, 5-02 should remain unchecked
assert.ok(written.includes('- [x] 5-01-PLAN.md'), '5-01-PLAN.md not marked complete');
assert.ok(written.includes('- [ ] 5-02-PLAN.md'), '5-02-PLAN.md incorrectly marked complete');
// Only two rows — no duplicates
const matches = (written.match(/- \[.\] 5-\d+-PLAN\.md/g) || []);
assert.equal(matches.length, 2, `Expected 2 checkbox rows, got ${matches.length}:\n${written}`);
});
// ── Inserted rows are sorted ─────────────────────────────────────────────
test('inserted plan rows are sorted in ascending order', () => {
fs.writeFileSync(roadmapPath, buildRoadmapBoldPlans('5'));
createPhaseWithPlans(tmpDir, '5', [
'5-03-PLAN.md',
'5-01-PLAN.md',
'5-02-PLAN.md',
]);
const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const written = fs.readFileSync(roadmapPath, 'utf-8');
const rowPositions = [
written.indexOf('5-01-PLAN.md'),
written.indexOf('5-02-PLAN.md'),
written.indexOf('5-03-PLAN.md'),
];
assert.ok(
rowPositions[0] < rowPositions[1] && rowPositions[1] < rowPositions[2],
'Inserted plan rows are not in ascending order. ROADMAP.md:\n' + written,
);
});
// ── No plans found — command returns updated:false without inserting ─────
test('returns updated:false gracefully when phase has no plan files', () => {
fs.writeFileSync(roadmapPath, buildRoadmapBoldPlans('5'));
// Create phase dir (padded slug) but no plan files
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${PHASE_DIR_SLUG_1163}-test-phase`);
fs.mkdirSync(phaseDir, { recursive: true });
const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.equal(parsed.updated, false, 'Expected updated:false when no plans exist');
});
// ── Adversarial: CRLF in ROADMAP.md ──────────────────────────────────────
test('CRLF line endings in ROADMAP.md are handled without corruption', () => {
const content = buildRoadmapBoldPlans('5').replace(/\r?\n/g, '\r\n');
fs.writeFileSync(roadmapPath, content);
createPhaseWithPlans(tmpDir, '5', ['5-01-PLAN.md', '5-02-PLAN.md']);
const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const parsed = JSON.parse(result.output);
assert.equal(parsed.updated, true, 'CRLF ROADMAP not handled');
});
});
describe('regressions: insert missing plan rows (#1163) — adversarial: partial gaps, canonical template, scoped insertion', () => {
let tmpDir;
let roadmapPath;
beforeEach(() => {
tmpDir = createTempProject('gsd-1163-adv-');
roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
});
afterEach(() => {
cleanup(tmpDir);
});
// ── Finding 1: partial-row gaps ──────────────────────────────────────────
test('(Finding 1) inserts missing rows when SOME plan rows already exist', () => {
// Phase has 5-01, 5-02, 5-03 on disk.
// ROADMAP already has a row for 5-01 only.
// Expected: 5-02 and 5-03 rows are inserted; 5-01 is NOT duplicated.
const roadmapContent = [
'# ROADMAP',
'',
'## Milestone v1.0',
'',
`### Phase 5: test phase`,
'',
'**Plans:** 0/3 plans executed',
'',
'Plans:',
'- [ ] 5-01-PLAN.md',
'',
].join('\n');
fs.writeFileSync(roadmapPath, roadmapContent);
createPhaseWithPlans(tmpDir, '5', [
'5-01-PLAN.md',
'5-02-PLAN.md',
'5-03-PLAN.md',
]);
const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const written = fs.readFileSync(roadmapPath, 'utf-8');
// All three plans must have rows
assert.ok(written.includes('5-01-PLAN.md'), '5-01-PLAN.md row missing');
assert.ok(written.includes('5-02-PLAN.md'), '5-02-PLAN.md row was not inserted for partial gap');
assert.ok(written.includes('5-03-PLAN.md'), '5-03-PLAN.md row was not inserted for partial gap');
// No duplicates: exactly one checkbox row per plan
const rows01 = (written.match(/- \[.\] 5-01-PLAN\.md/g) || []);
const rows02 = (written.match(/- \[.\] 5-02-PLAN\.md/g) || []);
const rows03 = (written.match(/- \[.\] 5-03-PLAN\.md/g) || []);
assert.equal(rows01.length, 1, `5-01-PLAN.md duplicated (${rows01.length} times)`);
assert.equal(rows02.length, 1, `5-02-PLAN.md duplicated (${rows02.length} times)`);
assert.equal(rows03.length, 1, `5-03-PLAN.md duplicated (${rows03.length} times)`);
});
test('(Finding 1) running twice (idempotent) does not duplicate partially-inserted rows', () => {
const roadmapContent = [
'# ROADMAP',
'',
'## Milestone v1.0',
'',
`### Phase 5: test phase`,
'',
'**Plans:** 0/2 plans executed',
'',
'Plans:',
'- [ ] 5-01-PLAN.md',
'',
].join('\n');
fs.writeFileSync(roadmapPath, roadmapContent);
createPhaseWithPlans(tmpDir, '5', ['5-01-PLAN.md', '5-02-PLAN.md']);
// Run once to insert 5-02
runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
// Run again — should be a no-op
runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
const written = fs.readFileSync(roadmapPath, 'utf-8');
const rows01 = (written.match(/- \[.\] 5-01-PLAN\.md/g) || []);
const rows02 = (written.match(/- \[.\] 5-02-PLAN\.md/g) || []);
assert.equal(rows01.length, 1, `5-01-PLAN.md duplicated after two runs (${rows01.length} times)`);
assert.equal(rows02.length, 1, `5-02-PLAN.md duplicated after two runs (${rows02.length} times)`);
});
// ── Finding 2: canonical template shape ─────────────────────────────────
// The canonical template (gsd-core/templates/roadmap.md) uses:
// **Plans**: N plans ← summary line (bold word, outer colon)
// (blank line)
// Plans: ← checklist header
// - [ ] NN-XX rows
//
// NOTE: `**Plans**:` differs from `**Plans:**` (colon placement):
// **Plans**: → bold "Plans" + outer colon (CANONICAL)
// **Plans:** → bold "Plans:" (previously assumed form)
// Rows must be inserted under the `Plans:` checklist header, not after the
// `**Plans**:` summary line.
test('(Finding 2) canonical template: inserts rows under Plans: checklist header, not after **Plans**: summary', () => {
// Canonical form: **Plans**: summary + blank + Plans: checklist header + no rows yet
fs.writeFileSync(roadmapPath, buildRoadmapCanonicalTemplate('5', []));
createPhaseWithPlans(tmpDir, '5', ['5-01-PLAN.md', '5-02-PLAN.md']);
const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const written = fs.readFileSync(roadmapPath, 'utf-8');
assert.ok(written.includes('- [ ] 5-01-PLAN.md'), '5-01-PLAN.md not inserted under Plans:');
assert.ok(written.includes('- [ ] 5-02-PLAN.md'), '5-02-PLAN.md not inserted under Plans:');
// Rows must appear AFTER the `Plans:` line, not between `**Plans**:` and `Plans:`
const plansHeaderIdx = written.indexOf('\nPlans:\n');
const boldPlansIdx = written.indexOf('**Plans**:');
const row01Idx = written.indexOf('- [ ] 5-01-PLAN.md');
assert.ok(boldPlansIdx !== -1, '**Plans**: summary line is missing from output');
assert.ok(plansHeaderIdx !== -1, 'Plans: checklist header is missing from output');
assert.ok(row01Idx > plansHeaderIdx, 'row 5-01 appears before Plans: checklist header');
});
test('(Finding 2) canonical template: plan count updated on **Plans**: summary line', () => {
// **Plans**: uses bold word + outer colon — the count update regex must handle it
fs.writeFileSync(roadmapPath, buildRoadmapCanonicalTemplate('5', []));
const phaseDir = createPhaseWithPlans(tmpDir, '5', ['5-01-PLAN.md', '5-02-PLAN.md']);
fs.writeFileSync(path.join(phaseDir, '5-01-SUMMARY.md'), '# Summary\n');
const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const written = fs.readFileSync(roadmapPath, 'utf-8');
// The **Plans**: summary line should be updated to reflect 1/2
assert.ok(
written.match(/\*\*Plans\*\*:\s*1\/2 plans executed/),
'**Plans**: count not updated for canonical template shape.\nROADMAP.md:\n' + written,
);
});
// ── Finding 3: insertion scoped to active milestone ──────────────────────
test('(Finding 3) rows are inserted only in the active milestone, not in archived <details>', () => {
// ROADMAP has duplicate phase heading: one inside <details> (archived) and
// one in the active section. Rows must land ONLY in the active section.
fs.writeFileSync(roadmapPath, buildRoadmapWithArchivedDuplicate('5'));
createPhaseWithPlans(tmpDir, '5', [
'5-01-PLAN.md',
'5-02-PLAN.md',
'5-03-PLAN.md',
]);
const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const written = fs.readFileSync(roadmapPath, 'utf-8');
// The archived section already has rows for 5-01 and 5-02 only (checked off).
// The active section should get 5-03 row (and ideally 5-01/5-02 too if they
// were missing from the active section — the active section's Plans: was empty).
// Key assertion: no NEW rows were inserted into the archived <details> block.
const detailsStart = written.indexOf('<details>');
const detailsEnd = written.indexOf('</details>');
const archivedSection = written.slice(detailsStart, detailsEnd + '</details>'.length);
// The archived section should still have exactly 2 rows (5-01 and 5-02)
const archivedRows = (archivedSection.match(/- \[.\] 5-\d+-PLAN\.md/g) || []);
assert.equal(archivedRows.length, 2, `Archived section row count changed — rows were inserted into archived section:\n${archivedSection}`);
// The active milestone section (after </details>) should have the new rows
const activeSection = written.slice(detailsEnd + '</details>'.length);
assert.ok(activeSection.includes('5-03-PLAN.md'), '5-03 row not inserted in active milestone section');
});
// ── Finding 1 (code-review round 2): detection scoped to active region ───
// When an archived <details> block contains checkbox rows for the SAME plan
// files as the current phase, the missingPlans filter must detect those plans
// as MISSING from the ACTIVE section and insert them there — not skip them
// because they appear anywhere in the full file content.
test('(Finding 1 code-review) inserts ALL missing rows in active section even when archived section has same plans', () => {
// Archived section has 5-01 and 5-02 (checked off for v0.9).
// Active section has NO checkbox rows yet (empty Plans: block).
// Phase 5 on disk has 5-01, 5-02, 5-03.
// Expected: active section gets rows for ALL THREE plans (5-01, 5-02, 5-03).
// Bug (pre-fix): detection runs against full content → 5-01 and 5-02 found in
// archived block → missingPlans = [5-03 only] → only 5-03 inserted.
fs.writeFileSync(roadmapPath, buildRoadmapWithArchivedDuplicate('5'));
createPhaseWithPlans(tmpDir, '5', [
'5-01-PLAN.md',
'5-02-PLAN.md',
'5-03-PLAN.md',
]);
const result = runGsdTools(['roadmap', 'update-plan-progress', '5'], tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const written = fs.readFileSync(roadmapPath, 'utf-8');
const detailsEnd = written.indexOf('</details>');
const activeSection = written.slice(detailsEnd + '</details>'.length);
// All three plans must be present in the active section
assert.ok(activeSection.includes('5-01-PLAN.md'), '5-01-PLAN.md not inserted in active section (detection not scoped to active region)');
assert.ok(activeSection.includes('5-02-PLAN.md'), '5-02-PLAN.md not inserted in active section (detection not scoped to active region)');
assert.ok(activeSection.includes('5-03-PLAN.md'), '5-03-PLAN.md not inserted in active section');
// Archived section must remain untouched (still exactly 2 rows)
const archivedSection = written.slice(written.indexOf('<details>'), detailsEnd + '</details>'.length);
const archivedRows = (archivedSection.match(/- \[.\] 5-\d+-PLAN\.md/g) || []);
assert.equal(archivedRows.length, 2, `Archived section row count changed:\n${archivedSection}`);
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2661-roadmap-sync-parallel.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2661-roadmap-sync-parallel (consolidation epic #1969 B2 #1971)", () => {
// allow-test-rule: source-text-is-the-product (see #2661)
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Regression tests for bug #2661:
* `/gsd-execute-phase N --auto` with parallelization: true, use_worktrees: false
* left ROADMAP plan checkboxes unchecked until a manual
* `roadmap update-plan-progress` was run.
*
* Root cause (workflow-level): execute-plan.md `update_roadmap` step was
* gated on a worktree-detection branch that incorrectly conflated
* "parallel mode" with "worktree mode". When `parallelization: true,
* use_worktrees: false` was configured, the step was still gated by the
* worktree-only check (which is true: the executing tree IS the main repo,
* not a worktree, so the gate happened to fire correctly there) — the
* actual reproducer was a different code path. The original PR #2682 fix
* made the sync unconditional, which violated the single-writer contract
* for shared ROADMAP.md established by #1486 / dcb50396 in worktree mode.
*
* Minimal fix (this PR): restore the worktree guard and document its
* intent explicitly. The `IS_WORKTREE != "true"` branch IS the
* `use_worktrees: false` mode: only that mode runs the in-handler sync.
* Worktree mode relies on the orchestrator's post-merge sync at
* execute-phase.md §5.7 (lines 815-834) — the single writer for shared
* tracking files.
*
* These tests:
* (1) assert the workflow gates the sync call on `use_worktrees: false`
* (i.e. the IS_WORKTREE != "true" branch is present and gates the call);
* (2) assert the handler itself behaves correctly under the
* use_worktrees: false reproducer (the original #2661 case);
* (3) assert the handler is idempotent and lock-safe (lockfile is the
* in-handler defense; the workflow gate is the cross-handler one).
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-plan.md');
function writeRoadmap(tmpDir, content) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content);
}
function readRoadmap(tmpDir) {
return fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
}
function seedPhase(tmpDir, phaseNum, planIds, summaryIds) {
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${String(phaseNum).padStart(2, '0')}-test`);
fs.mkdirSync(phaseDir, { recursive: true });
for (const id of planIds) {
fs.writeFileSync(path.join(phaseDir, `${id}-PLAN.md`), `# Plan ${id}`);
}
for (const id of summaryIds) {
fs.writeFileSync(path.join(phaseDir, `${id}-SUMMARY.md`), `# Summary ${id}`);
}
}
const THREE_PLAN_ROADMAP = `# Roadmap
- [ ] Phase 1: Test phase with three parallel plans
- [ ] 01-01-PLAN.md
- [ ] 01-02-PLAN.md
- [ ] 01-03-PLAN.md
### Phase 1: Test
**Goal:** Parallel execution regression test
**Plans:** 3 plans
## Progress
| Phase | Milestone | Plans Complete | Status | Completed |
|-------|-----------|----------------|--------|-----------|
| 1. Test | v1.0 | 0/3 | Planned | |
`;
// ─── Structural: workflow gates sync on use_worktrees=false ──────────────────
describe('bug #2661: execute-plan.md update_roadmap gating', () => {
const content = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
const stepMatch = content.match(
// eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own workflow .md content, fixed-size author-controlled content
/<step name="update_roadmap">([\s\S]*?)<\/step>/
);
const step = stepMatch && stepMatch[1];
test('update_roadmap step exists and invokes roadmap.update-plan-progress', () => {
assert.ok(stepMatch, 'update_roadmap step must exist');
// After #3797 architectural fix, callsites use gsd_run
assert.ok(
/gsd_run query roadmap\.update-plan-progress/.test(step),
'update_roadmap must still invoke roadmap.update-plan-progress'
);
});
test('use_worktrees: false mode — sync call is gated to fire (the #2661 reproducer)', () => {
// The non-worktree branch must contain the sync call.
// After #3797 architectural fix, callsites use gsd_run
assert.ok(
/IS_WORKTREE.*!=.*"true"[\s\S]*?gsd_run query roadmap\.update-plan-progress/.test(step),
'sync call must execute on the IS_WORKTREE != "true" branch (use_worktrees: false)'
);
});
test('use_worktrees: true mode — sync call does NOT fire (single-writer contract)', () => {
// The sync call must be inside an `if [ "$IS_WORKTREE" != "true" ]` block,
// i.e. it must NOT be unconditional and it must NOT appear on the worktree branch.
// We verify by extracting the bash block and checking the call sits under the gate.
const bashMatch = step.match(/```bash\s*([\s\S]*?)```/);
assert.ok(bashMatch, 'update_roadmap must contain a bash block');
const bash = bashMatch[1];
assert.ok(
/IS_WORKTREE/.test(bash),
'bash block must include the IS_WORKTREE worktree-detection check'
);
// Sync call must appear after the guard check, not before.
// After #3797 architectural fix, callsites use gsd_run
const guardIdx = bash.search(/if \[ "\$IS_WORKTREE" != "true" \]/);
const callIdx = bash.search(/gsd_run query roadmap\.update-plan-progress/);
assert.ok(guardIdx >= 0, 'guard must be present');
assert.ok(callIdx > guardIdx,
'sync call must appear inside the use_worktrees: false guard, not before/outside it');
});
test('intent doc references single-writer contract / orchestrator-owns-write', () => {
// The prose must justify why worktree mode is excluded so future readers
// do not regress this back to unconditional.
assert.ok(
/worktree|orchestrator|single-writer|#1486|#2661/i.test(step),
'update_roadmap must document the contract that justifies the gate'
);
});
});
// ─── Handler-level: idempotence + multi-plan sync (use_worktrees: false case) ─
describe('bug #2661: roadmap update-plan-progress handler (use_worktrees: false)', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject('gsd-2661-'); });
afterEach(() => { cleanup(tmpDir); });
test('three parallel SUMMARY.md files produce three [x] plan checkboxes', () => {
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, `handler failed: ${result.error}`);
const roadmap = readRoadmap(tmpDir);
assert.ok(roadmap.includes('[x] 01-01-PLAN.md'), 'plan 01-01 should be checked');
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 should be checked');
assert.ok(roadmap.includes('[x] 01-03-PLAN.md'), 'plan 01-03 should be checked');
assert.ok(roadmap.includes('3/3'), 'progress row should reflect 3/3');
});
test('handler is idempotent — second call produces identical content', () => {
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
const first = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(first.success, first.error);
const afterFirst = readRoadmap(tmpDir);
const second = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(second.success, second.error);
const afterSecond = readRoadmap(tmpDir);
assert.strictEqual(afterSecond, afterFirst,
'repeated invocation must not mutate ROADMAP.md further (idempotent)');
});
test('partial completion: only plans with SUMMARY.md get [x]', () => {
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
// Only plan 01-02 has a SUMMARY.md
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-02']);
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
assert.ok(result.success, result.error);
const roadmap = readRoadmap(tmpDir);
assert.ok(roadmap.includes('[ ] 01-01-PLAN.md'), 'plan 01-01 should remain unchecked');
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 should be checked');
assert.ok(roadmap.includes('[ ] 01-03-PLAN.md'), 'plan 01-03 should remain unchecked');
assert.ok(roadmap.includes('1/3'), 'progress row should reflect 1/3');
});
test('lockfile contention: concurrent handler invocations within a single tree do not corrupt ROADMAP.md', async () => {
// Scope: lockfile only serializes within a single working tree. Cross-worktree
// serialization is enforced by the workflow gate (worktree mode never calls
// this handler from execute-plan.md), not by the lockfile.
writeRoadmap(tmpDir, THREE_PLAN_ROADMAP);
seedPhase(tmpDir, 1, ['01-01', '01-02', '01-03'], ['01-01', '01-02', '01-03']);
const invocations = Array.from({ length: 3 }, () =>
new Promise((resolve) => {
const r = runGsdTools('roadmap update-plan-progress 1', tmpDir);
resolve(r);
})
);
const results = await Promise.all(invocations);
for (const r of results) {
assert.ok(r.success, `concurrent handler invocation failed: ${r.error}`);
}
const roadmap = readRoadmap(tmpDir);
// Structural integrity: each checkbox appears exactly once, progress row intact.
for (const id of ['01-01', '01-02', '01-03']) {
const occurrences = roadmap.split(`[x] ${id}-PLAN.md`).length - 1;
assert.strictEqual(occurrences, 1,
`plan ${id} checkbox should appear exactly once (got ${occurrences})`);
}
assert.ok(roadmap.includes('3/3'), 'progress row should reflect 3/3 after concurrent runs');
// Lockfile should have been cleaned up after the final release.
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'ROADMAP.md.lock')),
'ROADMAP.md.lock should be released after concurrent invocations settle'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3599-roadmap-get-phase-project-code-prefix.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3599-roadmap-get-phase-project-code-prefix (consolidation epic #1969 B2 #1971)", () => {
/**
* Bug #3599: roadmap.get-phase no longer matches custom phase IDs with
* project-code prefixes like `PROJ-42`.
*
* `phaseMarkdownRegexSource(phaseNum)` in gsd-core/bin/lib/core.cjs
* (and its SDK twin in sdk/src/query/roadmap-update-plan-progress.ts) strips
* the `PROJ-` prefix before building the padding-tolerant numeric regex.
* Result: `roadmap get-phase PROJ-42` produces a regex of `0*42`, which
* matches `### Phase 42:` instead of (or in addition to) the intended
* `### Phase PROJ-42:`. The function's own docstring promises a fallback to
* `escapeRegex(phaseNum)` for non-numeric custom IDs, but that branch is
* unreachable for project-code-prefixed numeric IDs.
*
* Fix: the emitted regex must match BOTH the stripped numeric form (so
* `CK-01-name` directory inputs still resolve to `Phase 1:` in prose, the
* #3537 contract) AND the full prefixed form (so `PROJ-42` resolves to
* `Phase PROJ-42:`).
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
function writeRoadmap(tmpDir, body) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body);
}
function writeState(tmpDir, version) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`---\nmilestone: ${version}\n---\n`,
);
}
describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('bug-3599-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('finds ### Phase PROJ-42: when queried as PROJ-42', () => {
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase PROJ-42: Custom phase',
'**Goal:** Verify project-code-prefixed lookup',
'',
].join('\n'),
);
const result = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir);
assert.ok(result.success, `command failed: ${result.error || result.output}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.found, true, `expected found=true, got: ${result.output}`);
assert.strictEqual(payload.phase_name, 'Custom phase');
assert.strictEqual(payload.goal, 'Verify project-code-prefixed lookup');
});
test('bare numeric prefers a bare sibling over a prefixed one (#3599 anti-steal, updated for #2114)', () => {
// #3599's real guard is anti-STEALING: when BOTH a bare `Phase 42:` and a
// distinct prefixed `Phase PROJ-42:` exist, a bare `42` query must resolve
// the BARE one — the numeric source is tried before the prefix-tolerant
// fallback, so a bare query never steals a distinct prefixed sibling.
// (Since #2114/#2121, a bare query DOES resolve a *drifted-only* prefixed
// heading when no bare sibling exists — see the bug #2114 block below.)
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 42: Bare',
'**Goal:** Canonical bare heading',
'',
'### Phase PROJ-42: Prefixed',
'**Goal:** Distinct prefixed sibling',
'',
].join('\n'),
);
const result = runGsdTools('roadmap get-phase 42 --json', tmpDir);
assert.ok(result.success);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.found, true, `expected found=true, got: ${result.output}`);
assert.strictEqual(
payload.phase_name,
'Bare',
`bare '42' must resolve the bare 'Phase 42:', not steal 'Phase PROJ-42:'; got ${result.output}`,
);
});
test('preserves #3537 contract: CK-01 directory form resolves to Phase 1 prose', () => {
// Existing contract: phase directory names like `CK-01-name` carry the
// project_code prefix and a zero-padded number, but ROADMAP prose is
// typically un-padded (`### Phase 1:`). The padding-tolerant lookup must
// still bridge those two surfaces.
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 1: Numeric prose',
'**Goal:** #3537 contract — CK-01 dir → Phase 1 prose',
'',
].join('\n'),
);
const result = runGsdTools('roadmap get-phase CK-01 --json', tmpDir);
assert.ok(result.success);
const payload = JSON.parse(result.output);
assert.strictEqual(
payload.found,
true,
`CK-01 must still resolve to 'Phase 1:' prose (#3537 contract); got ${result.output}`,
);
assert.strictEqual(payload.phase_name, 'Numeric prose');
});
test('finds the right phase when both prefixed and bare forms coexist', () => {
// Disambiguation test: a roadmap that contains BOTH `### Phase 42:` and
// `### Phase PROJ-42:` must resolve each query to its specific match.
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 42: Bare numeric',
'**Goal:** Bare',
'',
'### Phase PROJ-42: Prefixed',
'**Goal:** Prefixed',
'',
].join('\n'),
);
const r42 = runGsdTools('roadmap get-phase 42 --json', tmpDir);
const rProj = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir);
const p42 = JSON.parse(r42.output);
const pProj = JSON.parse(rProj.output);
assert.strictEqual(p42.found, true);
assert.strictEqual(p42.phase_name, 'Bare numeric');
assert.strictEqual(p42.goal, 'Bare');
assert.strictEqual(pProj.found, true);
assert.strictEqual(pProj.phase_name, 'Prefixed');
assert.strictEqual(pProj.goal, 'Prefixed');
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/enh-2447-roadmap-wave-deps.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:enh-2447-roadmap-wave-deps (consolidation epic #1969 B2 #1971)", () => {
'use strict';
// allow-test-rule: source-text-is-the-product (see #2447)
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Tests for ROADMAP wave dependency surfacing (#2447).
*/
const { test, describe, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const PLAN_TEMPLATE = (wave, truths = []) => `---
phase: "1"
plan: "01-0${wave}"
type: standard
wave: ${wave}
depends_on: []
files_modified: []
autonomous: true
requirements: []
must_haves:
truths:
${truths.map(t => ` - ${t}`).join('\n') || ' - (none)'}
artifacts: []
key_links: []
---
<objective>
Plan ${wave} objective
</objective>
`;
function makePlanProject(files = {}) {
const dir = createTempProject();
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), '');
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-foundation'), { recursive: true });
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(dir, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf-8');
}
return dir;
}
describe('bug #2114: roadmap get-phase resolves drifted prefixed headings by bare number', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject('bug-2114-'); });
afterEach(() => { cleanup(tmpDir); });
test('bare-number query resolves a drifted project-code-prefixed heading', () => {
// Before the fix, bare `29` did NOT match `### Phase AB-29:` from the CLI
// (2-source lookup), even though getRoadmapPhaseInternal (init.phase-op) did
// — the #2114 divergence. Now all three resolvers share the 3-source list.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v1.0.0\n---\n# State\n\n**Status:** In progress\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 30: Plain',
'**Goal:** Canonical bare heading',
'',
'### Phase AB-29: Prefixed',
'**Goal:** Drifted prefixed heading',
'',
].join('\n'),
);
const resultAB29 = runGsdTools('roadmap get-phase 29 --json', tmpDir);
assert.ok(resultAB29.success, `command failed: ${resultAB29.error || resultAB29.output}`);
const payloadAB29 = JSON.parse(resultAB29.output);
assert.strictEqual(payloadAB29.found, true, `expected found=true for drifted AB-29, got: ${resultAB29.output}`);
assert.strictEqual(payloadAB29.phase_name, 'Prefixed');
assert.strictEqual(payloadAB29.goal, 'Drifted prefixed heading');
// The canonical bare heading still resolves.
const result30 = runGsdTools('roadmap get-phase 30 --json', tmpDir);
assert.ok(result30.success);
const payload30 = JSON.parse(result30.output);
assert.strictEqual(payload30.found, true);
assert.strictEqual(payload30.phase_name, 'Plain');
});
test('project-code-prefixed checklist-only entry surfaces malformed_roadmap for both query forms', () => {
// #2121/#2114 route all three resolvers through the shared 3-source lookup. A
// `**Phase PROJ-42:**` summary line with no matching `### Phase PROJ-42:` detail heading
// is a malformed ROADMAP. Before the consolidation this project-code-prefixed checklist
// was reported as a silent `{found:false}` for BOTH query forms — the prefixed pass
// discarded its malformed candidate, and the bare pass could not match the `PROJ-` prefix
// at all. The unified lookup newly surfaces the malformed_roadmap diagnostic for both, so
// this test fails on the prior silent-empty behavior for the prefixed AND the bare form.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap v1.0',
'',
'## Phases',
'',
'- [ ] **Phase PROJ-42: Checklist only, no header**',
'',
].join('\n'),
);
const prefixed = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir);
assert.ok(prefixed.success, `command failed: ${prefixed.error || prefixed.output}`);
const pPayload = JSON.parse(prefixed.output);
assert.strictEqual(pPayload.found, false, 'malformed roadmap: phase must not be found');
assert.strictEqual(pPayload.error, 'malformed_roadmap', 'prefixed query must surface malformed_roadmap');
assert.ok(pPayload.message.includes('missing'), 'message must explain the missing detail section');
// Parity: the bare numeric form yields the same diagnostic against the same fixture.
const bare = runGsdTools('roadmap get-phase 42 --json', tmpDir);
assert.ok(bare.success, `command failed: ${bare.error || bare.output}`);
assert.strictEqual(JSON.parse(bare.output).error, 'malformed_roadmap', 'bare query surfaces the same diagnostic');
});
});
describe('roadmap annotate-dependencies', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('inserts wave headers for multi-wave plan set', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['DB schema is correct']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, ['API returns 200']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true);
assert.strictEqual(out.waves, 2);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('**Wave 1**'), 'Wave 1 header present');
assert.ok(roadmap.includes('**Wave 2**'), 'Wave 2 header present');
assert.ok(roadmap.includes('blocked on Wave 1'), 'Wave 2 blocked-on note present');
});
test('does not insert wave headers for single-wave plan set', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['DB schema is correct']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(1, ['API returns 200']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(!roadmap.includes('**Wave 1**'), 'no Wave header for single-wave set');
assert.ok(!roadmap.includes('blocked on'), 'no blocked-on note for single wave');
});
test('surfaces cross-cutting constraints when truths appear in 2+ plans', () => {
const sharedTruth = 'All endpoints require auth';
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, [sharedTruth, 'DB schema is correct']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, [sharedTruth, 'API returns 200']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cross_cutting_constraints, 1);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('Cross-cutting constraints:'), 'constraints subsection present');
assert.ok(roadmap.includes(sharedTruth), 'shared truth listed');
});
test('#1154: surfaces a cross-cutting backstop (object-form) truth by its statement, not dropped', () => {
// An object-form backstop truth `{ statement, verification: backstop }` (the #1154 non-inferable
// marker on must_haves.truths) shared across 2 plans must be coerced by its `statement` — the
// Hyrum backward-compat guard: a truth-reader must tolerate the new object form, never drop it.
const backstopTruth = 'statement: Adjacent touching intervals merge\n verification: backstop';
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, [backstopTruth, 'DB schema is correct']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, [backstopTruth, 'API returns 200']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cross_cutting_constraints, 1, 'the shared backstop truth is surfaced, not dropped');
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(roadmap.includes('Adjacent touching intervals merge'), 'surfaced by its statement text, not [object Object]');
});
test('does not surface constraints that appear in only one plan', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, ['Only in plan 1']),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2, ['Only in plan 2']),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.cross_cutting_constraints, 0);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(!roadmap.includes('Cross-cutting constraints:'), 'no constraints section when none are cross-cutting');
});
test('is idempotent — running twice does not double-insert wave headers', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Set up DB
- [ ] 01-02-PLAN.md — Build API
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1),
'.planning/phases/01-foundation/01-02-PLAN.md': PLAN_TEMPLATE(2),
});
runGsdTools('roadmap annotate-dependencies 1', tmpDir);
const secondResult = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(secondResult.success);
const out = JSON.parse(secondResult.output);
assert.strictEqual(out.updated, false, 'second run should be no-op');
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
const waveMatches = roadmap.match(/\*\*Wave \d+\*\*/g) || [];
assert.strictEqual(waveMatches.length, 2, 'exactly 2 wave headers (not doubled)');
});
test('returns no-op when phase has no plans', () => {
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Set up project\n`,
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, false);
});
test('#2757: truths containing colons do not crash annotate-dependencies', () => {
// Unquoted truths with colons (Rails idioms: db:seed, /foo/:id, Class::Method)
// caused parseMustHavesBlock to return {} instead of a string, then t.trim() threw.
const colonTruths = [
'GET /foo/:id resolves to controller#show',
'Class::Method is idempotent',
'"Quoted truth with colon: inside"',
];
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Set up project\n**Plans:** 1 plan\n\nPlans:\n- [ ] 01-01-PLAN.md — Repro plan\n`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(1, colonTruths),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command threw on colon-containing truths: ${result.error}`);
const out = JSON.parse(result.output);
assert.ok(typeof out.updated === 'boolean', 'should return a valid result object');
});
test('#314 map-lookup: found-path uses plan wave, miss-path defaults to wave 1', () => {
// Behavior lock for the O(1) Map swap: asserts BOTH branches of the lookup.
// - 01-01-PLAN.md is in planData (wave 2) → checklist line must land under Wave 2.
// - 01-99-PLAN.md is NOT in planData → null-on-miss → defaults to wave 1.
tmpDir = makePlanProject({
'.planning/ROADMAP.md': `# Roadmap
### Phase 1: Foundation
**Goal:** Set up project
**Plans:** 2 plans
Plans:
- [ ] 01-01-PLAN.md — Known plan
- [ ] 01-99-PLAN.md — Unknown plan (no PLAN.md)
`,
'.planning/phases/01-foundation/01-01-PLAN.md': PLAN_TEMPLATE(2),
// 01-99-PLAN.md intentionally absent — simulates a checklist entry with no backing plan file
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// Both waves must be present (wave 1 from the miss, wave 2 from the found entry)
assert.ok(roadmap.includes('**Wave 1**'), 'Wave 1 header present (miss-path default)');
assert.ok(roadmap.includes('**Wave 2**'), 'Wave 2 header present (found-path)');
// Known plan (wave 2) must appear AFTER Wave 2 header
const wave2Idx = roadmap.indexOf('**Wave 2**');
const knownLineIdx = roadmap.indexOf('01-01-PLAN.md');
assert.ok(knownLineIdx > wave2Idx, 'known plan line grouped under Wave 2');
// Unknown plan (wave 1 default) must appear AFTER Wave 1 header and BEFORE Wave 2 header
const wave1Idx = roadmap.indexOf('**Wave 1**');
const unknownLineIdx = roadmap.indexOf('01-99-PLAN.md');
assert.ok(unknownLineIdx > wave1Idx, 'unknown plan line grouped under Wave 1');
assert.ok(unknownLineIdx < wave2Idx, 'unknown plan line appears before Wave 2 section');
});
test('plan-phase.md documents annotate-dependencies step', () => {
const planPhase = fs.readFileSync(
path.join(__dirname, '../gsd-core/workflows/plan-phase.md'), 'utf-8'
);
assert.ok(planPhase.includes('annotate-dependencies'), 'plan-phase.md references annotate-dependencies command');
assert.ok(planPhase.includes('13d'), 'plan-phase.md has step 13d');
assert.ok(planPhase.includes('Cross-cutting constraints'), 'plan-phase.md documents cross-cutting constraints');
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-557-details-summary-milestone-strip.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-557-details-summary-milestone-strip (consolidation epic #1969 B2 #1971)", () => {
/**
* Bug #557: Active milestone wrapped in <details open> with version only in
* <summary> tag + 🔄 emoji causes extractCurrentMilestone() to fall through
* to stripShippedMilestones(), erasing the active block and making
* roadmap.analyze return phase_count: 0 — which then triggers a premature
* milestone_complete STATE write.
*
* Root cause (two miss paths in extractCurrentMilestone, core.cjs):
* 1. sectionPattern only matches ##/### headings; version in <summary> not found.
* 2. activeMarkerPattern does not include 🔄; only 🚧 is recognised.
* Both misses → stripShippedMilestones() deletes the active <details open> block.
*
* This test will FAIL before the fix (phase_count returns 0) and PASS after.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ─── Fixtures ────────────────────────────────────────────────────────────────
// ROADMAP where the active milestone's version ("v1.3") appears ONLY inside
// a <summary> tag, and the in-progress marker is 🔄 (not 🚧).
// Shipped milestone v1.2 is correctly collapsed in a <details> block.
const ROADMAP_DETAILS_SUMMARY = `# Roadmap
<details>
<summary>✅ v1.2: Foundation (shipped)</summary>
### Phase 1: Bootstrap
**Goal:** Set up infrastructure
### Phase 2: Core API
**Goal:** Build REST API
</details>
<details open>
<summary>🔄 v1.3: Active Sprint</summary>
### Phase 3: Auth
**Goal:** Add authentication
### Phase 4: Dashboard
**Goal:** Build dashboard UI
</details>
`;
// STATE.md with milestone: v1.3 — version matches the <summary> tag above
const STATE_V13 = `---
gsd_state_version: 1.0
milestone: v1.3
milestone_name: Active Sprint
status: in_progress
progress:
total_phases: 2
completed_phases: 0
total_plans: 0
completed_plans: 0
percent: 0
---
# Project State
## Current Position
Phase: 3 (Auth)
`;
// Second variant: active milestone uses the 🔄 emoji in a heading (not just
// <summary>) to confirm the activeMarkerPattern gap is also covered.
const ROADMAP_ROTATE_HEADING = `# Roadmap
<details>
<summary>✅ v2.0: Shipped (shipped)</summary>
### Phase 1: Old Phase
**Goal:** Done
</details>
## 🔄 v2.1: Active Milestone
### Phase 2: New Feature
**Goal:** Build the new feature
### Phase 3: Integration
**Goal:** Wire it all together
`;
const STATE_V21 = `---
gsd_state_version: 1.0
milestone: v2.1
milestone_name: Active Milestone
status: in_progress
---
# Project State
## Current Position
Phase: 2 (New Feature)
`;
// ─── Test suite ───────────────────────────────────────────────────────────────
describe('bug #557 — <details>/<summary> active milestone strip', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── Core repro: version only in <summary> tag ─────────────────────────────
test('roadmap.analyze returns correct phase_count when active milestone uses <summary> + 🔄', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.phase_count >= 2,
`Expected phase_count >= 2 (phases 3 and 4 of v1.3); got phase_count=${output.phase_count}. ` +
`Bug: extractCurrentMilestone() stripped the active <details open> block because ` +
`the version "v1.3" only appears in a <summary> tag and the emoji is 🔄, not 🚧.`
);
});
test('roadmap.analyze does NOT return phase_count: 0 when active milestone is in <details open>', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.notStrictEqual(
output.phase_count,
0,
'phase_count must not be 0 — a zero count caused by stripping the active block ' +
'is the direct trigger for the premature milestone_complete write.'
);
});
test('roadmap get-phase returns found:true for phase in active <details open> block', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'get-phase', '3'], tmpDir);
assert.ok(result.success, `roadmap get-phase failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(
output.found,
true,
`Phase 3 must be found in the active v1.3 milestone block. ` +
`Bug: stripShippedMilestones() erased the <details open> block so the phase section was lost.`
);
});
test('shipped phases in collapsed <details> are NOT visible to roadmap.analyze (strip preserved for non-active)', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V13, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
const output = JSON.parse(result.output);
const phaseNums = (output.phases || []).map(p => p.number);
assert.ok(
!phaseNums.includes('1') && !phaseNums.includes('2'),
`Shipped phases 1 and 2 (from collapsed <details>) must not appear in the analyze output. ` +
`Got phases: ${JSON.stringify(phaseNums)}`
);
});
// ── 🔄 in heading (not <summary>) also recognised ────────────────────────
test('extractCurrentMilestone recognises 🔄 in milestone heading as in-progress marker', () => {
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_ROTATE_HEADING, 'utf-8');
fs.writeFileSync(path.join(planning, 'STATE.md'), STATE_V21, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(result.success, `roadmap.analyze failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.phase_count >= 2,
`Expected phase_count >= 2 for v2.1 with 🔄 heading; got ${output.phase_count}. ` +
`activeMarkerPattern must include 🔄, not just 🚧.`
);
});
// ── Health check W026: milestone_complete vs unstarted phases ─────────────
// Phase 11 (#3309): this subject moved off the pre-migration 'W021' code
// onto the new 'W026' code (the split-off half of the two-subject
// conflation the design doc's "New codes for the two split subjects"
// section documents) — the OTHER W021 subject, phase_id_convention
// mismatch, kept its code.
test('validate health emits W026 when STATE says milestone complete but ROADMAP has unstarted phases', () => {
const planning = path.join(tmpDir, '.planning');
// ROADMAP still has active phases in it
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8');
// STATE falsely says milestone complete
fs.writeFileSync(path.join(planning, 'STATE.md'), `---
gsd_state_version: 1.0
milestone: v1.3
milestone_name: Active Sprint
status: v1.3 milestone complete
---
# Project State
## Current Position
Phase: Milestone v1.3 complete
`, 'utf-8');
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
const result = runGsdTools(['validate', 'health'], tmpDir);
assert.ok(result.success, `validate health failed: ${result.error}`);
const output = JSON.parse(result.output);
const warnings = output.warnings || [];
const w026 = warnings.find(w => w.code === 'W026');
assert.ok(
w026 !== undefined,
`Expected W026 warning (milestone-status vs. roadmap-progress incoherence). ` +
`Got warnings: ${JSON.stringify(warnings.map(w => w.code))}`
);
// W021/W026 independence (Phase 11, #3309 split): this fixture's subject
// is the W026 one (milestone-complete vs. unstarted phases) — it must
// NOT also produce a W021 (phase_id_convention mismatch, an unrelated
// subject this config.json-less fixture never triggers).
assert.ok(
warnings.every(w => w.code !== 'W021'),
`W026 fixture must not also fire W021: ${JSON.stringify(warnings.map(w => w.code))}`
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-549-total-phases-overcounts-with-phase-section-heading.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-549-total-phases-overcounts-with-phase-section-heading (consolidation epic #1969 B2 #1971)", () => {
'use strict';
/**
* Regression test for bug #549:
* STATE.md progress.total_phases is over-counted by 1 when the ROADMAP contains
* a non-phase section heading that happens to match the broader pattern used by
* getMilestonePhaseFilter (e.g. `## Phase Overview:`, `## Phase Details:`).
*
* Root cause:
* buildStateFrontmatter sources total_phases from getMilestonePhaseFilter.phaseCount,
* which uses the looser regex `#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:` to build its
* milestonePhaseNums set. That pattern matches section headings like
* `## Phase Overview:` and `## Phase Details:`, adding non-numeric tokens
* ("Overview", "Details") to the set and inflating phaseCount by 1 per heading.
*
* roadmap.analyze uses the stricter pattern
* `#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)` which requires a
* leading digit, so it only counts real phase headings.
*
* Fix: buildStateFrontmatter (and cmdStateSync) must source total_phases from
* the same digit-anchored phase-heading parser as roadmap.analyze — single
* source of truth.
*
* Scenario under test:
* ROADMAP with 6 integer phases (01-06) + 1 inserted decimal phase (05.1) = 7
* phases, plus a `## Phase Overview:` section header.
*
* BEFORE fix: state json / state sync report total_phases: 8 (7 + 1 spurious
* "Overview" token from the getMilestonePhaseFilter pattern).
* AFTER fix: state json / state sync report total_phases: 7, matching
* roadmap.analyze.phase_count.
*/
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ROADMAP: 6 integer phases + 1 inserted decimal + `## Phase Overview:` section header.
// The section header must include a trailing `:` so it matches the getMilestonePhaseFilter
// broader pattern (the bug trigger).
const ROADMAP = `## Milestone v1.0: Test Milestone
## Phase Overview:
High-level narrative about the phases.
### Phase 01: Alpha
**Goal:** alpha
### Phase 02: Beta
**Goal:** beta
### Phase 03: Gamma
**Goal:** gamma
### Phase 04: Delta
**Goal:** delta
### Phase 05: Epsilon
**Goal:** epsilon
### Phase 05.1: Inserted Hotfix (INSERTED)
**Goal:** inserted hotfix
### Phase 06: Zeta
**Goal:** zeta
`;
describe('bug #549 — total_phases over-counted by non-phase section headings', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('bug-549-');
const planning = path.join(tmpDir, '.planning');
fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP, 'utf-8');
fs.writeFileSync(
path.join(planning, 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: Test Milestone',
'status: executing',
'---',
'',
'# Project State',
'',
'## Configuration',
'Current Phase: 1',
'Status: Executing Phase 1',
'Last Activity: 2026-01-01',
'',
].join('\n'),
'utf-8',
);
fs.writeFileSync(path.join(planning, 'config.json'), '{}', 'utf-8');
// Create 7 phase dirs (6 integer + 1 decimal).
const phaseDirs = [
'01-alpha',
'02-beta',
'03-gamma',
'04-delta',
'05-epsilon',
'05.1-inserted-hotfix',
'06-zeta',
];
for (const d of phaseDirs) {
const dir = path.join(planning, 'phases', d);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
}
});
afterEach(() => {
cleanup(tmpDir);
});
test('state json total_phases matches roadmap.analyze phase_count (7, not 8)', () => {
// Authoritative count from roadmap.analyze — uses the digit-anchored pattern.
const analyzeResult = runGsdTools(['roadmap', 'analyze'], tmpDir);
assert.ok(analyzeResult.success, `roadmap analyze failed: ${analyzeResult.error}`);
const analyzed = JSON.parse(analyzeResult.output);
assert.equal(
analyzed.phase_count,
7,
`roadmap.analyze should count 7 phases (01-06 + 05.1), got ${analyzed.phase_count}`,
);
// State frontmatter must equal the authoritative count.
const stateResult = runGsdTools(['state', 'json'], tmpDir);
assert.ok(stateResult.success, `state json failed: ${stateResult.error}`);
const state = JSON.parse(stateResult.output);
assert.ok(state.progress, 'state json must return a progress block');
assert.equal(
state.progress.total_phases,
7,
`progress.total_phases must be 7 (not 8) — ## Phase Overview: section must not be counted as a phase. Got ${state.progress.total_phases}`,
);
assert.equal(
state.progress.total_phases,
analyzed.phase_count,
`progress.total_phases (${state.progress.total_phases}) must equal roadmap.analyze.phase_count (${analyzed.phase_count})`,
);
});
test('state sync total_phases matches roadmap.analyze phase_count', () => {
// Add a Progress field to the body so cmdStateSync has something to update.
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const before = fs.readFileSync(statePath, 'utf-8');
fs.writeFileSync(statePath, before.replace('Last Activity: 2026-01-01', 'Last Activity: 2026-01-01\nProgress: [░░░░░░░░░░] 0%'), 'utf-8');
const syncResult = runGsdTools(['state', 'sync'], tmpDir);
assert.ok(syncResult.success, `state sync failed: ${syncResult.error}`);
// Read frontmatter via state json (authoritative JSON path).
const stateResult = runGsdTools(['state', 'json'], tmpDir);
assert.ok(stateResult.success, `state json after sync failed: ${stateResult.error}`);
const state = JSON.parse(stateResult.output);
assert.ok(state.progress, 'state json must return a progress block after sync');
assert.equal(
state.progress.total_phases,
7,
`state sync must write total_phases: 7, not 8. ## Phase Overview: must not inflate the count. Got ${state.progress.total_phases}`,
);
});
test('integer-only project without decimal phase also counts correctly', () => {
// Regression guard: the fix must not break projects with no decimal phases.
const tmpDir2 = createTempProject('bug-549-integer-');
try {
const planning2 = path.join(tmpDir2, '.planning');
// ROADMAP: 4 integer phases only + non-phase section heading.
fs.writeFileSync(
path.join(planning2, 'ROADMAP.md'),
[
'## Milestone v1.0: Simple',
'',
'## Phase Overview:',
'',
'### Phase 01: One',
'**Goal:** one',
'',
'### Phase 02: Two',
'**Goal:** two',
'',
'### Phase 03: Three',
'**Goal:** three',
'',
'### Phase 04: Four',
'**Goal:** four',
].join('\n'),
'utf-8',
);
fs.writeFileSync(
path.join(planning2, 'STATE.md'),
'---\ngsd_state_version: 1.0\nmilestone: v1.0\nstatus: executing\n---\n\n# State\n\nStatus: Executing Phase 1\nLast Activity: 2026-01-01\n',
'utf-8',
);
fs.writeFileSync(path.join(planning2, 'config.json'), '{}', 'utf-8');
for (const d of ['01-one', '02-two', '03-three', '04-four']) {
const dir = path.join(planning2, '.planning', 'phases', d);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'PLAN.md'), '# Plan\n', 'utf-8');
}
const analyzeResult = runGsdTools(['roadmap', 'analyze'], tmpDir2);
assert.ok(analyzeResult.success, `roadmap analyze failed: ${analyzeResult.error}`);
const analyzed = JSON.parse(analyzeResult.output);
assert.equal(analyzed.phase_count, 4, `expected 4 phases, got ${analyzed.phase_count}`);
const stateResult = runGsdTools(['state', 'json'], tmpDir2);
assert.ok(stateResult.success, `state json failed: ${stateResult.error}`);
const state = JSON.parse(stateResult.output);
assert.ok(state.progress, 'state json must return a progress block');
assert.equal(
state.progress.total_phases,
4,
`integer-only project: total_phases must be 4, not 5. Got ${state.progress.total_phases}`,
);
} finally {
cleanup(tmpDir2);
}
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-3691-annotate-deps-plans-block-variants.test.cjs — consolidation epic #1969 (B2 #1971)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-3691-annotate-deps-plans-block-variants (consolidation epic #1969 B2 #1971)", () => {
'use strict';
// allow-test-rule: source-text-is-the-product (see #3691)
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Regression — issue #3691
*
* Two regex defects fixed in `roadmap.cjs` function `cmdRoadmapAnnotateDependencies`,
* plus two review-cycle additions (F3 defensive guard, F4 adversarial gaps):
*
* Bug 1 (line ~553) — Plans-block detection regex `/(Plans:\s*\n)/i` requires no text
* after the colon. Headers like `Plans: 3 plans across 2 waves\n` or
* `**Plans:** 3 plans\n` are silently skipped and the function early-returns.
* Fix: `(?:^|\n)(\*{0,2}Plans\*{0,2}:[^\n]*\n)` anchors to start-of-line and
* accepts optional bold wrappers and any trailing text on the header line.
*
* Bug 3 (line ~566) — Plan-ID extraction regex `/([\w-]+?)/` excludes `.`, so
* decimal plan IDs like `02.3-01` are captured as `02` only, never match
* the planData entry, and every plan defaults to wave 1.
* Fix: `[\w.-]+?` includes `.` so decimal IDs are captured in full.
*
* Note: "Bug 2" (phase-section boundary `\d` → `\d[\d.]*`) was confirmed empirically
* to be a no-op: any phase heading starts with a digit, so `\d` already matches.
* The no-op change was dropped from the PR; this file has no Bug 2 describe block.
*
* Review additions:
* F3 — Leading-dot plan ID guard: malformed IDs like `.invalid` are rejected
* before planData.find() rather than silently defaulting to wave 1.
* F4 — Adversarial gaps: multi-decimal leading-zero IDs (001.10-PLAN.md) and
* bare-bold `**Plans:**` (no trailing text) are explicitly covered.
*/
const { test, describe, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// ---------------------------------------------------------------------------
// Shared test helpers
// ---------------------------------------------------------------------------
function makePlanProject(files = {}) {
const dir = createTempProject();
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(dir, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf-8');
}
return dir;
}
/** Build a minimal PLAN.md frontmatter string */
function makePlan({ phase, plan, wave, dependsOn = [] }) {
return [
'---',
`phase: "${phase}"`,
`plan: "${plan}"`,
'type: standard',
`wave: ${wave}`,
`depends_on: [${dependsOn.map(d => `"${d}"`).join(', ')}]`,
'files_modified: []',
'autonomous: true',
'must_haves:',
' truths: []',
' artifacts: []',
' key_links: []',
'---',
'',
`<objective>Plan ${plan}</objective>`,
'',
].join('\n');
}
// ---------------------------------------------------------------------------
// Bug 1 — Plans-block detection: inline summary text after the colon
// ---------------------------------------------------------------------------
describe('bug #3691 — Bug 1: Plans-block detection with inline summary', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('Plans: N plans (inline count after colon) is detected as a Plans-block', (_t) => {
// Pre-fix: `Plans:\s*\n` requires bare newline — fails for "Plans: 2 plans\n"
// Post-fix: `Plans:[^\n]*\n` accepts any text after the colon
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Set up project',
'',
'Plans: 2 plans',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'Plans-block with inline summary must be detected and written');
assert.ok(out.waves >= 1, 'at least one wave must be written');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(written.includes('Wave'), 'wave annotation must appear in ROADMAP.md');
});
test('Plans: N plans across N waves (longer inline text) is detected', (_t) => {
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'Plans: 3 plans across 2 waves',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'- [ ] 01-03-PLAN.md — Task C',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 1 }),
'.planning/phases/01-foundation/01-03-PLAN.md': makePlan({ phase: '1', plan: '01-03', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'Plans-block with "N plans across N waves" inline text must be detected');
});
test('**Plans:** (bold markdown wrapper) is detected as a Plans-block', (_t) => {
// Bold wrapper: `**Plans:** 3 plans across 2 waves`
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Plans:** 2 plans',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2 }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'**Plans:** bold-wrapped header must be detected as a Plans-block');
});
test('bare Plans: (no inline text, legacy format) still works after fix', (_t) => {
// Regression guard: the fix must not break the working case
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'Plans:',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2 }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'bare Plans: (legacy format) must still be detected after the fix');
});
});
// ---------------------------------------------------------------------------
// Bug 3 — Plan-ID extraction: decimal phase IDs like 02.3-01
// ---------------------------------------------------------------------------
describe('bug #3691 — Bug 3: decimal plan IDs (e.g. 02.3-01-PLAN.md) parse correctly', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('decimal plan ID 02.3-01 is captured fully and matched to the correct wave', (_t) => {
// Pre-fix: `[\w-]+?` stops at `.` → captures `02` only → planData.find misses → wave = 1 for all
// Post-fix: `[\w.-]+?` captures `02.3-01` → planData.find resolves → correct wave written
const roadmap = [
'# Roadmap',
'',
'### Phase 02.3: Surgical edit ops',
'',
'Plans: 2 plans across 2 waves',
'- [ ] 02.3-01-PLAN.md — Path resolver',
'- [ ] 02.3-02-PLAN.md — Op handlers',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/02.3-surgical-edit-ops/02.3-01-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-01', wave: 1 }),
'.planning/phases/02.3-surgical-edit-ops/02.3-02-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-02', wave: 2, dependsOn: ['02.3-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 02.3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'decimal-phase ROADMAP with inline Plans: summary must be annotated');
assert.strictEqual(out.waves, 2,
'two distinct waves must be identified (02.3-01→wave 1, 02.3-02→wave 2)');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(/Wave 1/.test(written), 'Wave 1 header must appear in output');
assert.ok(/Wave 2/.test(written), 'Wave 2 header must appear in output');
});
test('combined fixture: decimal phase + bold Plans: header (both bugs together)', (_t) => {
// Exercises Bug 1 (bold **Plans:** header) AND Bug 3 (decimal IDs) simultaneously.
// This is the exact ROADMAP fragment from the issue report.
const roadmap = [
'# Roadmap',
'',
'## Milestone v1.2',
'',
'### Phase 02.3: Surgical edit ops',
'',
'**Plans:** 3 plans across 2 waves',
'- [ ] 02.3-01-PLAN.md — Path resolver',
'- [ ] 02.3-02-PLAN.md — Op handlers',
'- [ ] 02.3-03-PLAN.md — Tests',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/02.3-surgical-edit-ops/02.3-01-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-01', wave: 1 }),
'.planning/phases/02.3-surgical-edit-ops/02.3-02-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-02', wave: 1 }),
'.planning/phases/02.3-surgical-edit-ops/02.3-03-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-03', wave: 2, dependsOn: ['02.3-01', '02.3-02'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 02.3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'combined fixture (bold Plans: + decimal IDs) must produce updated: true');
assert.strictEqual(out.waves, 2,
'wave 2 dependency must be detected from decimal plan IDs');
});
});
// ---------------------------------------------------------------------------
// F3 review fix — Leading-dot ID validation guard (defensive, malformed ROADMAP)
// ---------------------------------------------------------------------------
describe('review fix F3 — leading-dot plan ID is rejected (defensive guard)', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('checklist line with leading-dot plan ID is skipped and does not silently default to wave 1', (_t) => {
// Guards: `.invalid-PLAN.md` would be captured as `.invalid` by the `[\w.-]+?` regex
// (since `.` is now included), which starts with a dot — an invalid ID.
// Without the guard, planData.find() misses it and wave defaults to 1, silently
// polluting the output. With the guard, the line is skipped entirely.
// We verify this by having TWO real plans with known waves (1 and 2), plus one
// malformed line. The malformed line must not appear in the wave-annotated output.
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'Plans: 3 items',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] .invalid-PLAN.md — Corrupted entry',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
// Two valid plans resolve to 2 waves — annotation must still proceed
assert.strictEqual(out.updated, true, 'annotation must proceed despite malformed line');
assert.strictEqual(out.waves, 2, 'two distinct waves from the two valid plans');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// The malformed line should not appear in the written output (it was skipped)
assert.ok(!written.includes('.invalid-PLAN.md'),
'malformed leading-dot entry must be dropped from the annotated output');
});
});
// ---------------------------------------------------------------------------
// F4 review additions — adversarial test gaps
// ---------------------------------------------------------------------------
describe('review fix F4 — adversarial test gaps', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('001.10-PLAN.md multi-decimal leading-zero ID is captured fully and wave-assigned correctly', (_t) => {
// Guards regression of Bug 3: `[\w-]+?` would stop at the first `.` and
// capture `001` instead of `001.10`, which never matches any planData entry.
// Post-fix `[\w.-]+?` must capture `001.10` in full.
const roadmap = [
'# Roadmap',
'',
'### Phase 001.10: Extended decimal phase',
'',
'Plans: 2 plans across 2 waves',
'- [ ] 001.10-01-PLAN.md — First task',
'- [ ] 001.10-02-PLAN.md — Second task',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/001.10-extended/001.10-01-PLAN.md': makePlan({ phase: '001.10', plan: '001.10-01', wave: 1 }),
'.planning/phases/001.10-extended/001.10-02-PLAN.md': makePlan({ phase: '001.10', plan: '001.10-02', wave: 2, dependsOn: ['001.10-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 001.10', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'multi-decimal leading-zero plan ID must be captured fully and annotated');
assert.strictEqual(out.waves, 2,
'wave 2 dependency must be resolved from full 001.10-02 ID (not truncated to 001)');
});
test('**Plans:** (bold, no trailing text) is matched and checklist is processed', (_t) => {
// Guards the bare-bold variant: `**Plans:**` with nothing after the colon.
// The `[^\n]*` quantifier accepts zero chars so this should already work,
// but this test would fail if `\*{0,2}Plans\*{0,2}` regressed to require no stars.
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Plans:**',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'**Plans:** bare-bold variant (no trailing text) must be detected and annotated');
assert.strictEqual(out.waves, 2,
'wave assignment must resolve correctly from planData for bare-bold variant');
});
});
// ---------------------------------------------------------------------------
// Bug #1103 — leading newline dropped in replace, fusing adjacent lines
//
// On the SUCCESSFUL mutation path, the block matcher anchors with `(?:^|\n)`.
// On a mid-string match (a `**Plans:** N plans` bold summary line directly above
// a bare `Plans:` block) `plansBlockMatch[0]` begins with the consumed `\n`. The
// replacement did not re-emit it, fusing the summary line onto the header →
// `**Plans:** 3 plansPlans:`. Distinct from #3691's silent-no-op detection bugs;
// #3691's Bug 1 fix is what let this two-line layout be matched and exposed it.
// ---------------------------------------------------------------------------
describe('bug #1103 — annotate-dependencies preserves newline before Plans: header', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('bold **Plans:** summary line followed by bare Plans: header are not fused', (_t) => {
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Set up project',
'',
'**Plans:** 3 plans',
'Plans:',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'- [ ] 01-03-PLAN.md — Task C',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 1 }),
'.planning/phases/01-foundation/01-03-PLAN.md': makePlan({ phase: '1', plan: '01-03', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true, 'annotation must succeed and write back');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// Core assertion: the malformed fusion string must NOT appear.
assert.ok(
!written.includes('plansPlans:'),
`ROADMAP must not contain the fused string "plansPlans:"; got:\n${written}`
);
// The bold summary line must keep its line boundary before Plans:.
assert.ok(
written.includes('**Plans:** 3 plans\nPlans:'),
`**Plans:** 3 plans must be followed by a newline before Plans:; got:\n${written}`
);
assert.ok(written.includes('Wave'), 'wave annotation must appear in ROADMAP.md');
});
test('inline Plans: header (no bold summary prefix) still annotates after fix', (_t) => {
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'Plans: 2 plans',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true, 'inline Plans: header must still be annotated after fix');
});
test('mid-string Plans: header keeps exact heading→header spacing (no extra newline)', (_t) => {
// `phaseSection` always begins with the `### Phase` heading, so the `Plans:`
// match is always mid-string and `(?:^|\n)` matches a `\n` (the start-of-string
// `^` branch is unreachable through the handler). This guards the inverse of the
// bug: the re-emitted newline must restore EXACTLY the one `\n` the regex
// consumed — not a doubled blank line and not a fusion.
const roadmap = [
'### Phase 1: Foundation',
'',
'Plans:',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true, 'Plans: must still be detected and annotated');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(
written.includes('### Phase 1: Foundation\n\nPlans:'),
`heading→Plans spacing must be preserved exactly; got:\n${written}`
);
assert.ok(!written.includes('FoundationPlans:'), 'heading must not fuse onto Plans:');
assert.ok(!written.includes('Foundation\n\n\nPlans:'), 'no doubled blank line before Plans:');
});
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// bug #2978: roadmap validate returns {"warnings":[]} for every input
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #2978: roadmap validate performs structural validation', () => {
test('empty (zero-byte) ROADMAP.md → non-empty warnings, non-zero exit', () => {
const tmpDir = createTempProject('gsd-2978-empty-');
try {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '');
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'empty file must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(Array.isArray(payload.warnings) && payload.warnings.length > 0,
`empty file must produce non-empty warnings; got: ${JSON.stringify(payload)}`);
} finally { cleanup(tmpDir); }
});
test('garbage/non-roadmap text → non-empty warnings, non-zero exit', () => {
const tmpDir = createTempProject('gsd-2978-garbage-');
try {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'not a roadmap at all');
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'garbage must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.length > 0, 'garbage must produce warnings');
} finally { cleanup(tmpDir); }
});
test('missing ROADMAP.md → non-empty warnings, non-zero exit', () => {
const tmpDir = createTempProject('gsd-2978-missing-');
try {
// createTempProject creates .planning/phases but no ROADMAP.md — don't write one
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'missing file must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.length > 0, 'missing file must produce warnings');
} finally { cleanup(tmpDir); }
});
test('truncated frontmatter (unterminated ---) → non-empty warnings', () => {
const tmpDir = createTempProject('gsd-2978-trunc-');
try {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'---\nmilestone: v1.0\n### Phase 1: Setup\n');
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'truncated frontmatter must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.length > 0, 'truncated frontmatter must produce warnings');
} finally { cleanup(tmpDir); }
});
test('well-formed roadmap → warnings: [], exit 0 (no false positive)', () => {
const tmpDir = createTempProject('gsd-2978-good-');
try {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## v1.0\n\n### Phase 1: Foundation\n**Goal:** setup\n');
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.ok(result.success, `well-formed roadmap must exit 0; got: ${result.error}`);
const payload = JSON.parse(result.output);
assert.deepStrictEqual(payload.warnings, [], 'well-formed roadmap must have no warnings');
} finally { cleanup(tmpDir); }
});
test('BOM-prefixed well-formed roadmap → warnings: [], exit 0 (not corruption)', () => {
const tmpDir = createTempProject('gsd-2978-bom-');
try {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
'\uFEFF# Roadmap\n\n### Phase 1: Foundation\n**Goal:** setup\n');
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.ok(result.success, `BOM-prefixed roadmap must exit 0; got: ${result.error}`);
const payload = JSON.parse(result.output);
assert.deepStrictEqual(payload.warnings, [], 'BOM is not corruption');
} finally { cleanup(tmpDir); }
});
});
// ─────────────────────────────────────────────────────────────────────────────
// bug #3263: roadmap validate is silent when the active milestone window is
// truncated (phase entries exist in the document but are excluded from the
// resolved window). The scope discriminator (#3184) already classifies this
// as SCOPE.TRUNCATED; validate must surface it as V005 + non-zero exit.
// Matrix: .gsd/bug/fix-3263-milestone-window-truncation-retest/50-test-matrix.md
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #3263: roadmap validate warns on a truncated milestone window', () => {
function writeFixture(tmpDir, roadmapContent, stateFields) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent);
if (stateFields) {
const lines = ['---'];
for (const [k, v] of Object.entries(stateFields)) lines.push(`${k}: ${v}`);
lines.push('---', '');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), lines.join('\n'));
}
}
test('window closed before its phase entries → V005 warning, non-zero exit', () => {
const tmpDir = createTempProject('gsd-3263-truncated-');
try {
// v3.0's window closes at the intervening `## v4.0 Next` heading,
// before the phase entries — which exist in the document, under v4.0.
writeFixture(tmpDir, [
'# Roadmap',
'',
'## v3.0 In Progress 🚧',
'',
'Some preamble notes. No phase headings here.',
'',
'## v4.0 Next',
'',
'### Phase 1: Foo',
'',
'### Phase 2: Bar',
].join('\n'), { milestone: 'v3.0' });
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'truncated window must exit non-zero');
const payload = JSON.parse(result.output);
const v005 = payload.warnings.find((w) => w.code === 'V005');
assert.ok(v005, `truncated window must produce a V005 warning; got: ${JSON.stringify(payload)}`);
assert.ok(v005.message.length > 0, 'V005 carries a message');
} finally { cleanup(tmpDir); }
});
test('phases inside the window → no V005, exit 0 (no false positive)', () => {
const tmpDir = createTempProject('gsd-3263-normal-');
try {
writeFixture(tmpDir, [
'# Roadmap',
'',
'## v3.0 In Progress 🚧',
'',
'### Phase 1: Foo',
'',
'### Phase 2: Bar',
'',
'## v4.0 Next',
'',
'Later plans.',
].join('\n'), { milestone: 'v3.0' });
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.ok(result.success, `normal roadmap must exit 0; got: ${result.error}`);
const payload = JSON.parse(result.output);
assert.deepStrictEqual(payload.warnings, [], 'normal roadmap must have no warnings');
} finally { cleanup(tmpDir); }
});
test('genuinely phase-less milestone → V004 only, no V005', () => {
const tmpDir = createTempProject('gsd-3263-empty-');
try {
writeFixture(tmpDir, [
'# Roadmap',
'',
'## v1.0 Current 🚧',
'',
'Nothing planned yet.',
].join('\n'), { milestone: 'v1.0' });
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'phase-less roadmap must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.some((w) => w.code === 'V004'),
`V004 still owns the no-phase-entries case; got: ${JSON.stringify(payload)}`);
assert.ok(!payload.warnings.some((w) => w.code === 'V005'),
`a genuinely phase-less milestone must not produce V005; got: ${JSON.stringify(payload)}`);
} finally { cleanup(tmpDir); }
});
test('unscoped versioned roadmap (no STATE.md milestone) → no V005 over-fire', () => {
const tmpDir = createTempProject('gsd-3263-unscoped-');
try {
// No STATE.md: scope is UNSCOPED, not TRUNCATED — must not fire V005.
writeFixture(tmpDir, [
'# Roadmap',
'',
'## v1.0 Old ✅ SHIPPED',
'',
'### Phase 1: Foo',
].join('\n'));
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.ok(result.success, `unscoped roadmap must exit 0; got: ${result.error}`);
const payload = JSON.parse(result.output);
assert.deepStrictEqual(payload.warnings, [], 'unscoped roadmap must have no warnings');
} finally { cleanup(tmpDir); }
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #3641: hasPhaseEntries is convention-blind — V005 (and V004) cannot see
// bracket-convention phase entries (`### [GSD.04] 01: Name`), so a genuinely
// truncated bracket window classifies COMPLETE and validate stays silent
// (while V004 falsely reports "no recognizable phase entries"). The fix
// threads the resolved `phase_id_convention` into hasPhaseEntries and routes
// V004 through the same owner. Mirrors the #3263 harness directly above.
// Matrix: .gsd/bug/fix-3641-hasphaseentries-convention-v005/50-test-matrix.md
// ─────────────────────────────────────────────────────────────────────────────
describe('bug #3641: bracket-convention windows are visible to validate (V005/V004)', () => {
const ROADMAP_PARSER_LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'roadmap-parser.cjs');
function writeFixture3641(tmpDir, roadmapContent, stateFields, conventionSource) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent);
if (stateFields) {
const lines = ['---'];
for (const [k, v] of Object.entries(stateFields)) lines.push(`${k}: ${v}`);
lines.push('---', '');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), lines.join('\n'));
}
if (conventionSource && conventionSource.kind === 'config') {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ phase_id_convention: 'bracket' }, null, 2),
);
}
// kind === 'frontmatter': the roadmap content itself carries the frontmatter.
}
const BRACKET_TRUNCATED_ROADMAP = [
'# Roadmap',
'',
'## v3.0 In Progress 🚧',
'',
'Some preamble notes. No phase headings here.',
'',
'## v4.0 Next',
'',
'### [GSD.04] 01: Foo',
'',
'### [GSD.04] 02: Bar',
].join('\n');
test('bracket truncated window (config convention) → V005 fires, no false V004', (t) => {
const tmpDir = createTempProject('gsd-3641-bracket-truncated-');
t.after(() => cleanup(tmpDir));
writeFixture3641(tmpDir, BRACKET_TRUNCATED_ROADMAP, { milestone: 'v3.0' }, { kind: 'config' });
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'truncated bracket window must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.some((w) => w.code === 'V005'),
`truncated bracket window must produce V005; got: ${JSON.stringify(payload)}`);
assert.ok(!payload.warnings.some((w) => w.code === 'V004'),
`the document HAS bracket phase entries — V004 must not fire; got: ${JSON.stringify(payload)}`);
});
test('bracket complete window → no warnings, exit 0', (t) => {
const tmpDir = createTempProject('gsd-3641-bracket-complete-');
t.after(() => cleanup(tmpDir));
writeFixture3641(tmpDir, [
'# Roadmap',
'',
'## v3.0 In Progress 🚧',
'',
'### [GSD.03] 01: Foo',
'',
'### [GSD.03] 02: Bar',
'',
'## v4.0 Next',
'',
'Later plans.',
].join('\n'), { milestone: 'v3.0' }, { kind: 'config' });
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.ok(result.success, `complete bracket window must exit 0; got: ${result.error}`);
const payload = JSON.parse(result.output);
assert.deepStrictEqual(payload.warnings, [], `complete bracket window must have no warnings; got: ${JSON.stringify(payload)}`);
});
test('bracket truncated window (frontmatter convention) → V005 fires', (t) => {
const tmpDir = createTempProject('gsd-3641-bracket-fm-');
t.after(() => cleanup(tmpDir));
const withFrontmatter = [
'---',
'phase_id_convention: bracket',
'---',
'',
BRACKET_TRUNCATED_ROADMAP,
].join('\n');
writeFixture3641(tmpDir, withFrontmatter, { milestone: 'v3.0' }, { kind: 'frontmatter' });
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'frontmatter-convention truncated window must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.some((w) => w.code === 'V005'),
`frontmatter convention must resolve for V005; got: ${JSON.stringify(payload)}`);
});
test('legacy truncated window without convention → V005 still fires (parity)', (t) => {
const tmpDir = createTempProject('gsd-3641-legacy-parity-');
t.after(() => cleanup(tmpDir));
writeFixture3641(tmpDir, [
'# Roadmap',
'',
'## v3.0 In Progress 🚧',
'',
'Some preamble notes.',
'',
'## v4.0 Next',
'',
'### Phase 1: Foo',
'',
'### Phase 2: Bar',
].join('\n'), { milestone: 'v3.0' }, null);
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'legacy truncated window must still exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.some((w) => w.code === 'V005'),
`legacy V005 behavior must be unchanged; got: ${JSON.stringify(payload)}`);
});
test('bracket-spelling roadmap without convention → not widened (V004 only)', (t) => {
const tmpDir = createTempProject('gsd-3641-no-convention-');
t.after(() => cleanup(tmpDir));
writeFixture3641(tmpDir, BRACKET_TRUNCATED_ROADMAP, { milestone: 'v3.0' }, null);
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'unrecognized entries must still exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.some((w) => w.code === 'V004'),
`a project that never opted in keeps the legacy reading (V004); got: ${JSON.stringify(payload)}`);
assert.ok(!payload.warnings.some((w) => w.code === 'V005'),
`no convention declared — the widened grammar must not engage; got: ${JSON.stringify(payload)}`);
});
test('bracket phase-less roadmap with bracket milestone name headings → V004 only, no V005', (t) => {
const tmpDir = createTempProject('gsd-3641-bracket-phaseless-');
t.after(() => cleanup(tmpDir));
writeFixture3641(tmpDir, [
'# Roadmap',
'',
'## [GSD.02] Foundation',
'',
'Nothing planned yet. A bracket MILESTONE heading is a bracket plus a',
'name — not a phase entry (no digit-then-colon tail).',
].join('\n'), { milestone: 'v3.0' }, { kind: 'config' });
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'genuinely phase-less bracket roadmap must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.some((w) => w.code === 'V004'),
`V004 owns the phase-less case under bracket too; got: ${JSON.stringify(payload)}`);
assert.ok(!payload.warnings.some((w) => w.code === 'V005'),
`a phase-less window must never produce V005; got: ${JSON.stringify(payload)}`);
});
test('fenced bracket heading example does not count as a phase entry', (t) => {
const tmpDir = createTempProject('gsd-3641-bracket-fence-');
t.after(() => cleanup(tmpDir));
writeFixture3641(tmpDir, [
'# Roadmap',
'',
'## v3.0 In Progress 🚧',
'',
'Documentation example of the syntax:',
'',
'```markdown',
'### [GSD.03] 01: Documented example',
'```',
'',
'No real phase headings anywhere.',
'',
'## v4.0 Next',
'',
'Later plans.',
].join('\n'), { milestone: 'v3.0' }, { kind: 'config' });
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'fenced-example-only roadmap must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.some((w) => w.code === 'V004'),
`a fenced example is not a real entry; got: ${JSON.stringify(payload)}`);
assert.ok(!payload.warnings.some((w) => w.code === 'V005'),
`fenced examples must not flip the scope axis; got: ${JSON.stringify(payload)}`);
});
test('bracket+label mixed spelling still recognized under bracket convention', (t) => {
const tmpDir = createTempProject('gsd-3641-bracket-mixed-');
t.after(() => cleanup(tmpDir));
writeFixture3641(tmpDir, [
'# Roadmap',
'',
'## v3.0 In Progress 🚧',
'',
'### [GSD.03] Phase 01: Foo',
'',
'## v4.0 Next',
'',
'Later plans.',
].join('\n'), { milestone: 'v3.0' }, { kind: 'config' });
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.ok(result.success, `mixed spelling is already-recognized — must exit 0; got: ${result.error}`);
const payload = JSON.parse(result.output);
assert.deepStrictEqual(payload.warnings, [], `mixed spelling must stay recognized; got: ${JSON.stringify(payload)}`);
});
test('colon-bearing bracket milestone heading in-window does not read as a phase entry (#3641 review HIGH)', (t) => {
const tmpDir = createTempProject('gsd-3641-bracket-colon-milestone-');
t.after(() => cleanup(tmpDir));
writeFixture3641(tmpDir, [
'# Roadmap',
'',
'## [GSD.03] v3.0: Current',
'',
'No phases under the active milestone — and its own heading carries a',
'colon, which must NOT make the window read as having phase entries.',
'',
'## [GSD.04] v4.0: Next',
'',
'### [GSD.04] 01: Foo',
].join('\n'), { milestone: 'v3.0' }, { kind: 'config' });
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'truncated bracket window (colon-bearing milestone heading) must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.some((w) => w.code === 'V005'),
`ADR-612 discriminator: a bracket + NAME(:version) heading is a milestone, not an entry — the window is still truncated; got: ${JSON.stringify(payload)}`);
assert.ok(!payload.warnings.some((w) => w.code === 'V004'), `real bracket entries exist; got: ${JSON.stringify(payload)}`);
});
test('decoy bracket notes heading outside the window does not manufacture V005 (#3641 review MEDIUM)', (t) => {
const tmpDir = createTempProject('gsd-3641-bracket-decoy-');
t.after(() => cleanup(tmpDir));
writeFixture3641(tmpDir, [
'# Roadmap',
'',
'## v3.0 Current 🚧',
'',
'Nothing planned yet — a genuinely phase-less milestone.',
'',
'## v4.0 Next',
'',
'### [GSD.04] Notes: follow-ups',
'',
'Prose under a later milestone, not a phase entry.',
].join('\n'), { milestone: 'v3.0' }, { kind: 'config' });
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'phase-less bracket roadmap must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.some((w) => w.code === 'V004'),
`no real entries — V004 owns the verdict; got: ${JSON.stringify(payload)}`);
assert.ok(!payload.warnings.some((w) => w.code === 'V005'),
`a decoy colon heading must never flip the scope axis to TRUNCATED; got: ${JSON.stringify(payload)}`);
});
test('milestone-scope probe agrees with validate on a truncated bracket window (#3641 review MEDIUM)', (t) => {
const tmpDir = createTempProject('gsd-3641-bracket-probe-');
t.after(() => cleanup(tmpDir));
writeFixture3641(tmpDir, BRACKET_TRUNCATED_ROADMAP, { milestone: 'v3.0' }, { kind: 'config' });
const result = runGsdTools(['roadmap', 'milestone-scope', '--raw'], tmpDir);
assert.ok(result.success, `probe must exit 0; got: ${result.error}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.scope, 'truncated',
`the #3262 capture/compare signal must agree with validate's V005 classifier; got: ${JSON.stringify(payload)}`);
});
test('colon-less legacy phase heading alone still yields V004 (owner-entry definition pin)', (t) => {
const tmpDir = createTempProject('gsd-3641-colonless-');
t.after(() => cleanup(tmpDir));
writeFixture3641(tmpDir, [
'# Roadmap',
'',
'## v3.0 Current 🚧',
'',
'### Phase 1 — Foo',
].join('\n'), { milestone: 'v3.0' }, null);
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
assert.strictEqual(result.success, false, 'colon-less-only roadmap must exit non-zero');
const payload = JSON.parse(result.output);
assert.ok(payload.warnings.some((w) => w.code === 'V004'),
`the canonical entry grammar is colon-terminated — a colon-less heading is not an entry for ANY reader; got: ${JSON.stringify(payload)}`);
});
test('hasPhaseEntries gates the widened grammar on the bracket convention (unit seam)', () => {
// Direct rows on the exported predicate: the widened heading grammar
// engages ONLY when the resolved convention is 'bracket'.
const roadmapParser = require(ROADMAP_PARSER_LIB);
assert.strictEqual(typeof roadmapParser.hasPhaseEntries, 'function',
'hasPhaseEntries must be exported for the validate seam (#3641)');
const bracketHeading = '### [GSD.04] 01: Foo';
const legacyHeading = '### Phase 01: Foo';
assert.strictEqual(roadmapParser.hasPhaseEntries(bracketHeading, 'bracket'), true,
"bracket convention: '[GSD.04] 01:' is a phase entry");
assert.strictEqual(roadmapParser.hasPhaseEntries(bracketHeading, null), false,
'no convention: the bracket+bare-number spelling is not an entry');
assert.strictEqual(roadmapParser.hasPhaseEntries(legacyHeading, 'bracket'), true,
'bracket convention is a superset — the legacy label stays recognized');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #3885 (ADR-3473 §8.5) / item 5 — `countPhasePlansAndSummaries` swallows an
// unreadable phase directory into "absent", indistinguishable from a phase
// that genuinely has no CONTEXT.md.
//
// Mechanism (src/roadmap.cts, countPhasePlansAndSummaries):
// try { phaseFiles = fs.readdirSync(phaseDir); } catch { /* empty */ }
// An EACCES/EIO collapses `phaseFiles` to `[]`, which makes
// `findContextMdIn(scopedFiles)` return null — identical to a phase dir that
// was successfully read and genuinely has no CONTEXT.md. `cmdRoadmapAnalyze`
// (the only exported consumer) surfaces this as `has_context: false` on the
// phase's entry in `phases[]`, with nothing distinguishing "could not read"
// from "nothing there".
//
// DESIGN DECISION (chosen by this test file, not yet implemented): each
// `AnalyzePhase` gains a `context_read_error: string | null` field — null on
// success (including a genuinely missing/ENOENT directory), and a message
// string naming the phase directory when the readdirSync call fails with any
// non-ENOENT error (EACCES, EIO, ...). Mirrors the SCOPE.UNREADABLE
// discriminator `src/core-utils.cts`'s `getPhaseFileStats` already uses to
// keep "unreadable" separate from "absent" on the sibling phase-stats path.
//
// `countPhasePlansAndSummaries` itself is not exported from roadmap.cjs, so
// these tests drive the ONLY exported consumer, `cmdRoadmapAnalyze`, in
// process — injecting the fs failure by monkeypatching `fs.readdirSync`
// (restored in `finally`) and capturing `output()`'s raw fd-1 write by
// monkeypatching `fs.writeSync` (io.cjs writes via `fs.writeSync(1, ...)`,
// bypassing console.log, so `captureConsole()` cannot see it). NEVER
// `chmod 0o000` — root bypasses mode bits, so that trick passes with zero
// coverage in root Docker/CI.
describe('#3885 (ADR-3473 §8.5): countPhasePlansAndSummaries distinguishes unreadable from absent (roadmap.cts caller)', () => {
let tmpDir;
let roadmapLib;
beforeEach(() => {
tmpDir = createTempProject();
roadmapLib = require('../gsd-core/bin/lib/roadmap.cjs');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 3: API\n**Goal:** Build API\n',
);
});
afterEach(() => {
cleanup(tmpDir);
});
// Runs `roadmapLib.cmdRoadmapAnalyze(cwd, false)` while capturing the raw
// fd-1 bytes `output()` writes via `fs.writeSync`, and returns the parsed
// JSON result. Restores `fs.writeSync` in `finally` even if analyze throws.
function runAnalyzeCapturingStdout(cwd) {
const chunks = [];
const origWriteSync = fs.writeSync;
fs.writeSync = function patchedWriteSync(fd, buffer, offset, length) {
if (fd !== 1) return origWriteSync.apply(fs, arguments);
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
const start = offset ?? 0;
const len = length ?? (buf.length - start);
chunks.push(Buffer.from(buf.subarray(start, start + len)));
return len;
};
try {
roadmapLib.cmdRoadmapAnalyze(cwd, false);
} finally {
fs.writeSync = origWriteSync;
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
// Monkeypatches `fs.readdirSync` so a call whose FIRST argument resolves to
// `targetPath` throws an error shaped like `code`; every other path is
// delegated to the real implementation. Returns a restorer — callers MUST
// invoke it in `finally`.
function injectReaddirFailure(targetPath, code) {
const resolved = path.resolve(targetPath);
const origReaddirSync = fs.readdirSync;
fs.readdirSync = function patchedReaddirSync(p, ...rest) {
if (path.resolve(String(p)) === resolved) {
const err = new Error(`${code}: simulated failure, scandir '${p}'`);
err.code = code;
throw err;
}
return origReaddirSync.call(fs, p, ...rest);
};
return () => { fs.readdirSync = origReaddirSync; };
}
function findPhase3(analyzeOutput) {
const phase = analyzeOutput.phases.find((p) => p.number === '3');
assert.ok(phase, `phase 3 must appear in analyze output; got: ${JSON.stringify(analyzeOutput.phases)}`);
return phase;
}
// T61 — MUST STAY GREEN: a readable phase directory with no CONTEXT.md
// reports has_context:false, and (using `?? null` so this passes both
// before and after the fix) no read-error signal.
test('T61: readableDirWithoutContextReportsFalse', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n## Task 1\n');
const output = runAnalyzeCapturingStdout(tmpDir);
const phase = findPhase3(output);
assert.strictEqual(phase.has_context, false, 'no CONTEXT.md on disk — has_context must be false');
assert.strictEqual(phase.context_read_error ?? null, null, 'a readable, genuinely context-less dir must report no read error');
});
// T62 — RED today: measured on this tree, an EACCES on the phase
// directory's readdirSync collapses to has_context:false /
// disk_status:"empty" with nothing distinguishing it from a phase that was
// successfully read and genuinely has no CONTEXT.md. Required: the failure
// must be reported, naming the phase directory.
test('T62: unreadablePhaseDirIsNotReportedAsAbsent', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n## Task 1\n');
const restore = injectReaddirFailure(phaseDir, 'EACCES');
let output;
try {
output = runAnalyzeCapturingStdout(tmpDir);
} finally {
restore();
}
const phase = findPhase3(output);
assert.strictEqual(
typeof (phase.context_read_error ?? null),
'string',
`an unreadable phase directory must be reported as an error, not silently absent; got context_read_error=${JSON.stringify(phase.context_read_error)}`,
);
assert.ok(
(phase.context_read_error || '').includes('03-api'),
`the reported error must name the discarded input (the phase directory); got: ${phase.context_read_error}`,
);
});
// T64 — MUST STAY GREEN: a genuinely missing directory (ENOENT) is absent,
// not an error — this is the row that keeps T62's fix from over-firing on
// every ordinary "no directory yet" phase. Injected the same way as T62/T63
// (readdirSync throws ENOENT for the exact phase-dir path) so the assertion
// exercises the discriminator itself, not merely "no error was ever
// thrown".
test('T64: missingDirIsGenuinelyAbsent', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n## Task 1\n');
const restore = injectReaddirFailure(phaseDir, 'ENOENT');
let output;
try {
output = runAnalyzeCapturingStdout(tmpDir);
} finally {
restore();
}
const phase = findPhase3(output);
assert.strictEqual(
phase.context_read_error ?? null,
null,
`ENOENT must be treated as genuinely absent, not reported as an error; got: ${phase.context_read_error}`,
);
});
});