Files
msd-core/tests/issue-3204-state-writer-phase-count.test.cjs
Tom Boucher 2a73f53cb3 fix(#3204): milestone sectioning is vocabulary, not heading position (#3230)
* test(#3204): failing-first suite for the clobbered phase count

A project declaring six phases with four phase directories on disk had
state.record-session write progress.total_phases: 4 — #2828 regressing at
1.9.1, reported in #3204 with a deterministic reproduction.

Before the fix in the following commit, these rows FAILED (wrote 4, expected
6): a flat roadmap carrying `## Progress`; one carrying `## Overview` and
`## Phase Details`; the CRLF variant of the first. Two more, found by
adversarial review and added after the first fix attempt, failed against that
attempt: structural headings interleaved among flat phase headings, and this
repo's own bundled-template shape (a `## Phases` wrapper around a single
nested milestone).

The #1761 control — sibling milestone sections must keep falling back to the
disk count — passes both before and after, so the fix has something it must
not break.

Assertions read progress.total_phases through the product's own frontmatter
parser via `state json --raw`, never a regex over STATE.md. Rows 12 and 13
are hostile: a phase heading carrying a version token, and a version heading
inside a fenced code block; neither may count as milestone sectioning.

Refs #3185, #3204

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

* fix(#3204): milestone sectioning is vocabulary, not heading position

buildStateFrontmatter chooses total_phases between the ROADMAP's declared
phase count and the on-disk directory count, and refuses the roadmap count
when hasMilestoneSectioning says the document is milestone-sectioned — because
a whole-document count would then conflate sibling milestones (#1761).

That predicate returned true for ANY non-Phase level-2/3 heading, so a flat
roadmap carrying an ordinary `## Progress` was called sectioned and the disk
count clobbered the declared one: six declared phases, four directories,
total_phases written as 4, converging on the truth only once the last
directory happened to exist. That is #2828 regressing at 1.9.1, and it came
from this epic — #3184 replaced state.cts's hand-rolled #2828 guard with this
predicate, and the replacement is strictly more permissive than the guard it
retired.

Three position-based models were tried and all failed, because position does
not carry milestone-ness:

  - any non-Phase heading (shipped) — over-detects, giving #3204;
  - strict nesting/ownership — misses same-level siblings, regressing #1761,
    and false-positives on the bundled template, where `## Phases` wraps a
    single `### v1.1`;
  - adjacency — reproduced live: `## Overview` and `## Notes` interleaved
    among six phase headings are two owning candidates, so a 6-phase roadmap
    with 2 directories wrote 2.

A heading is now a milestone heading iff it is a non-Phase heading carrying a
milestone signal: a version token, a status marker, or the word Milestone.
Sectioning means two or more, since one cannot conflate siblings.

Known limit, recorded in the doc comment rather than hidden: two milestone
sections carrying none of those three signals are not detected.

Also drops buildStateFrontmatter's local dedup-key regex, flagged in-source as
diverging from the canonical token rule, for phaseKeyFromDir — the remainder
of #3185, since #3222 had already routed the enumeration itself through
listMilestonePhaseDirs.

#1514, #2445 and #3017 are preserved untouched.

Closes #3185
Fixes #3204

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

* docs(#3185): changeset, glossary entry and ADR status for the phase-count fix

CONTEXT.md's Roadmap Parser Module entry never named hasMilestoneSectioning,
so the predicate whose semantics this change reverses had no glossary presence
at all — a PR gate for a module/seam change. Added, covering the vocabulary
model, the three position-based models that failed, and the residual limit.

ADR-3180 recorded the fifth enumeration copy as unowned in four places. It is
owned now. Amendment 4's scope table row 1 also carried an error worth keeping
visible rather than rewriting: it claimed Phase 3 merged without routing the
state writers, when #3222 had in fact routed the enumeration — the audit read
Amendment 3's silence about the symbol names as absence of the work. The real
gap was the trust discriminator one layer above, which is what #3204 was.

Changeset is Fixed and leads with the symptom a user sees — a phase count that
shrinks to match how many phase directories happen to exist yet — and carries
the known limit forward rather than leaving it in a source comment.

Refs #3185, #3204

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

* fix(#3185): stop quoting the retired phase-token regex in a comment

The remote runner failed tests/phase-id-drift-guard.test.cjs: the comment
explaining that the local dedup regex had been replaced by phaseKeyFromDir
quoted that regex verbatim, and scripts/lint-phase-id-drift.cjs scans for the
literal token without caring whether it sits in code or in prose.

That is the guard being right, not over-eager — a quoted pattern is one paste
away from being live again, which is exactly how the copy it replaced spread.
Described in prose instead.

Worth recording: this guard is check:phase-id-drift, which lint:ci does not
run — it is enforced by tests/phase-id-drift-guard.test.cjs. A green lint:ci
is therefore not evidence the drift guards pass.

Refs #3185

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

* chore(#3185): backfill changeset PR number (#3230)

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-08 22:01:20 -04:00

785 lines
32 KiB
JavaScript

// allow-test-rule: source-text-is-the-product, see #3204
// Reads STATE.md/ROADMAP.md fixture files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* #3204 / #3185 — failing-first regression suite for `buildStateFrontmatter`'s
* `total_phases` selection (`src/state.cts:1620`, guard at `:1795-1805`).
*
* `hasMilestoneSectioning` (`src/roadmap-parser.cts:195`) is
* /^#{2,3}\s+(?!Phase\s+\S)/mi
* — true for ANY non-Phase level-2/3 heading, so a FLAT roadmap carrying an
* ordinary structural heading (`## Progress`, `## Overview`, ...) is
* misclassified as milestone-sectioned. `safeToUseRoadmapCount` then goes
* false and the on-disk phase-directory count silently clobbers the
* ROADMAP-declared count — a regression of #2828, reported in #3204 as
* "roadmap declares 6 phases, 4 directories exist, state.record-session
* writes total_phases: 4".
*
* DO NOT fix src/state.cts or src/roadmap-parser.cts from this file. Rows 2,
* 3, and 14 below assert the CORRECT (post-fix) value and currently FAIL —
* that is the point of a failing-first suite. Every other row asserts
* behavior verified to already hold today (see phase-log for the manual CLI
* probes that established each expected value before this file was written).
*
* Rows and naming follow `.gsd/phase/fix-3185-state-writer-phase-count/50-test-matrix.md`
* verbatim (row numbers refer to that matrix, not the 8-row table in
* `40-design.md`).
*
* Driven via `state record-session` (the shape #3204's own report used),
* then read back with `state json --raw` — the product's own frontmatter
* parser — so `progress.total_phases` is asserted as a NUMBER, never a
* regex over rendered STATE.md text. `tests/helpers.cjs`'s `parseFrontmatter`
* only reads flat top-level keys (it does not descend into the nested
* `progress:` block), so `state json --raw` is the correct structured seam
* for a nested field — it is what `tests/state.test.cjs`'s own '#1761
* read-path' and 'milestone-scoped phase counting' suites already use for
* this exact assertion shape.
*/
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');
// ─────────────────────────────────────────────────────────────────────────────
// Fixture builders
// ─────────────────────────────────────────────────────────────────────────────
/**
* Seed `.planning/phases/<padded>-phase-<n>` for each phase number in `nums`,
* each with a single PLAN.md so the directory is a recognizable phase dir.
*/
function seedPhaseDirs(tmpDir, nums) {
for (const n of nums) {
const padded = String(n).padStart(2, '0');
const dir = path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${n}`);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, `${padded}-01-PLAN.md`), '# Plan\n');
}
}
/** Seed one arbitrarily-named phase directory (sentinel / dup / pre-milestone cases). */
function seedNamedPhaseDir(tmpDir, dirName, planBase) {
const dir = path.join(tmpDir, '.planning', 'phases', dirName);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, `${planBase}-01-PLAN.md`), '# Plan\n');
}
/**
* Build STATE.md frontmatter + minimal body. `milestone` is always set (the
* #3204 fixture needs it truthy — `getMilestoneInfo` defaults an absent
* `milestone:` field to 'v1.0' anyway, so this pins the same value
* explicitly for every row rather than relying on that fallback).
* Lines are joined with the caller-supplied `eol` (default '\n') — row 14
* reuses this to build the CRLF variant without a second copy.
*/
function buildStateMd({ milestone = 'v1.0', milestoneName = 'Test', totalPhases, currentPhase = '01', eol = '\n' }) {
const lines = [
'---',
'gsd_state_version: 1.0',
`milestone: ${milestone}`,
`milestone_name: ${milestoneName}`,
`current_phase: "${currentPhase}"`,
'status: executing',
'progress:',
` total_phases: ${totalPhases}`,
' completed_phases: 0',
' total_plans: 0',
' completed_plans: 0',
' percent: 0',
'---',
'',
'# GSD State',
'',
'## Current Position',
'',
`**Current Phase:** ${currentPhase}`,
'**Status:** Executing',
'',
];
return lines.join(eol);
}
/** Invoke `state record-session` (the #3204 entry point) then read back `state json --raw`. */
function recordSessionAndReadTotalPhases(tmpDir) {
const recordResult = runGsdTools(
['state', 'record-session', '--stopped-at', 'Phase 1, Plan 1', '--resume-file', 'none'],
tmpDir,
);
assert.ok(recordResult.success, `state record-session failed: ${recordResult.error}`);
const jsonResult = runGsdTools(['state', 'json', '--raw'], tmpDir);
assert.ok(jsonResult.success, `state json --raw failed: ${jsonResult.error}`);
return JSON.parse(jsonResult.output);
}
// ─────────────────────────────────────────────────────────────────────────────
// Rows 1-3, 14 — #3204 regression: flat roadmap + a structural heading
// ─────────────────────────────────────────────────────────────────────────────
describe('#3204 buildStateFrontmatter total_phases — flat roadmap misclassified as milestone-sectioned', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('flat roadmap with no structural headings keeps the roadmap count', () => {
// Row 1 (happy path / control) — no non-Phase heading anywhere, so
// hasMilestoneSectioning is false today and this already passes. Guards
// against a fix that overcorrects and breaks the trivial flat case.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(Number(out.progress.total_phases), 6, `expected roadmap count 6, got ${out.progress && out.progress.total_phases}`);
});
test('#3204 flat roadmap with a Progress heading is not treated as milestone-sectioned', () => {
// Row 2 — the crux repro, transcribed from #3204's own report: 6
// declared phases, 4 directories, a flat '## Progress' heading. FAILS
// TODAY: hasMilestoneSectioning misclassifies '## Progress' as
// sectioning, safeToUseRoadmapCount goes false, and the write clobbers
// total_phases down to the disk count (4) instead of 6.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
'## Progress',
'',
'Some progress notes.',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
6,
`#3204: total_phases must stay 6 (roadmap-declared), not clobber to the disk count of 4. Got ${out.progress && out.progress.total_phases}`,
);
});
test('#3204 multiple structural headings still count as flat', () => {
// Row 3 — same shape as row 2 with TWO structural headings ('## Overview',
// '## Phase Details'); '## Phase Details' is correctly excluded by the
// heading's own '(?!Phase\s+\S)' lookahead, but '## Overview' still trips
// the misclassification. FAILS TODAY for the same reason as row 2.
const roadmap = [
'# Roadmap',
'',
'## Overview',
'',
'Some overview text.',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
'## Phase Details',
'',
'More detail prose.',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
6,
`#3204: multiple structural headings must still count as flat (6), got ${out.progress && out.progress.total_phases}`,
);
});
test('#3204 repro under CRLF', () => {
// Row 14 — row 2's exact repro, all fixture content authored with CRLF
// line endings, proving the bug (and required fix) is not an artifact of
// LF-only fixtures. FAILS TODAY for the same reason as row 2.
const roadmapLines = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
'## Progress',
'',
'Some progress notes.',
'',
];
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapLines.join('\r\n'));
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ totalPhases: 6, eol: '\r\n' }),
);
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
6,
`#3204 under CRLF: total_phases must stay 6, got ${out.progress && out.progress.total_phases}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Rows 4-11 — negative space and boundaries (must hold both before and after the fix)
// ─────────────────────────────────────────────────────────────────────────────
describe('#3204 buildStateFrontmatter total_phases — negative space / boundaries (must not regress)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('bounded milestone uses its own section count', () => {
// Row 4 — versioned roadmap with two sibling milestone sections ('## v1.0'
// owning phases 1-2, '## v2.0' owning phases 3-5). The asserted milestone
// ('v2.0') IS bound to its own heading, so `sliceMilestoneWindow`/
// `extractCurrentMilestoneScoped` narrow to that section and
// roadmapPhaseCount is the SECTION's count (3), not the whole-document
// count (5) and not the disk count (2 dirs seeded).
const roadmap = [
'# Roadmap',
'',
'## v1.0',
'## Phase 1: One',
'## Phase 2: Two',
'',
'## v2.0',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v2.0', milestoneName: 'Second', totalPhases: 3 }),
);
seedPhaseDirs(tmpDir, [3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
3,
`a bounded milestone must use its own section's phase count (3), not the whole document (5) or the disk count (2). Got ${out.progress && out.progress.total_phases}`,
);
});
test('a single milestone section cannot conflate siblings', () => {
// Row 6 — exactly ONE '## v2.0' section owning phases, with the asserted
// milestone ('v9.9') absent from the roadmap entirely. One milestone
// heading can never satisfy hasMilestoneSectioning's >=2 threshold, so
// this is NOT sectioned and the roadmap-declared count is still used.
const roadmap = [
'# Roadmap',
'',
'## v2.0',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v9.9', milestoneName: 'Absent', totalPhases: 4 }),
);
seedPhaseDirs(tmpDir, [1, 2]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
4,
`a single milestone section cannot conflate siblings; expected the roadmap count (4), got ${out.progress && out.progress.total_phases}`,
);
});
test('#1761 sibling milestone sections still fall back to the disk count', () => {
// Row 5 — TWO sibling (unversioned) milestone sections, asserted
// milestone ('v3.0') absent from either. This is genuinely
// milestone-sectioned (2 phase-bearing sections would conflate if
// whole-doc counted), so total_phases must stay the disk count. Passes
// today; a fix that touches hasMilestoneSectioning must not break it.
const roadmap = [
'# Roadmap',
'',
'## Milestone 1: First Milestone',
'### Phase 1: a',
'### Phase 2: b',
'### Phase 3: c',
'### Phase 4: d',
'',
'## Milestone 2: Second Milestone',
'### Phase 5: e',
'### Phase 6: f',
'### Phase 7: g',
'### Phase 8: h',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v3.0', milestoneName: 'Third', totalPhases: 8 }),
);
seedPhaseDirs(tmpDir, [1, 2, 3]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
3,
`#1761: unbounded sibling milestones must fall back to the disk count (3), got ${out.progress && out.progress.total_phases}`,
);
});
test('zero phase directories keeps the declared count', () => {
// Row 7 — boundary limit-1: 0 dirs vs 6 declared.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
// No phase dirs seeded.
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(Number(out.progress.total_phases), 6, `expected 6, got ${out.progress && out.progress.total_phases}`);
});
test('equal counts agree', () => {
// Row 8 — boundary limit: 6 dirs vs 6 declared.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4, 5, 6]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(Number(out.progress.total_phases), 6, `expected 6, got ${out.progress && out.progress.total_phases}`);
});
test('extra directories win via max()', () => {
// Row 9 — boundary limit+1. 6 heading-declared phases + a 7th phase
// declared only via the bullet-entry syntax ('- [ ] **Phase 7 — Extra**',
// #2199 bullet house style), which the directory-membership filter
// counts but the heading-only roadmapPhaseCount scan does not — so disk
// (7, all pass the membership filter) legitimately exceeds the
// heading-only roadmap count (6), and max() must pick 7.
//
// NOTE: a naive "N heading-declared phases + N+1 plain directories" does
// NOT exercise this path — the directory-membership filter
// (getMilestonePhaseFilter, roadmap-parser.cts) excludes any directory
// whose phase number has no matching roadmap entry at all, so an
// out-of-roadmap directory number is silently dropped from the disk
// count rather than inflating it. Verified against the running CLI
// before authoring this fixture.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
'- [ ] **Phase 7 — Extra**',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 6 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4, 5, 6, 7]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
7,
`expected max(7 dirs, 6 heading-declared) = 7, got ${out.progress && out.progress.total_phases}`,
);
});
test('absent roadmap falls back to disk', () => {
// Row 10 — no ROADMAP.md at all.
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 4 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(Number(out.progress.total_phases), 4, `expected disk count 4, got ${out.progress && out.progress.total_phases}`);
});
test('roadmap with no phase headings falls back to disk', () => {
// Row 11 — ROADMAP.md present but zero Phase headings anywhere.
const roadmap = ['# Roadmap', '', '## Notes', '', 'No phases declared yet.', ''].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 4 }));
seedPhaseDirs(tmpDir, [1, 2, 3, 4]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(Number(out.progress.total_phases), 4, `expected disk count 4, got ${out.progress && out.progress.total_phases}`);
});
test('a phase heading carrying a version token is not a milestone heading', () => {
// Row 12 (hostile, negative space) — '### Phase 3: Ship v2.0 gaps' carries
// a version token in its own text, but hasMilestoneSectioning's
// isPhaseHeading check excludes any heading matching '^Phase\s+\S' before
// the vocabulary signal is ever tested, so this must NOT count as a
// milestone heading. Otherwise-flat roadmap, so the roadmap-declared
// count must be used, not the disk count.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'### Phase 3: Ship v2.0 gaps',
'## Phase 4: Four',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 4 }));
seedPhaseDirs(tmpDir, [1, 2]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
4,
`a version token borne by a Phase heading must not trigger milestone sectioning; expected roadmap count 4, got ${out.progress && out.progress.total_phases}`,
);
});
test('a version heading inside a fence is not sectioning', () => {
// Row 13 (hostile, negative space) — '## v2.0' appears only inside a
// fenced code block (a documentation example of the heading syntax) on an
// otherwise flat roadmap. hasMilestoneSectioning is routed through
// tokenizeHeadings (fence-aware), so a fenced heading is never tokenised
// and must NOT count as sectioning. The roadmap-declared count must be
// used, not the disk count.
const roadmap = [
'# Roadmap',
'',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Phase 4: Four',
'',
'Example heading syntax:',
'',
'```',
'## v2.0',
'```',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 4 }));
seedPhaseDirs(tmpDir, [1, 2]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
4,
`a version heading inside a fence must not trigger milestone sectioning; expected roadmap count 4, got ${out.progress && out.progress.total_phases}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #3185 adversarial review — hasMilestoneSectioning ownership-model shapes
// missed by the original suite (BLOCKER + MAJOR findings against the #3184
// "strictly-deeper nesting" rewrite).
// ─────────────────────────────────────────────────────────────────────────────
describe('#3185 review — hasMilestoneSectioning shapes the original suite missed', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('BLOCKER: same-level sibling milestones fall back to the disk count', () => {
// Adversarial review BLOCKER (#1761 regression): the #3184 rewrite
// required a candidate milestone heading's owned Phase heading to be
// STRICTLY DEEPER (next.level > candidate.level). Real sibling
// milestones are frequently at the SAME level as their own Phase
// headings ('## v1.0' / '## Phase 1:' / '## v2.0' / '## Phase 3:'), so
// that predicate answered false and the whole-document count conflated
// both milestones. The asserted milestone ('v3.0') is unbound (matches
// neither v1.0 nor v2.0), so this is genuinely sectioned and must fall
// back to the disk count.
const roadmap = [
'# Roadmap',
'',
'## v1.0',
'## Phase 1: One',
'## Phase 2: Two',
'',
'## v2.0',
'## Phase 3: Three',
'## Phase 4: Four',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v3.0', milestoneName: 'Third', totalPhases: 4 }),
);
seedPhaseDirs(tmpDir, [1, 2]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
2,
`same-level sibling milestones must fall back to the disk count (2), got ${out.progress && out.progress.total_phases}`,
);
});
test('#3185 repro: structural headings interleaved among flat phases keep the roadmap count', () => {
// #3185's own reproduction of the adjacency model this suite's
// predecessor shipped: '## Overview' sits immediately before
// '## Phase 1:' and '## Notes' sits immediately before '## Phase 4:',
// giving an adjacency-based predicate 2 "owning" candidates even though
// neither heading carries any milestone vocabulary (no version token, no
// status marker, no "Milestone" word) and the roadmap is genuinely flat.
const roadmap = [
'# Roadmap',
'',
'## Overview',
'## Phase 1: One',
'## Phase 2: Two',
'## Phase 3: Three',
'## Notes',
'## Phase 4: Four',
'## Phase 5: Five',
'## Phase 6: Six',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v9.9', milestoneName: 'Test', totalPhases: 6 }),
);
seedPhaseDirs(tmpDir, [1, 2]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
6,
`#3185: structural headings adjacent to phase headings must not be treated as milestone sectioning; expected roadmap count 6, got ${out.progress && out.progress.total_phases}`,
);
});
test('MAJOR: wrapper + single nested milestone keeps the roadmap count', () => {
// Adversarial review MAJOR (#3204 reintroduction): every ancestor in a
// nesting chain was counted as its own candidate section under the
// #3184 rewrite, so a generic wrapper heading with only ONE real
// milestone nested under it was misclassified as sectioned. Mirrors
// this repo's own bundled template shape (gsd-core/templates/roadmap.md:
// '## Phases' -> '### 🚧 v1.1 [Name] (In Progress)' -> '#### Phase N:').
// The asserted milestone ('v9.9') is deliberately unbound so the
// assertion exercises hasMilestoneSectioning itself, not
// isMilestoneBoundedInRoadmap.
const roadmap = [
'# Roadmap',
'',
'## Phases',
'',
'### 🚧 v1.1 [Name] (In Progress)',
'',
'#### Phase 1: One',
'#### Phase 2: Two',
'#### Phase 3: Three',
'#### Phase 4: Four',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
buildStateMd({ milestone: 'v9.9', milestoneName: 'Unbound', totalPhases: 2 }),
);
seedPhaseDirs(tmpDir, [1, 2]);
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
4,
`wrapper + single nested milestone must keep the roadmap-declared count (4), not clobber to the disk count of 2. Got ${out.progress && out.progress.total_phases}`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Rows 15-18 — #3185 consolidation independence checks
//
// These exercise the directory-enumeration owner (listMilestonePhaseDirs /
// getMilestonePhaseFilter), not hasMilestoneSectioning. Verified PASSING
// against the current build (manual CLI probe) before being added here —
// included per the dispatch brief's "include only if they pass today"
// condition. If a future change to the #3204 fix regresses one of these,
// that is a SEPARATE finding from the #3204 repro above, not folded into it.
// ─────────────────────────────────────────────────────────────────────────────
describe('#3185 buildStateFrontmatter total_phases — directory-enumeration independence (currently passing)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('sentinel directories are excluded by the canonical enumeration', () => {
// Row 15 — a 999.x backlog directory alongside 3 real phase directories
// must not inflate total_phases.
const roadmap = ['# Roadmap', '', '## Phase 1: One', '## Phase 2: Two', '## Phase 3: Three', ''].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 3 }));
seedPhaseDirs(tmpDir, [1, 2, 3]);
seedNamedPhaseDir(tmpDir, '999.1-backlog-idea', '999.1');
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
3,
`sentinel 999.x directory must be excluded, expected 3, got ${out.progress && out.progress.total_phases}`,
);
});
test('pre-milestone directories are excluded', () => {
// Row 16 — a '0-*' pre-milestone directory must not be counted.
const roadmap = ['# Roadmap', '', '## Phase 1: One', '## Phase 2: Two', '## Phase 3: Three', ''].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 3 }));
seedPhaseDirs(tmpDir, [1, 2, 3]);
seedNamedPhaseDir(tmpDir, '0-premilestone', '0');
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
3,
`pre-milestone '0-*' directory must be excluded, expected 3, got ${out.progress && out.progress.total_phases}`,
);
});
test('duplicate phase-number directories count once', () => {
// Row 17 — two directories both keyed to phase number 2 must dedup to a
// single count.
const roadmap = ['# Roadmap', '', '## Phase 1: One', '## Phase 2: Two', '## Phase 3: Three', ''].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), buildStateMd({ totalPhases: 3 }));
seedPhaseDirs(tmpDir, [1, 2, 3]);
seedNamedPhaseDir(tmpDir, '02-phase-2-dup', '02');
const out = recordSessionAndReadTotalPhases(tmpDir);
assert.strictEqual(
Number(out.progress.total_phases),
3,
`duplicate phase-2 directories must dedup to a single count (3), got ${out.progress && out.progress.total_phases}`,
);
});
test('re-running record-session does not move total_phases', () => {
// Row 18 — idempotence: a second record-session call over an unchanged
// tree, with the clock pinned so 'Last session' does not itself vary,
// must produce a byte-identical STATE.md.
const roadmap = ['# Roadmap', '', '## Phase 1: One', '## Phase 2: Two', '## Phase 3: Three', ''].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const sessionState = [
'# GSD State',
'',
'## Session',
'',
'**Last session:** 2024-01-01T00:00:00.000Z',
'**Stopped at:** None',
'**Resume file:** None',
'',
'## Current Position',
'',
'**Current Phase:** 01',
'**Status:** Executing',
'',
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), sessionState);
seedPhaseDirs(tmpDir, [1, 2, 3]);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const pinnedEnv = { GSD_TEST_MODE: '1', GSD_NOW_MS: '1600000000000' };
const args = ['state', 'record-session', '--stopped-at', 'Phase 1, Plan 1', '--resume-file', 'none'];
const first = runGsdTools(args, tmpDir, pinnedEnv);
assert.ok(first.success, `first record-session failed: ${first.error}`);
const afterFirst = fs.readFileSync(statePath, 'utf8');
const second = runGsdTools(args, tmpDir, pinnedEnv);
assert.ok(second.success, `second record-session failed: ${second.error}`);
const afterSecond = fs.readFileSync(statePath, 'utf8');
assert.strictEqual(
afterSecond,
afterFirst,
're-running record-session on an unchanged tree with a pinned clock must produce a byte-identical STATE.md',
);
});
});