Files
msd-core/tests/roadmap.test.cjs
Tom Boucher 15af0f5536 enhance(#3951): B6+B7 — widen two unreachable lint rules and make the guard ledger true (#3965)
* fix(#3951): two lint rules that could not reach the code they govern

B6 names two widenings. Measuring them first turned up a defect the criterion did
not know about, and refuted the reason it gave for one of them.

1. no-adhoc-markdown-parsing self-gates on its own filename.

   Lines 107-110 short-circuit create() to {} unless the path matches
   /(?:^|\/)src\/[^/]+\.cts$/. B6 says to widen the files: glob in
   eslint.config.mjs - but doing only that ships an INERT rule, because the gate
   still returns {} for every new path. Both halves have to change, and the gate
   is the load-bearing one.

   That same regex hides a live hole: [^/]+ is FLAT-ONLY, so it requires the file
   to sit directly in src/. The registered glob is src/**/*.cts, which includes
   subdirectories. 28 .cts files - health-diagnostic-rules/ (10),
   installer-migrations/ (11), observability/ (3), host-integration-adapters/ (2),
   vendor/ (2) - are inside the registered glob and silently skipped.

   Measured with the gate neutralized: 0 violations there today. The hole is
   hiding nothing right now, and is fixed anyway, because "no violations today" is
   not a property that keeps holding.

   The fix is not invented: require-subprocess-timeout.cjs:196 already carries the
   correct form of this guard, /(?:^|\/)src\/.*\.cts$/ with .*, one directory over.
   Checked the other 21 rules for the same bug - no-adhoc-regex-escape and
   no-private-binary-resolution short-circuit only to exempt their own seam file,
   which is the right shape, and no-crlf-fragile-split has no filename gate at
   all. This bug is unique to the one rule.

2. no-adhoc-regex-escape could not see the shape that actually occurs.

   Line 396 gated the whole UNSAFE-NEW-REGEXP arm on arg.type === 'Identifier'.
   Every check below it - the _SOURCE provenance check, the
   isSoleReturnOfOwnParameter shape - lives inside that branch, so
   new RegExp(obj['key']) and new RegExp(cfg.pattern) were never examined at all.
   Runtime data arrives as a property access far more often than as a bare
   identifier, which is exactly why this rule never fired on the #3477 ReDoS.

   Widened to MemberExpression, measured by AST walk across all five registered
   blocks rather than by grep. 27 sites, zero TSAsExpression:

     18  safe new RegExp(X.source, flags)  -> exempted, keyed strictly on the
         PROPERTY being `source`, never on the object. Keying on the object would
         wave through X.anything and buy nothing. B6 estimated ~10; that was an
         undercount.
      3  _SOURCE-suffixed constants reached through a required module namespace
         (phaseId.BRACKET_PHASE_TOKEN_SOURCE) -> the same provenance-exempt class
         the rule already recognizes for bare identifiers, extended to reach them.
         Without this the widening produces 3 false flags.
      6  real findings -> marked, each a test extracting a pattern from a shipped
         file at test time, where the runtime contract IS the product.

   Deliberately the NARROW MemberExpression form. The rule's own
   isSoleReturnOfOwnParameter doc comment records that an earlier broad
   "any non-literal identifier" heuristic produced ~25 false positives and was
   rejected; a re-run of the census after this change flags exactly the 6 above
   and nothing else.

Verified by execution, not by reading: the gate now accepts src/<subdir>/x.cts,
still accepts flat src/x.cts, and still exempts paths outside src/ - each pinned
by a test proven to fail against the old regex. build:lib, lint and lint:ci all
exit 0.

Refs #3951

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

* fix(#3951): give no-adhoc-markdown-parsing its reach, and fix the 80 parses it finds

The rule self-gates on filename AND is registered on one glob, so widening either
half alone is inert. Both move here: the gate now accepts tests/**/*.cjs and
scripts/**/*.cjs alongside src/**/*.cts, and eslint.config.mjs registers it on the
same two.

A test pins that the gate and the registration AGREE, in both directions. The
original defect was a gate narrower than its registration; the failure mode of
this fix is a gate wider than its registration. Both are silent, so the test
asserts the pair rather than either half.

80 violations across 43 files, all in tests/, zero in scripts/. 70 are routed
through the existing seams - scanFencedBlocks, collectSection, stripFencedCode,
tokenizeHeadings from markdown-sectionizer; splitTableRow, parseMarkdownTable,
findTableWithColumns from markdown-table. Headerless STATE.md tables use
splitTableRow per line, because parseMarkdownTable needs a real delimiter row.

10 are suppressed, 12.5%, well under the third that would have meant the rule is
mis-scoped for tests/ rather than the tests carrying debt. Each names its reason:
three regression guards (#3873 / bug-#21) are deliberately independent of the
generator's own fence handling, and routing them through the seam would have them
test the generator against itself; one is a negative-text probe that extracts
nothing; six are a shell-pipe-to-jq detector whose regex coincidentally matches the
table fingerprint and is not markdown parsing at all.

All ten sit in tests whose subject is .md content, which is normally a reason to
prefer the seam. The marker used is allow-adhoc-markdown, distinct from
no-source-grep's allow-test-rule, and lint:ci's lint-allow-test-rule-refs reports
the same 280/280 unverified count as before - checked rather than assumed, because
those two markers are easy to conflate.

The widening earned its keep immediately: it found a test that passed for the
wrong reason.

  tests/config-field-docs.test.cjs asserted notEqual(<cell>, '600') against the
  TYPE column instead of the DEFAULT column. notEqual('number', '600') is true
  forever, so the guard against workflow.subagent_timeout regressing to the old
  seconds default could never fire. docs/CONFIGURATION.md:434 is
  `| workflow.subagent_timeout | number | 300000 | ... |`, so the default is cell
  index 2; the assertion is now row-scoped through splitTableRow and reads 300000.

That is the argument for the widening in one case: the violation was invisible to
lint, the suite was green, and the assertion was vacuous. A rule that cannot reach
a file cannot tell you the file is lying.

Not fixed here, and recorded rather than assumed: #3426/#3239 are NOT reachable by
this widening. tests/package-legitimacy-gate.test.cjs yields zero violations even
with the gate bypassed - its hand-rolled scans are real, but built from line
filters and split('|') rather than the regex-literal fingerprints this rule
detects. They need new detectors. The epic assumed a wider glob would catch them.

build:lib, lint and lint:ci all exit 0; the post-fix census across tests/** and
scripts/** is 0 violations.

Refs #3951

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

* fix(#3951): B7 — and #3356's defects were still live in the code

B7 asks that each closed child be driven fail-first with a behavioral identity
test at the CONSUMER's output. Four of eleven children had no test citing their
issue number. Auditing them by BEHAVIOR rather than by number-grep changed the
answer for three of the four.

#3364 and #2540 — traceability only. Both were implemented by #3941 and their
consumer-output tests exist and were shown failing-first; neither cited its
originating issue, so an audit that greps for the number reports them uncovered.
Tagged the specific asserting test in each file, following the citation form those
files already use.

#3372 — covered, but only at helper level, and the triage narrowed it. Of the four
commands the issue names, only estimate-cli's collectCalibrationSamples actually
enumerates phase dirs from disk; smart-entry, audit and roadmap-upgrade derive from
ROADMAP/body text and never reach the sentinel path, so they are benign by
construction and were left alone rather than "fixed" into churn. The existing #3882
rows asserted the helper's return value. Added a consumer-output test driving
`query estimate-calibrate` and asserting sample_count and the persisted document.
RED proof: reverted collectCalibrationSamples to a raw readdirSync and ran the real
CLI - sample_count 3, sentinel leaked; restored - sample_count 2.

#3356 — NOT covered, and BOTH halves of the defect were still live in source. The
issue is closed; the bug was not fixed. Fixed here rather than writing tests that
document a bug as correct.

  Defect 1, the contradicted row. quick.md:627 claimed
  `quick-tasks-append` performs "the equivalent write" to the Step 7c row. It did
  not: the `#` cell was a positional ordinal and `Directory` read `—`, because the
  route had no way to receive a quick id or task directory. Added OPTIONAL
  `--quick-id` / `--slug` / `--directory`. A caller with neither - fast.md, the
  original #2133 caller - omits them and gets the byte-identical prior row, so
  nothing existing changes. A caller that HAS a real id and directory now gets the
  canonical row quick.md:632 renders. The false-equivalence sentence itself is
  corrected rather than left to mislead the next reader.

  Defect 2, the forced re-derive. The route called readModifyWriteStateMd with no
  options, so a body-only append to the Quick Tasks table triggered a full
  re-derive of the disk-derived progress.* frontmatter. Every other body-only
  writer passes { resync: false } - src/state.cts's own docstring prescribes it -
  and this route was the lone outlier. RED proof: reverted the option, seeded a
  project with 2 real phase dirs and a curated total_phases of 25, ran
  quick-tasks-append; total_phases collapsed to 2. Restored; it stayed 25.

That second one is the shape this epic exists to close: a silent write that
replaces curated state with a re-derivation nobody asked for, exit 0 throughout.

build:lib, lint and lint:ci all exit 0.

Refs #3951

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

* docs(#3951): amend B6's ledger to what was measured, and document the new flags

The ADR gains a ledger amendment in its own correction style - the sixth wrong
premise it records, found the same way as the other five, by measuring before
building.

B6 says the net guard count must fall. It rose: 62 -> 69, +7, measured from the
epic's filing commit to origin/next. The attribution is the point, though. Five of
the seven came from PRs unrelated to this epic, one was added by a phase of it, and
the epic did retire something sub-file - #3884 removed a detector with an explicit
"net: -1 detector, 0 added" ledger. Every named casualty is load-bearing, two
already carry retractions in this same document, and a sweep of all 22 rules plus
every scripts/lint-* found no provably dead guard. There is no honest way to make
the count fall; forcing it would trade coverage for a number, which is the Goodhart
outcome Decision 6 exists to prevent.

The amendment also records that B6's own prescribed fix for one widening was inert.
no-adhoc-markdown-parsing self-gates on its filename, so widening only the files:
glob - which is what the criterion says to do - ships a rule that still returns {}
for every new path. And #3426/#3239 are not reachable by that widening at all;
their scans use line filters and split('|'), not the regex fingerprints the rule
detects. The roster row tracked them against the wrong mechanism.

Three roster rows updated from aspiration to fact: the two widenings are DONE with
their measured counts, and lint-phase-enumeration-drift is marked RETAINED rather
than "expected casualty - verify before retiring", because Phase 5 verified it and
kept it.

The rule Decision 6 should carry forward is stated plainly: a guard ledger is a
claim about COVERAGE, not about COUNT. "Net count must fall" is measurable and
wrong. "Every guard is reachable, and each retirement names what makes its defect
unrepresentable" is the property that was actually wanted.

CLI-TOOLS.md documents the optional --quick-id/--slug/--directory flags and says
plainly that omitting them keeps the pre-#3356 row byte-identical, plus that the
append no longer re-derives progress frontmatter.

New features fragment (id 3951); FEATURES.md regenerated rather than hand-edited.
Changeset is Changed, pr:0 pending backfill.

Refs #3951

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

* test(#3951): correct four rows that pinned the lint rule's old narrow reach

The remote suite came back RED with 5 failures, all in tests/eslint-rules.test.cjs.
They are stale tests, not a regression: four rows assert that
no-adhoc-markdown-parsing is inert outside src/*.cts, which is exactly the
contract this deliverable changes.

Confirmed by reading rather than inferred from the names - the row at :1981 used
filename: 'tests/some.test.cjs' and filename: 'scripts/helper.cjs', the two roots
the rule now covers on purpose.

Worth recording WHY local gates missed this. npm run lint and lint:ci were green,
and the touched test files passed standalone. Lint only reports violations in real
files; these rows assert the rule's REACH using synthetic RuleTester filenames, so
nothing but the full suite could see them. Local green on a rule change says
nothing about the rule's own tests.

Each row is rewritten with BOTH halves rather than flipped from valid to invalid:

  - the same fingerprint under tests/ or scripts/ is now flagged, with the right
    messageId
  - the negative space is preserved - the same fingerprint under a path outside
    all three roots (gsd-core/bin/lib/foo.cjs) is still NOT flagged

The second half is the one that matters. Without it the rule has no boundary and
nothing would catch an over-wide gate later, which is the mirror image of the bug
this deliverable just fixed.

Each row is renamed to state the current contract; the old names said
"non-src/*.cts ... is not flagged" and would have been actively misleading once
the bodies changed.

Proven to test the widening rather than restate it: every flagged half was run
against HEAD~2's pre-widening rule and does NOT fire there, then against the
current rule and does. 12/12 on that probe; the full file is 178/178.

Swept for the same staleness elsewhere and found none.
require-subprocess-timeout's own "inert outside src/*.cts" row is untouched -
that rule's gate was not widened here - and no-adhoc-regex-escape's test file
already carries correctly-targeted rows.

Refs #3951

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

* test(#3951): acknowledge the quick.md growth the attribution guard reported

The full suite came back RED with one failure, and it is mine:

  1 file(s) grew without an acknowledgment:
    quick.md grew 364 bytes

gsd-core/workflows/quick.md is runtime-loaded emitted content, so correcting
its false 'performs the equivalent write' claim trips emitted-attribution by
construction. This is the acknowledgment, not a workaround - there is nothing
to regenerate.

The fragment names ONE path, which is the only one the guard reported. The four
spent acknowledgments it also listed (audit-uat, plan-phase, progress, review)
belong to other fragments whose ripple the base already absorbs; they are inert,
not failures, and are deliberately NOT copied here - naming paths I did not
change would make this record false in the other direction.

Byte figure corrected before committing: the guard reported 37220 -> 37584
(+364), but origin/next has since moved and quick.md is 37232 there now, so the
measured delta is +352. The reason text says so and names the base as a moving
figure rather than pinning a number that is already stale.

Refs #3951

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

* test(#3951): move the quick.md growth ack to a trailer, delete the obsolete fragment

The acknowledgment mechanism changed under this branch. Merging next brought in
the redesign - it also deleted .github/workflows/ack-fragment-sweep.yml, which
was in the merge status and which I did not register at the time - and the guard
now says so directly:

  Add a trailer to a commit in this PR (never a new file).
    Emitted-Drift-Ack-Growth: quick.md - <why this growth is deliberate>

So tests/emitted-drift-acks/3951-quick-append-equivalence.json is obsolete on
arrival. A fragment file is no longer read by anything, and leaving it would be a
dead record that looks like an active one. It is deleted here rather than kept
"just in case".

The byte figure moved again with the merge: 37232 -> 37596, +364. The earlier
fragment said +352, measured before the merge auto-merged quick.md itself. The
trailer carries no number, which is the better design - the figure was stale
twice in two attempts.

Refs #3951

Emitted-Drift-Ack-Growth: quick.md — #3356/#3951 replaces a false claim with an accurate one. Line 627 said the `quick-tasks-append` shortcut "performs the equivalent write" to the Step 7c row rendered above it; it did not, and that was the documented half of #3356 — with no quick id or task directory the route emitted a positional ordinal in `#` and an em-dash in `Directory`, a visibly different row. The corrected sentence has to carry three facts the original elided: what the shortcut actually writes when it has neither input, that this is honest behavior for its real caller (`fast.md`, which has neither), and how a caller with both now gets the byte-identical canonical row via the new optional `--quick-id`/`--slug`/`--directory` flags. Prose is the product here — an executing agent reads this line to decide whether the shortcut is safe for its case, and a shorter correction would either drop the flags (leaving the reader unable to act on the fix) or drop the limitation (recreating the false claim in gentler words).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3951): backfill changeset pr number

Refs #3951

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 23:10:49 -04:00

4316 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');
const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.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 stepLines = step.split(/\r?\n/);
const bashFence = scanFencedBlocks(stepLines).find(
(b) => b.closeLineIdx !== -1 && (b.infoString || '').trim() === 'bash',
);
assert.ok(bashFence, 'update_roadmap must contain a bash block');
const bash = stepLines.slice(bashFence.openLineIdx + 1, bashFence.closeLineIdx).join('\n');
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}`,
);
});
});