* test(#3572): pin single-frontmatter contract for phase remove STATE.md writes Failing-first regression for #3572: when the removed phase has a directory and the body lacks Total Phases/of-N, cmdPhaseRemove's no-op-guard bypass prepended the count field to the WHOLE file — before the opening fence — corrupting STATE.md into two frontmatter blocks. Rows also strengthen the #2640 coverage (whose first-match assertions pass even on a corrupted file) and pin the issue's ROADMAP-only control. * fix(#3572): keep phase remove's STATE.md write single-block and drop the removed heading from ROADMAP Two defects in the decimal-phase removal path: (1) the #2640 no-op-guard bypass prepended 'Total Phases: N' to the WHOLE file content, landing it before the opening fence and corrupting STATE.md into two frontmatter blocks; the field now inserts at the top of the BODY, after the closing fence (EOL-aware, frontmatter-less files unchanged in behavior). (2) updateRoadmapAfterPhaseRemoval matched the raw query token ('1.1') against the normalized zero-padded heading ('Phase 01.1:'), so the removed phase stayed in ROADMAP and the resync counted it; the heading, checklist, and progress-row matchers are now zero-pad tolerant, which also covers unpadded integer headings. * fix(#3572): clamp phase-count decrements at zero; harden EOL detection; pin controls Review findings: a stale 'Total Phases: 0' could decrement to -1 on the next removal (both the field and the 'of N' phrase now clamp at 0); insertStateBodyFieldAtTop detects EOL from the first line ending so an LF-dominant file with a stray CRLF cannot fall through to the raw prepend; the issue's insert-alone control is pinned; row 1 pins the body-field value (dir-count provenance) alongside the roadmap-derived frontmatter count. * fix(#3572): keep CRLF endings intact in the body-field insertion Green-run failure root cause: splitting on '\n' but re-joining on a detected '\r\n' doubled every carriage return in CRLF files. Split and join uniformly on '\n' so each '\r' stays attached to the line it terminated. * chore(#3572): add changeset fragment * chore(#3572): backfill changeset pr number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -1724,6 +1724,14 @@ function updateRoadmapAfterPhaseRemoval(
|
||||
withPlanningLock(cwd, () => {
|
||||
let content = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const escaped = escapeRegex(targetPhase);
|
||||
// #3572: ROADMAP headings and rows carry the normalized (zero-padded) form
|
||||
// of a decimal id — `phase insert 1` writes `### Phase 01.1:` while the
|
||||
// user's remove query is usually unpadded (`1.1`) — and integer headings
|
||||
// legitimately appear both padded (`02`) and unpadded (`2`). A `0*` prefix
|
||||
// makes the token padding-insensitive in both directions without widening
|
||||
// to other ids: the token stays anchored between `Phase\s+`/line-start and
|
||||
// `:`/whitespace/end, so `0*2` still never matches `Phase 12:`.
|
||||
const padTolerant = `0*${escaped}`;
|
||||
|
||||
// SECTION-DELETION (not a section-body edit) — removes the phase's ENTIRE
|
||||
// detail section INCLUDING its own heading line. Migrated onto deleteSection
|
||||
@@ -1737,7 +1745,7 @@ function updateRoadmapAfterPhaseRemoval(
|
||||
// away everything after it — including a trailing `## Progress` heading and
|
||||
// its tracking table.
|
||||
const phaseHeadingRe = new RegExp(
|
||||
`^Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`,
|
||||
`^Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}\\s*:`,
|
||||
'i',
|
||||
);
|
||||
content = deleteSection(
|
||||
@@ -1745,7 +1753,7 @@ function updateRoadmapAfterPhaseRemoval(
|
||||
(h) => h.level >= 2 && h.level <= 4 && phaseHeadingRe.test(h.text),
|
||||
);
|
||||
content = content.replace(
|
||||
new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'),
|
||||
new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${padTolerant}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*`, 'gi'),
|
||||
'',
|
||||
);
|
||||
// ROW-DELETION (not a cell update) — removes the WHOLE Progress-table row
|
||||
@@ -1776,7 +1784,7 @@ function updateRoadmapAfterPhaseRemoval(
|
||||
const matchRemovedProgressRow = (row: Record<string, string>): boolean => {
|
||||
const firstCellRaw = (Object.values(row)[0] ?? '').trim();
|
||||
if (isDecimal) {
|
||||
return new RegExp(`^${escaped}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
|
||||
return new RegExp(`^${padTolerant}\\.?(?:\\s|$)`, 'i').test(firstCellRaw);
|
||||
}
|
||||
const leadingMatch = firstCellRaw.match(/^0*(\d+)(\.\d+)?/);
|
||||
if (!leadingMatch || leadingMatch[2]) return false;
|
||||
@@ -1919,6 +1927,34 @@ interface PhaseRemoveOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* #3572: insert `fieldLine` at the start of STATE.md's BODY — immediately after
|
||||
* the leading frontmatter block's closing `---` fence — so a body field never
|
||||
* lands before the opening fence. The former whole-content prepend
|
||||
* (`field + content`) put the line ABOVE the opening `---`, and
|
||||
* syncStateFrontmatter then treated the scrambled fence structure as TWO
|
||||
* frontmatter blocks, rebuilding a derived one on top of the original
|
||||
* (milestone_name from a ROADMAP heading, total_phases counting the removed
|
||||
* phase, a stray 'Total Phases: 0' between fences). A file with no leading
|
||||
* frontmatter is all body: the field goes to content start, preserving the
|
||||
* former behavior for that shape.
|
||||
*/
|
||||
function insertStateBodyFieldAtTop(content: string, fieldLine: string): string {
|
||||
// Split AND join on bare '\n' so CRLF line endings stay attached to their
|
||||
// own lines — each '\r' remains the tail of the line it terminated, where
|
||||
// the trimmed fence compare still matches it. (#3572 review: splitting on
|
||||
// '\n' but re-joining on a detected '\r\n' doubled every carriage return.)
|
||||
const lines = content.split('\n');
|
||||
if ((lines[0] ?? '').trim() === '---') {
|
||||
const closeIdx = lines.findIndex((l: string, i: number) => i > 0 && l.trim() === '---');
|
||||
if (closeIdx !== -1) {
|
||||
lines.splice(closeIdx + 1, 0, '', fieldLine);
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
return fieldLine + '\n' + content;
|
||||
}
|
||||
|
||||
function cmdPhaseRemove(
|
||||
cwd: string,
|
||||
targetPhase: string,
|
||||
@@ -2034,15 +2070,21 @@ function cmdPhaseRemove(
|
||||
let modified = stateContent;
|
||||
const totalRaw = stateExtractField(modified, 'Total Phases');
|
||||
if (totalRaw) {
|
||||
// #3572 review: clamp at 0 — a stale 'Total Phases: 0' (e.g. written by
|
||||
// an earlier remove whose dir-count was 0) must not decrement to -1 on
|
||||
// the next removal.
|
||||
modified =
|
||||
stateReplaceField(modified, 'Total Phases', String(parseInt(totalRaw, 10) - 1)) ||
|
||||
modified;
|
||||
stateReplaceField(
|
||||
modified,
|
||||
'Total Phases',
|
||||
String(Math.max(0, parseInt(totalRaw, 10) - 1)),
|
||||
) || modified;
|
||||
}
|
||||
const ofMatch = modified.match(/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i);
|
||||
if (ofMatch) {
|
||||
modified = modified.replace(
|
||||
/(\bof\s+)(\d+)(\s*(?:\(|phases?))/i,
|
||||
`$1${parseInt(ofMatch[2], 10) - 1}$3`,
|
||||
`$1${Math.max(0, parseInt(ofMatch[2], 10) - 1)}$3`,
|
||||
);
|
||||
}
|
||||
// #2640: if neither body field was found, the transform is a no-op.
|
||||
@@ -2066,15 +2108,22 @@ function cmdPhaseRemove(
|
||||
// just-deleted directory as still present and write a `Total Phases`
|
||||
// one too high. Identity is also what the comment above already
|
||||
// claims this filter does, and the block is gated on targetDir.
|
||||
const remainingPhases = subdirs.filter((d) => d !== targetDir).length;
|
||||
// (#3572 note: this body field counts DIRECTORIES on disk; the
|
||||
// frontmatter progress.* block is rebuilt by syncStateFrontmatter
|
||||
// from the post-removal ROADMAP — the two counts legitimately differ
|
||||
// when phases exist in ROADMAP without directories.)
|
||||
const remainingPhases = Math.max(0, subdirs.filter((d) => d !== targetDir).length);
|
||||
if (totalRaw) {
|
||||
modified =
|
||||
stateReplaceField(modified, 'Total Phases', String(remainingPhases)) || modified;
|
||||
} else {
|
||||
// No 'Total Phases:' field in the body — append one so the no-op
|
||||
// guard sees a diff. syncStateFrontmatter will then rebuild the
|
||||
// frontmatter progress.* block from the real disk/ROADMAP count.
|
||||
modified = `Total Phases: ${remainingPhases}\n` + modified;
|
||||
// No 'Total Phases:' field in the body — insert one at the start of
|
||||
// the BODY so the no-op guard sees a diff. #3572: the former
|
||||
// whole-content prepend landed the line BEFORE the opening '---'
|
||||
// fence and corrupted STATE.md into two frontmatter blocks.
|
||||
// syncStateFrontmatter will still rebuild the frontmatter
|
||||
// progress.* block from the real disk/ROADMAP count.
|
||||
modified = insertStateBodyFieldAtTop(modified, `Total Phases: ${remainingPhases}`);
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
|
||||
Reference in New Issue
Block a user