fix(#3807): advance-plan refuses an ambiguous multi-Phase Current Position (#4028)

* test(#3807): advance-plan must refuse an ambiguous multi-entry Current Position

* fix(#3807): refuse an ambiguous multi-Phase Current Position before advancing

* chore(#3807): changeset fragment (pr number backfilled after PR creation)

* chore(#3807): backfill changeset PR number (4028)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-29 03:50:03 -04:00
committed by GitHub
parent 3a4c3cb83e
commit b811ea16fc
5 changed files with 146 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 4028
---
**`state advance-plan` refuses an ambiguous Current Position instead of silently advancing the first entry** — when the section carries more than one `Phase:` line (the wave-log style), the command now returns a typed `ambiguous_position_phase` error naming every candidate and leaves STATE.md byte-identical, instead of silently advancing the first entry's plan counter (in the reporting incident, a hard-gated final plan 7→8 of 8) with `advanced: true` and no ambiguity signal. (#3807)

View File

@@ -16,7 +16,7 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs'); import frontmatter = require('./frontmatter.cjs');
import { stateReplaceField, stateExtractField, stateReplaceFieldIfTemplate, stateReplaceFieldWithFallback, stateReplaceFieldInSession } from './state-document.cjs'; import { stateReplaceField, stateExtractField, stateReplaceFieldIfTemplate, stateReplaceFieldWithFallback, stateReplaceFieldInSession, stateCurrentPositionSlice } from './state-document.cjs';
import { KNOWN_TEMPLATE_DEFAULTS, toFiniteNumber } from './state-document.cjs'; import { KNOWN_TEMPLATE_DEFAULTS, toFiniteNumber } from './state-document.cjs';
import { tokenizeHeadings } from './markdown-sectionizer.cjs'; import { tokenizeHeadings } from './markdown-sectionizer.cjs';
import type { HeadingToken } from './markdown-sectionizer.cjs'; import type { HeadingToken } from './markdown-sectionizer.cjs';
@@ -1382,6 +1382,34 @@ function advancePlanCore(content: string, deps: StateTransitionDeps): StateTrans
const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath); const { body: initialBody, reassemble } = beginFrontmatterReassembly(content, deps.sourcePath);
let body = initialBody; let body = initialBody;
// #3807: refuse a Current Position section carrying more than one `Phase:`
// entry BEFORE mutating. The plan fields below come from document-wide
// first-match extraction, so in a wave-log style section (one entry per
// completed wave) the FIRST entry's plan counter silently advanced — in the
// reporting incident, a hard-gated final plan 7→8 of 8 — while the entry
// the caller meant sat untouched below it, with advanced:true and no
// ambiguity signal. advance-plan now refuses before acting. Scoped via the
// #2956 canonical locator (stateCurrentPositionSlice — H2 or H3 heading,
// the same one cmdStateAdvancePlan's own milestone read uses); NO whole-body
// fallback — a legacy-format document with unrelated `Phase:` history lines
// elsewhere has no section to disambiguate and must keep its current
// behavior rather than be falsely refused.
const positionScope = stateCurrentPositionSlice(body);
if (positionScope !== null) {
const phaseCandidates = (positionScope.match(/^Phase:.*$/gm) || []);
if (phaseCandidates.length > 1) {
return {
content,
updated: [],
data: {
error: true,
reason: 'ambiguous_position_phase',
phase_candidates: phaseCandidates.map((l) => l.trim()),
},
};
}
}
// Parse plan number — legacy first, then compound. // Parse plan number — legacy first, then compound.
const legacyPlan = stateExtractField(content, 'Current Plan'); const legacyPlan = stateExtractField(content, 'Current Plan');
const legacyTotal = stateExtractField(content, 'Total Plans in Phase'); const legacyTotal = stateExtractField(content, 'Total Plans in Phase');

View File

@@ -894,6 +894,16 @@ function cmdStateAdvancePlan(cwd: string, raw: boolean): void {
}, cwd, { divergedFields, preWriteState }); }, cwd, { divergedFields, preWriteState });
if (!resultData || resultData['error']) { if (!resultData || resultData['error']) {
// #3807: a multi-`Phase:` Current Position section carries its own cause
// and its own remedy (name the candidates; the caller resolves them).
if (resultData && resultData['reason'] === 'ambiguous_position_phase') {
output({
error: 'Current Position section contains more than one Phase: entry — refusing to silently advance the first. Resolve the section to a single current entry and re-run.',
reason: resultData['reason'],
phase_candidates: resultData['phase_candidates'],
}, raw, undefined);
return;
}
output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined); output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw, undefined);
return; return;
} }

View File

@@ -0,0 +1,100 @@
'use strict';
// ─────────────────────────────────────────────────────────────────────────────
// #3807 — advance-plan must refuse a Current Position section carrying
// more than one `Phase:` entry instead of silently advancing the first.
//
// The #2956 fix scoped the milestone-conflict Phase read to the Current
// Position section, but advancePlanCore's plan fields still came from
// document-wide first-match stateExtractField — so a wave-log style section
// (one Phase: entry per completed wave, all under Current Position) had its
// FIRST entry's plan counter silently advanced — in the reporter's incident,
// a hard-gated final plan 7→8 of 8 — with advanced:true and
// milestone_conflict:null, no error, no ambiguity signal.
// ─────────────────────────────────────────────────────────────────────────────
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
const TWO_ENTRY_BODY = [
'## Current Position',
'',
'Phase: 03.1 of 8 (some-phase)',
'Plan: 7 of 8 in current phase',
'Status: In progress',
'Last activity: 2026-08-24 — working',
'',
'Phase: 04 of 15 (other-phase)',
'Plan: 7 of 15 in current phase',
'Status: Phase complete',
'Last activity: 2026-08-24 — wave 4 done',
'',
].join('\n');
function writeState(tmpDir, positionBody) {
const content = [
'---',
'gsd_state_version: 1.0',
'current_phase: 03',
'status: executing',
'progress:',
' total_phases: 2',
'---',
'',
positionBody,
].join('\n');
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
}
function runAdvance(cwd) {
return runGsdTools(['state', 'advance-plan'], cwd);
}
describe('#3807: advance-plan refuses an ambiguous multi-entry Current Position', () => {
test('#3807: two Phase: entries under Current Position → ambiguous error, no mutation', (t) => {
const tmpDir = createTempProject('gsd-3807-amb-');
t.after(() => cleanup(tmpDir));
writeState(tmpDir, TWO_ENTRY_BODY);
const before = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
const r = runAdvance(tmpDir);
// The CLI reports an error payload (exit success shape is the command's
// own convention for parse errors — assert on the payload, not the code).
const out = JSON.parse(r.output);
assert.ok(
out.error && out.reason === 'ambiguous_position_phase' && /more than one Phase/i.test(String(out.error)),
`#3807: the error must name the multi-Phase condition with the typed reason; got ${r.output}`,
);
assert.ok(
Array.isArray(out.phase_candidates) && out.phase_candidates.length === 2,
`#3807: both Phase: candidates must be named; got ${JSON.stringify(out.phase_candidates)}`,
);
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
assert.equal(after, before, '#3807: refusing must leave STATE.md byte-identical');
assert.ok(!/Plan: 8 of 8/.test(after), 'the first entry\'s plan counter must NOT advance');
});
test('#3807 control: a single-entry section advances exactly as before', (t) => {
const tmpDir = createTempProject('gsd-3807-ctl-');
t.after(() => cleanup(tmpDir));
writeState(tmpDir, [
'## Current Position',
'',
'Phase: 03 of 8 (some-phase)',
'Plan: 3 of 8 in current phase',
'Status: In progress',
'Last activity: 2026-08-24 — working',
'',
].join('\n'));
const r = runAdvance(tmpDir);
const out = JSON.parse(r.output);
assert.equal(out.advanced, true, `single-entry advance still works; got ${r.output}`);
const after = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf8');
assert.match(after, /Plan: 4 of 8/, 'the plan counter advanced');
});
});

View File

@@ -841,11 +841,11 @@ describe('#3912 A3-A5: output({error}) records DEGRADED — shape-exhaustive plu
perFile, perFile,
{ {
'commands.cts': 5, 'frontmatter.cts': 7, 'gsd2-import.cts': 2, 'phase.cts': 4, 'commands.cts': 5, 'frontmatter.cts': 7, 'gsd2-import.cts': 2, 'phase.cts': 4,
'roadmap.cts': 3, 'state.cts': 25, 'template.cts': 3, 'verify.cts': 8, 'workstream.cts': 7, 'roadmap.cts': 3, 'state.cts': 26, 'template.cts': 3, 'verify.cts': 8, 'workstream.cts': 7, // +1 #3807: advance-plan's ambiguous-position error
}, },
`per-file output({error}) census drifted: ${JSON.stringify(perFile)}`, `per-file output({error}) census drifted: ${JSON.stringify(perFile)}`,
); );
assert.strictEqual(total, 64, `enumerated output({error}) population drifted from the measured 64: got ${total}`); assert.strictEqual(total, 65, `enumerated output({error}) population drifted from the measured 65 (64 + #3807's ambiguous-position error): got ${total}`);
}); });
}); });