fix(#2137): parse the ROADMAP Progress table by header, not fixed column count

deriveProgressFromRoadmap read the `## Progress` table with two
4-column-only regexes, so every project past its v1.0 milestone — which
uses the 5-column milestone-grouped shape the same template ships
(| Phase | Milestone | Plans Complete | Status | Completed |) — parsed to
{completedPhases: null, totalPhases: null, totalPlans: null}. Because the
body is wrapped in a bare try/catch the failure was silent, and
phase.complete flipped the ROADMAP row to Complete but never advanced
STATE's progress percent / completed-phase count.

Parse the table by header instead: locate the `## Progress` section
(line-anchored h2, mirroring the writer's #2012 scoping so an earlier
Phase/Status/Completed-shaped table can't hijack it), read the header row
to find the Status, Completed, and plans columns by name, then index each
data row positionally. One code path now handles both the 4-column
greenfield and 5-column milestone-grouped shapes — the same understanding
the writer (cmdPhaseComplete, with its `cells.length === 5` branch)
already had. Preserves the 999.x backlog exclusion, M/N plan summing, and
the prior contract that a count of 0 is reported as null.

Regression tests fold into tests/state.test.cjs: the template's own
5-column Progress block parses non-null, both shapes derive identically,
999.x stays excluded in the 5-column shape, and decoy tables (an earlier
## Retrospective table and an ### Progress h3 subheading) do not hijack
the scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Behruz Nassre Esfahani
2026-07-10 23:57:15 -07:00
parent e3a8c063b8
commit 3f6be47db9
2 changed files with 208 additions and 32 deletions

View File

@@ -9447,6 +9447,132 @@ describe('bug #1445 — deriveProgressFromRoadmap excludes 999.x rows', () => {
});
});
// ─── #2137: header-driven parse handles the milestone-grouped (5-col) table ──
//
// Regression for #2137: deriveProgressFromRoadmap read the `## Progress` table
// with two 4-column-only regexes. Every project past its v1.0 milestone uses the
// 5-column milestone-grouped shape the same template ships, so the reader (which
// only understood 4 columns) returned { null, null, null } while the writer
// (cmdPhaseComplete, with its explicit `cells.length === 5` branch) happily wrote
// it — and phase.complete then silently skipped the STATE progress update. The
// fix reads columns by NAME, so both shapes parse identically. These tests would
// fail against the pre-fix 4-column regexes (which returned all-null for 5-col).
describe('#2137 regression: deriveProgressFromRoadmap parses the milestone-grouped 5-column table', () => {
test("the template's own 5-column milestone-grouped Progress block parses non-null", () => {
// Byte-identical to gsd-core/templates/roadmap.md's "Milestone-Grouped
// Roadmap" Progress block — the exact shape that silently returned all-null.
const roadmap = [
'## Progress',
'',
'| Phase | Milestone | Plans Complete | Status | Completed |',
'|-------|-----------|----------------|--------|-----------|',
'| 1. Foundation | v1.0 | 3/3 | Complete | YYYY-MM-DD |',
'| 2. Features | v1.0 | 2/2 | Complete | YYYY-MM-DD |',
'| 5. Security | v1.1 | 0/2 | Not started | - |',
].join('\n');
const result = deriveProgressFromRoadmap(roadmap);
assert.equal(result.totalPhases, 3, `totalPhases must be 3 (5-col table must parse). Got ${result.totalPhases}`);
assert.equal(result.completedPhases, 2, `completedPhases must be 2 (Status is column 4 in the 5-col shape). Got ${result.completedPhases}`);
assert.equal(result.totalPlans, 7, `totalPlans must be 3+2+2=7 (Plans is column 3 in the 5-col shape). Got ${result.totalPlans}`);
});
test('the 4-column greenfield and 5-column milestone-grouped shapes derive the same progress', () => {
// The reader must agree with the writer on both shapes the template ships.
const fiveCol = [
'## Progress',
'| Phase | Milestone | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- | --- |',
'| 1. Foundation | v1.0 | 3/3 | Complete | 2026-01-01 |',
'| 2. Features | v1.0 | 2/2 | Complete | 2026-01-02 |',
].join('\n');
const fourCol = [
'## Progress',
'| Phase | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1. Foundation | 3/3 | Complete | 2026-01-01 |',
'| 2. Features | 2/2 | Complete | 2026-01-02 |',
].join('\n');
assert.deepEqual(
deriveProgressFromRoadmap(fiveCol),
deriveProgressFromRoadmap(fourCol),
'the milestone-grouped and greenfield shapes must derive identical progress',
);
assert.deepEqual(deriveProgressFromRoadmap(fiveCol), {
completedPhases: 2,
totalPhases: 2,
totalPlans: 5,
});
});
test('999.x backlog rows stay excluded in the 5-column shape', () => {
const roadmap = [
'## Progress',
'| Phase | Milestone | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- | --- |',
'| 1. Alpha | v1.0 | 2/2 | Complete | 2026-01-01 |',
'| 2. Beta | v1.0 | 1/1 | Complete | 2026-01-02 |',
'| 999.1 Future | v2.0 | 0/0 | Backlog | - |',
].join('\n');
const result = deriveProgressFromRoadmap(roadmap);
assert.equal(result.totalPhases, 2, `999.1 backlog row must be excluded in the 5-col shape too. Got ${result.totalPhases}`);
assert.equal(result.completedPhases, 2, `completedPhases must be 2. Got ${result.completedPhases}`);
});
test('binds to the ## Progress table, not an earlier Phase/Status/Completed-shaped table', () => {
// A decoy table under a different heading shares the Phase/Status/Completed
// header shape. The reader must scope to ## Progress (mirroring the writer's
// #2012 scoping) rather than binding to the first matching table it sees.
const roadmap = [
'## Retrospective',
'',
'| Phase | Owner | Status | Completed |',
'| --- | --- | --- | --- |',
'| 1. Old | jo | Complete | 2025-01-01 |',
'',
'## Progress',
'',
'| Phase | Milestone | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- | --- |',
'| 1. Foundation | v1.0 | 3/3 | Complete | 2026-01-01 |',
'| 2. Features | v1.0 | 2/2 | Complete | 2026-01-02 |',
'| 3. Security | v1.1 | 0/2 | Not started | - |',
'',
'## Next',
].join('\n');
const result = deriveProgressFromRoadmap(roadmap);
assert.equal(result.totalPhases, 3, `must count the 3 rows of the ## Progress table, not the 1-row decoy. Got ${result.totalPhases}`);
assert.equal(result.completedPhases, 2, `must count Complete rows in ## Progress (2), not the decoy's 1. Got ${result.completedPhases}`);
assert.equal(result.totalPlans, 7, `must sum the ## Progress plans (3+2+2=7). Got ${result.totalPlans}`);
});
test('an h3 ### Progress decoy does not hijack the h2 ## Progress scope', () => {
// Heading detection must be line-anchored to h2: "### Progress".indexOf("## Progress")
// is 1, so a substring scan would start the slice inside the h3 subheading and
// miss the real table below.
const roadmap = [
'### Progress notes',
'',
'Some prose about progress, no table here.',
'',
'## Progress',
'',
'| Phase | Milestone | Plans Complete | Status | Completed |',
'| --- | --- | --- | --- | --- |',
'| 1. Foundation | v1.0 | 3/3 | Complete | 2026-01-01 |',
'| 2. Features | v1.0 | 2/2 | Complete | 2026-01-02 |',
].join('\n');
const result = deriveProgressFromRoadmap(roadmap);
assert.equal(result.totalPhases, 2, `h2 ## Progress table must be found past the h3 decoy. Got ${result.totalPhases}`);
assert.equal(result.completedPhases, 2, `completedPhases must be 2. Got ${result.completedPhases}`);
});
});
// ─── Scenario B: state json total_phases via roadmapPhaseCount ───────────────
describe('bug #1445 — state json excludes 999.x phase headings from total_phases', () => {