* 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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user