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

@@ -63,7 +63,7 @@ import unusableInputMod = require('./unusable-input.cjs');
const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
import { tokenizeHeadings, stripTaggedBlocks, withSection, stripFencedCode, collectSection } from './markdown-sectionizer.cjs';
import type { HeadingToken } from './markdown-sectionizer.cjs';
import { findTableWithColumns, matchTableSchema, isDelimiterRow, splitTableRow } from './markdown-table.cjs';
import { findTableWithColumns, isDelimiterRow, splitTableRow } from './markdown-table.cjs';
import type { MarkdownTable } from './markdown-table.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('./planning-scope.cjs');
@@ -645,16 +645,17 @@ function hasPhaseEntries(markdown: string, phaseIdConvention?: string | null): b
}
// ─── #3577: markdown-table phase listings ─────────────────────────────────────
// #3577: a GFM table declares phases when its header's FIRST cell is the literal
// `Phase` (optionally `Phase #` / `Phase No.` / `Phase number`) and the header does
// NOT match a known non-listing schema — the canonical RoadmapProgress table
// (`| Phase | Plans Complete | Status | Completed |`) leads with `Phase` too, and
// its rows are progress markers, not declarations. Data rows carry the phase id in
// their first cell (digit-bearing canonical shape — `Phase`-word header cells and
// `---` delimiter rows are digit-free and excluded by construction). Fence-aware
// via stripFencedCode, matching the #3184 lesson: a fenced EXAMPLE of the table
// form is not a declared phase.
// #3577/#4480: a GFM table declares phases only when its header's FIRST cell is
// the literal `Phase` (optionally `Phase #` / `Phase No.` / `Phase number`) AND
// it positively identifies a `Name` or `Phase Name` column. This fails closed:
// ordinary progress/summary tables such as `| Phase | Status |` cannot mint a
// phase whose name is whichever value happens to occupy column two. Data rows
// carry the phase id in their first cell (digit-bearing canonical shape —
// `Phase`-word header cells and `---` delimiter rows are digit-free and excluded
// by construction). Fence-aware via stripFencedCode, matching the #3184 lesson:
// a fenced EXAMPLE of the table form is not a declared phase.
const PHASE_LISTING_HEADER_RE = /^\|?\s*phase(?:\s*(?:#|no\.?|number))?\s*\|/i;
const PHASE_NAME_HEADER_RE = /^(?:phase\s+)?name$/i;
const TABLE_PHASE_ID_RE = /^[A-Za-z]?\d[\w.-]*$/;
function collectTablePhaseRows(window: string): Array<{ id: string; name: string | null; row: string }> {
@@ -664,7 +665,8 @@ function collectTablePhaseRows(window: string): Array<{ id: string; name: string
for (let i = 0; i + 1 < lines.length; i++) {
if (!PHASE_LISTING_HEADER_RE.test(lines[i])) continue;
const headerCells = splitTableRow(lines[i]);
if (matchTableSchema(headerCells) !== null) continue; // canonical non-listing schema
const nameColumn = headerCells.findIndex((cell) => PHASE_NAME_HEADER_RE.test(cell));
if (nameColumn === -1) continue;
if (!isDelimiterRow(splitTableRow(lines[i + 1]))) continue;
for (let j = i + 2; j < lines.length; j++) {
// GFM semantics: the table ENDS at the first line that is not a table
@@ -676,7 +678,8 @@ function collectTablePhaseRows(window: string): Array<{ id: string; name: string
const first = cells[0] ?? '';
if (!TABLE_PHASE_ID_RE.test(first)) continue;
if (!/^999\b/.test(first)) {
rows.push({ id: first, name: cells[1] && cells[1] !== '' ? cells[1] : null, row: lines[j] });
const name = cells[nameColumn];
rows.push({ id: first, name: name && name !== '' ? name : null, row: lines[j] });
}
}
}