fix(#4247): refuse update-plan-progress on a roadmap with no writable phase entry (#4468)

* test(#4247): failing-first regressions for checklist-form update-plan-progress

* fix(#4247): refuse update-plan-progress when the roadmap has no writable phase entry

* fix(#4247): single local source for the phase-heading anchor grammar

* docs(#4247): note the missing_phase_details refusal in cli-tools reference

* docs(#4247): backfill pr number in changeset

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-07 02:20:47 -04:00
committed by GitHub
parent 03c770be47
commit c4b6dbd486
4 changed files with 448 additions and 7 deletions

View File

@@ -1032,6 +1032,23 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
// Wrap entire read-modify-write in lock to prevent concurrent corruption
let updated = false;
// #4247: the refusal flag. The write/report decision below must be keyed to
// "a writable roadmap representation of THIS phase was found", never to "any
// byte moved". On a checklist-form ROADMAP (`- [ ] **Phase N: …**`, the
// roadmapper's own summary-checklist form) every phase-targeted grammar
// below requires an ATX `#{2,4} Phase N` heading and therefore finds
// nothing; the Progress-table row is the only other writable target, and
// when its Phase cell does not match `phaseCellRe` (e.g. a word-prefixed
// `Phase 68` cell — deliberately unrecognized on the read side too,
// `deriveProgressFromRoadmap`'s `/^\d/` data-row filter) the command used
// to fall through to unrelated byte deltas (an UN-scoped plan-checkbox mark
// anywhere in the document) and report `updated: true` while the phase's
// own row stayed untouched — with the file-global write then letting the
// platform write seam's markdown normalization inject blank lines around
// other phases' bullets, splitting hand-wrapped sentences mid-entry. The
// refusal below declines with the analyzer's own `missing_phase_details`
// vocabulary and leaves ROADMAP.md byte-identical.
let missingPhaseDetails = false;
withPlanningLock(cwd, () => {
// #3957 (B9.4): captured BEFORE any transform runs, so the write/report
// decision below reflects whether the transforms actually changed
@@ -1041,6 +1058,35 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
const originalContent = fs.readFileSync(roadmapPath, 'utf-8');
let roadmapContent = originalContent;
const phasePattern = phaseMarkdownRegexSource(phaseNum);
// #4247: ONE local source for the ATX phase-heading anchor that every
// section-scoped writer below (`planCountPattern`,
// `insertRowsPatternA|B`) starts with — extracted so the target-detection
// gate below reads the SAME grammar the writers anchor on, and a future
// edit to one cannot drift from the other three copies.
const phaseHeadingAnchor = `#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`;
// #4247: target detection runs against the ORIGINAL content's active
// (post-</details>) region — the same milestone scoping every writer
// below applies — so the gate asks "does the file carry a writable phase
// representation" rather than "did some regex fire mid-transform".
const gateDetailsClose = originalContent.lastIndexOf('</details>');
const gateActiveRegion = gateDetailsClose === -1
? originalContent
: originalContent.slice(gateDetailsClose + '</details>'.length);
// Heading target: the exact grammar `planCountPattern` /
// `insertRowsPatternA|B` anchor on (an ATX phase heading for this phase).
const headingTargetFound = new RegExp(phaseHeadingAnchor, 'i').test(gateActiveRegion);
// Checklist target: when the phase is complete, its own checklist bullet
// (`- [ ] **Phase N: …**`) IS a writable phase row — the completion
// checkbox stamp below updates it. Same grammar as that writer, widened
// one notch to `[ x]` so an ALREADY-checked bullet still counts as a
// found target: an idempotent re-run then takes the honest
// "no changes were needed" decline instead of this refusal.
const checklistTargetFound = isComplete && new RegExp(
`-\\s*\\[[ x]\\]\\s*.*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`,
'i',
).test(gateActiveRegion);
// Table-row target: set by the row-scoped cell updates below.
let tableRowFound = false;
// Progress table row: update Plans Complete/Status/Completed columns BY
// COLUMN NAME (handles 4- or 5-column RoadmapProgress tables regardless of
@@ -1062,10 +1108,10 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
let text = scoped;
const plansResult = updateTableCell(text, rowMatch, 'Plans Complete', ` ${summaryCount}/${planCount} `);
if (plansResult.ok) text = plansResult.value;
if (plansResult.ok) { text = plansResult.value; tableRowFound = true; }
const statusResult = updateTableCell(text, rowMatch, 'Status', ` ${status.padEnd(11)}`);
if (statusResult.ok) text = statusResult.value;
if (statusResult.ok) { text = statusResult.value; tableRowFound = true; }
// Preserve only a valid ISO date (#1161: idempotent; self-heal garbage).
// Ragged-tolerant (#2245 Blocker 2): probe the CURRENT Completed cell via
@@ -1081,7 +1127,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
}
return ' ';
});
if (completedResult.ok) text = completedResult.value;
if (completedResult.ok) { text = completedResult.value; tableRowFound = true; }
return text;
});
@@ -1125,7 +1171,7 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
// continuation on the next line, since the pattern never spans past
// `\n` in the first place.
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans?)?([^\\r\\n]*)`,
`(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)(\\d+\\s*\\/\\s*\\d+\\s+plans(?:\\s+(?:complete|executed))?|\\d+\\s+plans?)?([^\\r\\n]*)`,
'i'
);
const planCountText = isComplete
@@ -1218,11 +1264,11 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
// Pattern A: anchor to bare `Plans:` header (preferred).
// Pattern B: fallback to bold summary when no bare header exists.
const insertRowsPatternA = new RegExp(
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`,
`(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:^|\\n)(?:Plans:)[^\\n]*)`,
'i'
);
const insertRowsPatternB = new RegExp(
`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`,
`(${phaseHeadingAnchor}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`,
'i'
);
@@ -1268,10 +1314,24 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
// `cmdRoadmapAnnotateDependencies`'s existing `nextContent !== content`
// gate. Previously this wrote and reported `updated: true`
// unconditionally, even on an idempotent re-run that changed nothing.
if (roadmapContent !== originalContent) {
//
// #4247: ...but ONLY when a writable representation of THIS phase was
// found. Without the target gate, a byte delta from an unrelated
// transform (the un-scoped plan-checkbox mark) satisfied the #3957 gate
// and produced a success-shaped `updated: true` while the phase's own
// row stayed untouched — and the file-global write let the platform
// write seam's markdown normalization reflow unrelated entries. When no
// target exists the command refuses: no write at all, so ROADMAP.md is
// left byte-identical, and the caller gets a typed
// `missing_phase_details` decline instead of a false green.
const phaseRepresentationFound = tableRowFound || headingTargetFound || checklistTargetFound;
if (phaseRepresentationFound && roadmapContent !== originalContent) {
platformWriteSync(roadmapPath, roadmapContent);
updated = true;
}
if (!phaseRepresentationFound) {
missingPhaseDetails = true;
}
});
const computed = {
@@ -1284,6 +1344,18 @@ function cmdRoadmapUpdatePlanProgress(cwd: string, phaseNum: string | null | und
};
if (updated) {
output({ updated: true, ...computed }, raw, `${summaryCount}/${planCount} ${status}`);
} else if (missingPhaseDetails) {
// #4247: honest refusal — the reason names the real condition (the
// analyzer's `missing_phase_details` vocabulary), never "already
// reflects", which was false: the ROADMAP was never able to record this
// phase's progress in the first place.
declineNoOp(
raw,
'updated',
'missing_phase_details',
`roadmap update-plan-progress skipped — ROADMAP.md has no writable entry for phase ${formatDiagnosticToken(String(phaseNum))} (no matching Progress-table row, no phase detail section, and no checklist entry this command can update). ROADMAP.md was left unchanged.`,
computed,
);
} else {
declineNoOp(
raw,