* 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:
@@ -16,7 +16,7 @@
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
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 { tokenizeHeadings } 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);
|
||||
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.
|
||||
const legacyPlan = stateExtractField(content, 'Current Plan');
|
||||
const legacyTotal = stateExtractField(content, 'Total Plans in Phase');
|
||||
|
||||
@@ -894,6 +894,16 @@ function cmdStateAdvancePlan(cwd: string, raw: boolean): void {
|
||||
}, cwd, { divergedFields, preWriteState });
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user