fix(#4480): require a name column in roadmap phase tables (#4511)

* test(#4480): reproduce unnamed roadmap table phases

* fix(#4480): require named roadmap phase tables

* docs(#4480): add changeset for #4511

* test(#4480): cover phase name columns generatively

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Michel Moreira
2026-09-10 19:32:55 -03:00
committed by GitHub
parent 5e2055ab90
commit 411199d08b
3 changed files with 85 additions and 15 deletions

View File

@@ -3580,7 +3580,7 @@ describe('#1881 unreadable ROADMAP vs absent ROADMAP', () => {
const TABLE_ROADMAP = [
'# Roadmap: Table Repro', '',
'## Milestone v2.0', '',
'| Phase | Focus | Requirements | Success criteria (preview) |',
'| Phase | Name | Requirements | Success criteria (preview) |',
'| --- | --- | --- | --- |',
'| 20 | Alpha focus | R1 | Works |',
'| 21 | Beta focus | R2 | Works too |',
@@ -3633,6 +3633,68 @@ describe('#1881 unreadable ROADMAP vs absent ROADMAP', () => {
a3.ok(out.phases.some((p) => /Alpha focus/.test(p.phase_name || p.name || '')), 'phase 20 named from column 2');
});
t3('#4480: table phase names are resolved by a recognized header', () => {
const nameRows = rp3.collectTablePhaseRows([
'| Phase | Status | Name |',
'| --- | --- | --- |',
'| 1 | done | First Thing |',
].join('\n'));
const phaseNameRows = rp3.collectTablePhaseRows([
'| Phase | Phase Name | Status |',
'| --- | --- | --- |',
'| 2 | Second Thing | pending |',
].join('\n'));
a3.deepStrictEqual(nameRows.map(({ id, name }) => ({ id, name })), [
{ id: '1', name: 'First Thing' },
]);
a3.deepStrictEqual(phaseNameRows.map(({ id, name }) => ({ id, name })), [
{ id: '2', name: 'Second Thing' },
]);
});
t3('#4480 property: the declared name column wins at every column position', () => {
const otherHeader = fc.constantFrom('Status', 'Goal', 'Plans', 'Owner');
fc.assert(fc.property(
fc.array(otherHeader, { maxLength: 4 }),
fc.array(otherHeader, { maxLength: 4 }),
fc.constantFrom('Name', 'Phase Name'),
fc.integer({ min: 1, max: 998 }),
fc.stringMatching(/^[A-Za-z0-9][A-Za-z0-9 ]{0,30}$/),
(before, after, nameHeader, phase, name) => {
const headers = ['Phase', ...before, nameHeader, ...after];
const values = [String(phase), ...before.map(() => 'x'), name, ...after.map(() => 'y')];
const delimiter = headers.map(() => '---');
const rows = rp3.collectTablePhaseRows([
`| ${headers.join(' | ')} |`,
`| ${delimiter.join(' | ')} |`,
`| ${values.join(' | ')} |`,
].join('\n'));
a3.deepStrictEqual(rows.map(({ id, name: parsedName }) => ({ id, name: parsedName })), [
{ id: String(phase), name: name.trim() },
]);
},
), { numRuns: 200 });
});
t3('#4480: a status table cannot hide missing phase details', () => {
writeRoadmap3(tmpDir, [
'# Roadmap', '',
'## Phases', '',
'- [x] **Phase 1: First Thing** — shipped',
'- [ ] **Phase 2: Second Thing** — not started', '',
'## Progress', '',
'| Phase | Status |',
'| --- | --- |',
'| 1 | done |',
'',
].join('\n'));
const r = rgt3(['roadmap', 'analyze', 'json'], tmpDir);
a3.ok(r.success, `analyze failed: ${r.error}`);
const out = JSON.parse(r.output);
a3.strictEqual(out.phase_count, 0, `status rows are not declarations; got ${r.output}`);
a3.deepStrictEqual(out.missing_phase_details, ['1', '2']);
});
t3('#3577: the canonical progress table is not a phase listing; fenced examples excluded', () => {
writeRoadmap3(tmpDir, [
'# Roadmap', '', '## v1.0', '',
@@ -3642,7 +3704,7 @@ describe('#1881 unreadable ROADMAP vs absent ROADMAP', () => {
'| 3 | 1/2 | In Progress | |',
'',
'```md',
'| Phase | Focus |',
'| Phase | Name |',
'| --- | --- |',
'| 77 | fenced example |',
'```',
@@ -3664,7 +3726,7 @@ describe('#1881 unreadable ROADMAP vs absent ROADMAP', () => {
writeRoadmap3(tmpDir, [
'# Roadmap', '', '## v1.0', '',
'### Phase 1: Heading Form', '**Goal:** g', '',
'| Phase | Focus |',
'| Phase | Name |',
'| --- | --- |',
'| 1 | heading dup guard |',
'| 2.5 | decimal row |',