fix(#3577): recognize markdown-table phase rows across the roadmap enumeration family (#3599)

* test(#3577): pin table-declared phase resolution across all four surfaces

Failing-first regression for #3577: a GFM table phase listing (Phase header,
id in the first data cell) declared real phases that roadmap.analyze,
roadmap.get-phase, init.phase-op, and the milestone filter all reported as
absent (phase_count: 0 / found: false). Rows pin the lookup, the scope
probe, the analyzer, schema discrimination against the canonical
RoadmapProgress table, fenced-example exclusion, heading+table union
without double-count, decimal ids, and the 999 icebox exclusion.

* fix(#3577): recognize markdown-table phase rows across the enumeration family

A GFM table whose header leads with Phase and whose data rows carry the id
in the first cell is a phase listing — the #2199 bullet blind spot's table
sibling. collectTablePhaseRows (schema-discriminated against the canonical
RoadmapProgress table via matchTableSchema, fence-aware via stripFencedCode,
digit-bearing id shape, 999 icebox excluded) now feeds: the milestone
filter's sole owner scanMilestonePhaseIds, window classification
hasPhaseEntries, both roadmap lookup chains (getRoadmapPhaseInternal +
cmdRoadmapGetPhase, as last-resort tiers after heading and bullet), and
roadmap analyze's enumerator (with the same disk enrichment contract as
headings and a zero-pad-tolerant duplicate guard). init.phase-op resolves
through its existing getRoadmapPhaseInternal fallback.

* fix(#3577): GFM table termination + icebox word boundary in the table scan

Review findings: the row harvest broke only on blank lines, so prose after
a table (a bare date line) could be harvested as a phase id — rows now stop
at the first non-row line per GFM semantics; the 999 icebox exclusion gains
the heading scan's word boundary so 9991 is kept.

* fix(#3577): sanction collectTablePhaseRows in the enumeration drift scanner

The scan's local 999-only exclusion mirrors its parent owner
scanMilestonePhaseIds' deliberate NOT-isSentinelPhaseId choice (a leading 0
is a real decimal phase, #2554), so it cannot route through the sentinel
owner — function-scoped exemption with the documented reason, same entry
shape as the #3262 owner's.

* chore(#3577): add changeset fragment

* chore(#3577): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-17 16:50:24 -04:00
committed by GitHub
parent 5f64d999dc
commit dfc4c69e3d
5 changed files with 254 additions and 4 deletions

View File

@@ -42,7 +42,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 } from './markdown-table.cjs';
import { findTableWithColumns, matchTableSchema, 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');
@@ -424,7 +424,48 @@ function hasPhaseEntries(markdown: string): boolean {
// doc showing the convention) counts as a real phase entry. Strip fences
// through the canonical seam before testing, matching tokenizeHeadings'
// fence-awareness above.
return BULLET_PHASE_LINE_PATTERN.test(stripFencedCode(markdown).text);
if (BULLET_PHASE_LINE_PATTERN.test(stripFencedCode(markdown).text)) return true;
// #3577: a markdown-table phase listing also declares phases.
return collectTablePhaseRows(markdown).length > 0;
}
// ─── #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.
const PHASE_LISTING_HEADER_RE = /^\|?\s*phase(?:\s*(?:#|no\.?|number))?\s*\|/i;
const TABLE_PHASE_ID_RE = /^[A-Za-z]?\d[\w.-]*$/;
function collectTablePhaseRows(window: string): Array<{ id: string; name: string | null; row: string }> {
const unfenced = stripFencedCode(window).text;
const lines = unfenced.split(/\r?\n/);
const rows: Array<{ id: string; name: string | null; row: 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
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
// row. Review finding: breaking only on blank lines let subsequent prose
// (e.g. a bare `2026-01-01` date line) be harvested as a phase id.
if (!/^\s*\|/.test(lines[j])) break;
const cells = splitTableRow(lines[j]);
if (cells.length === 0 || cells.every((c) => c === '')) break; // defensive: blank row
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] });
}
}
}
return rows;
}
/**
@@ -465,6 +506,9 @@ function scanMilestonePhaseIds(window: string): Set<string> {
while ((bm = scanner.exec(unfenced)) !== null) {
if (!/^999\b/.test(bm[1])) ids.add(bm[1]);
}
// #3577: table-declared ids join the same membership set — the milestone
// filter must not collapse a table-house-style window to zero-count.
for (const tr of collectTablePhaseRows(window)) ids.add(tr.id);
return ids;
}
@@ -884,6 +928,25 @@ function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSour
};
}
// #3577: markdown-table row fallback. Mirrors the #2199 bullet fallback's
// tier — used only AFTER heading and bullet lookups fail on scoped + full
// content, so a heading with a Requirements/Goal section always wins. The row
// itself is the section (single line), the name comes from column 2.
function findRoadmapTablePhaseInContent(content: string, phaseNum: unknown): RoadmapPhaseResult | null {
const wanted = String(phaseNum).replace(/^0+(?=.)/, '');
for (const tr of collectTablePhaseRows(content)) {
if (tr.id.replace(/^0+(?=.)/, '') !== wanted) continue;
return {
found: true,
phase_number: String(phaseNum),
phase_name: tr.name ?? `Phase ${tr.id}`,
goal: null,
section: tr.row.trim(),
};
}
return null;
}
function findRoadmapBulletPhaseInContent(content: string, phaseNum: unknown, phaseSource?: string): RoadmapPhaseResult | null {
// #2199: bullet/checkbox entry fallback (`- [ ] **Phase N — name**`). Returns
// the single bullet line as the section (no multi-line body) — used only as a
@@ -941,6 +1004,12 @@ function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseRe
if (fullBullet) return fullBullet;
}
// #3577: last tier — a markdown-table row declaration.
const scopedTable = findRoadmapTablePhaseInContent(content, phaseNum);
if (scopedTable) return scopedTable;
const fullTable = findRoadmapTablePhaseInContent(fullContent, phaseNum);
if (fullTable) return fullTable;
return null;
} catch (err) {
// Absence already returned above via existsSync; anything caught here is a read fault
@@ -1591,5 +1660,6 @@ export = {
// CLI probe) and the free-text predicate the phase add/add-batch/insert
// guards and the edit-phase workflow's pre/post capture are built on.
scanMilestonePhaseIds,
collectTablePhaseRows,
findMilestoneScopeHeadingLines,
};

View File

@@ -26,7 +26,7 @@ const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserModule = require('./roadmap-parser.cjs');
const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, replaceInCurrentMilestone, listMilestoneHeadings, scanMilestonePhaseIds } = roadmapParserModule;
const { stripShippedMilestones, extractCurrentMilestone, extractCurrentMilestoneScoped, replaceInCurrentMilestone, listMilestoneHeadings, scanMilestonePhaseIds, collectTablePhaseRows } = roadmapParserModule;
import { tokenizeHeadings } from './markdown-sectionizer.cjs';
import { updateTableCell } from './markdown-table.cjs';
import { clampPercent } from './phase-lifecycle.cjs';
@@ -311,6 +311,22 @@ function cmdRoadmapGetPhase(cwd: string, phaseNum: string, raw: boolean): void {
if (!malformed) malformed = (milestoneResult?.error ? milestoneResult : (fullResult?.error ? fullResult : null));
}
// #3577: no heading or checklist entry matched — fall back to a
// markdown-table row declaration (the same last-resort tier
// getRoadmapPhaseInternal gained). Zero-pad-tolerant id compare (#3572
// lesson: the declared form may be padded).
const stripPad = (s: string) => s.replace(/^0+(?=.)/, '');
const tableHit = collectTablePhaseRows(milestoneContent).find((tr) => stripPad(tr.id) === stripPad(phaseNum))
?? collectTablePhaseRows(fullContent).find((tr) => stripPad(tr.id) === stripPad(phaseNum));
if (tableHit) {
output(
{ found: true, phase_number: phaseNum, phase_name: tableHit.name ?? `Phase ${tableHit.id}`, goal: null, section: tableHit.row.trim() },
raw,
tableHit.row.trim(),
);
return;
}
if (malformed) {
output(malformed, raw, '');
return;
@@ -459,6 +475,41 @@ function collectAnalyzePhases(content: string, phasesDir: string, phaseDirNames:
roadmap_complete: roadmapComplete,
});
}
// #3577: markdown-table row declarations join the enumeration — same
// enrichment contract as headings (disk counts when the directory exists),
// zero-pad-tolerant duplicate guard so an id declared in BOTH a heading and
// a table counts once.
const stripPadA = (s: string) => s.replace(/^0+(?=.)/, '');
const seen = new Set(phases.map((ph) => stripPadA(ph.number)));
for (const tr of collectTablePhaseRows(content)) {
if (seen.has(stripPadA(tr.id))) continue;
const dirMatchA = matchPhaseDirs(phaseDirNames, normalizePhaseName(tr.id)).matches[0];
let tPlanCount = 0;
let tSummaryCount = 0;
let tHasContext = false;
let tHasResearch = false;
if (dirMatchA) {
const counts = countPhasePlansAndSummaries(path.join(phasesDir, dirMatchA));
tPlanCount = counts.planCount;
tSummaryCount = counts.summaryCount;
tHasContext = fs.existsSync(path.join(phasesDir, dirMatchA, 'CONTEXT.md'));
tHasResearch = fs.existsSync(path.join(phasesDir, dirMatchA, 'RESEARCH.md'));
}
phases.push({
number: tr.id,
name: tr.name ?? `Phase ${tr.id}`,
goal: null,
mode: null,
depends_on: null,
plan_count: tPlanCount,
summary_count: tSummaryCount,
has_context: tHasContext,
has_research: tHasResearch,
disk_status: dirMatchA ? 'ok' : 'no_directory',
roadmap_complete: false,
});
}
return phases;
}