fix(#3572): keep phase remove's STATE.md write single-block and drop the removed heading (#3594)

* 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:
Tom Boucher
2026-08-17 12:58:25 -04:00
committed by GitHub
parent 325fc25c01
commit 0c00ef4a6d
3 changed files with 234 additions and 11 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3594
---
**`phase remove` no longer corrupts STATE.md after removing an inserted (decimal) phase** — the removed-phase write prepended a second, partially-wrong frontmatter block (and left the phase's ROADMAP heading behind, so total_phases kept counting it); removal now updates STATE.md in place as a single block, drops the heading, and clamps phase counts at zero. (#3572)

View File

@@ -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;

View File

@@ -11618,3 +11618,172 @@ still to be determined by the roadmap.
);
});
});
// ─── #3572: phase remove must not prepend a second frontmatter block ──────────
describe('bug #3572: phase remove must not corrupt STATE.md into two frontmatter blocks', () => {
const ISSUE_STATE = [
'---',
'gsd_state_version: 1.0',
'milestone: v1.0',
'milestone_name: First',
'current_phase: 2',
'current_phase_name: Feature',
'status: executing',
'stopped_at: Phase 1 complete',
'last_updated: "2026-08-16T10:00:00.000Z"',
'last_activity: 2026-08-16',
'last_activity_desc: "Phase 1 complete."',
'progress:',
' total_phases: 2',
' completed_phases: 1',
' total_plans: 2',
' completed_plans: 1',
' percent: 50',
'---',
'',
'# Project State',
'',
'Some prose here that must survive.',
'',
].join('\n');
const TWO_PHASE_ROADMAP = '# Roadmap\n\n## Milestone v1.0\n\n### Phase 1: Setup\n**Goal:** Bootstrap the project.\n\n### Phase 2: Feature\n**Goal:** Ship the feature.\n';
function setupProject(t, stateMd = ISSUE_STATE, eol = '\n') {
const tmpDir = createTempProject('gsd-3572-');
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), TWO_PHASE_ROADMAP);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
stateMd.split('\n').join(eol),
);
t.after(() => cleanup(tmpDir));
return tmpDir;
}
function fenceLineCount(content) {
return content.split(/\r?\n/).filter((l) => l.trim() === '---').length;
}
test('#3572: phase remove of an inserted decimal phase keeps STATE.md a single frontmatter block', (t) => {
const tmpDir = setupProject(t);
// The issue's exact sequence: insert creates the directory; remove then has a
// targetDir !== null, and the body lacks Total Phases/of-N — the trigger.
let r = runGsdTools('phase insert 1 "Inserted probe"', tmpDir);
assert.ok(r.success, `phase insert failed: ${r.error}`);
r = runGsdTools('phase remove 1.1', tmpDir);
assert.ok(r.success, `phase remove failed: ${r.error}`);
assert.strictEqual(JSON.parse(r.output).state_updated, true, 'the #2640 resync must still happen');
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(after.startsWith('---\n') || after.startsWith('---\r\n'), 'file must still OPEN with the frontmatter fence');
assert.strictEqual(fenceLineCount(after), 2, `exactly one frontmatter block (2 fence lines); got ${fenceLineCount(after)}:\n${after.slice(0, 400)}`);
assert.strictEqual((after.match(/gsd_state_version/g) || []).length, 1, 'exactly one gsd_state_version — no second derived block');
assert.ok(after.includes('Some prose here that must survive.'), 'body prose must survive verbatim');
assert.match(after, /^Total Phases:\s*\d+$/m, 'the inserted count field must live in the BODY (line-start), not before the first fence');
// Pinned value: the body field counts DIRECTORIES on disk (0 after removing
// the only directory); the frontmatter progress block derives from ROADMAP
// (2 below) — the two counters have different provenance by design (#2640/#2528).
assert.match(after, /^Total Phases:\s*0$/m, 'body field = remaining on-disk phase directories');
const fm = after.match(/total_phases:\s*(\d+)/);
assert.ok(fm, 'frontmatter progress.total_phases present');
assert.strictEqual(fm[1], '2', `total_phases must resync to the 2 remaining roadmap phases; got ${fm[1]}`);
});
test('#3572: integer-phase remove with directory also stays single-block (strengthens #2640)', (t) => {
const tmpDir = setupProject(t);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature'), { recursive: true });
const r = runGsdTools('phase remove 2', tmpDir);
assert.ok(r.success, `phase remove failed: ${r.error}`);
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.strictEqual(fenceLineCount(after), 2, `single frontmatter block; got ${fenceLineCount(after)}`);
assert.ok(after.startsWith('---'), 'opens with the fence');
assert.ok(after.includes('Some prose here that must survive.'), 'body prose preserved');
});
test('#3572: existing body Total Phases decremented in place, single block', (t) => {
const stateWithField = ISSUE_STATE.replace(
'Some prose here that must survive.',
'Total Phases: 2\n\nSome prose here that must survive.',
);
const tmpDir = setupProject(t, stateWithField);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature'), { recursive: true });
const r = runGsdTools('phase remove 2', tmpDir);
assert.ok(r.success, `phase remove failed: ${r.error}`);
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.strictEqual(fenceLineCount(after), 2, 'single frontmatter block');
const bodyCounts = after.match(/^Total Phases:\s*(\d+)$/gm) || [];
assert.strictEqual(bodyCounts.length, 1, `exactly one Total Phases field; got ${bodyCounts.length}`);
assert.match(bodyCounts[0], /^Total Phases:\s*1$/, `field decremented to 1; got ${bodyCounts[0]}`);
});
test('#3572: frontmatter-less STATE.md gets the field at content start', (t) => {
const tmpDir = setupProject(t, '# Bare state\n\nNo fences at all here.\n');
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-feature'), { recursive: true });
const r = runGsdTools('phase remove 2', tmpDir);
assert.ok(r.success, `phase remove failed: ${r.error}`);
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.match(after, /^Total Phases:\s*\d+$/m, 'field lands at content start when the whole file is body');
assert.ok(after.includes('No fences at all here.'), 'original body preserved');
});
test('#3572: CRLF STATE.md stays single-block with CRLF preserved', (t) => {
const tmpDir = setupProject(t, ISSUE_STATE, '\r\n');
let r = runGsdTools('phase insert 1 "Inserted probe"', tmpDir);
assert.ok(r.success, `phase insert failed: ${r.error}`);
r = runGsdTools('phase remove 1.1', tmpDir);
assert.ok(r.success, `phase remove failed: ${r.error}`);
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.strictEqual(fenceLineCount(after), 2, `single frontmatter block under CRLF; got ${fenceLineCount(after)}`);
assert.ok(after.includes('Some prose here that must survive.'), 'body prose preserved');
assert.match(after, /^Total Phases:\s*\d+\r?$/m, 'count field present in body');
});
test('#3572: ROADMAP-only phase removal leaves STATE.md untouched (issue control)', (t) => {
const tmpDir = setupProject(t);
const before = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const r = runGsdTools('phase remove 2', tmpDir); // phase 2 has NO directory
assert.ok(r.success, `phase remove failed: ${r.error}`);
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.strictEqual(after, before, 'issue control: removal without a directory must not touch STATE.md');
});
});
describe('bug #3572 controls and clamps', () => {
test('#3572 control: phase insert alone leaves STATE.md untouched (issue control #2)', (t) => {
const tmpDir = createTempProject('gsd-3572-ctl-');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: A\n**Goal:** x\n\n### Phase 2: B\n**Goal:** y\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\ngsd_state_version: 1.0\nprogress:\n total_phases: 2\n---\n\n# Project State\n\nBody.\n',
);
const before = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
const r = runGsdTools('phase insert 1 "Probe"', tmpDir);
assert.ok(r.success, `phase insert failed: ${r.error}`);
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.strictEqual(after, before, 'issue control: insert alone must not touch STATE.md');
t.after(() => cleanup(tmpDir));
});
test('#3572 clamp: a stale Total Phases: 0 never decrements to -1 on the next removal', (t) => {
const tmpDir = createTempProject('gsd-3572-clamp-');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: A\n**Goal:** x\n\n### Phase 2: B\n**Goal:** y\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\ngsd_state_version: 1.0\nprogress:\n total_phases: 2\n---\n\n# Project State\n\nTotal Phases: 0\n',
);
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true });
const r = runGsdTools('phase remove 2', tmpDir);
assert.ok(r.success, `phase remove failed: ${r.error}`);
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.doesNotMatch(after, /Total Phases:\s*-\d+/, 'count must never go negative');
assert.match(after, /^Total Phases:\s*0$/m, 'stale zero stays clamped at 0');
t.after(() => cleanup(tmpDir));
});
});