fix(#2125): route complete-phase resolver through canonical parser (review)

Orthogonal review surfaced that resolvePhaseIdForCompletePhase (state.cts) and
cmdStateCompletePhase's idempotency check still used an unanchored
/(\d+[A-Z]?(?:\.\d+)*)/i — even more permissive than the parseProsePhaseField
regex this phase fixes. Reachable corruption: after `milestone complete v0.5`,
`state complete-phase` (no --phase) mined "0.5" from the body line
"Phase: Milestone v0.5 complete" and rewrote STATE.md as "Phase 0.5 complete".

Both sites now delegate to phase-id.cts:parsePhaseFromProse (the same anchored
parser), so a milestone-closure line yields no token and the existing
"unable to resolve" guard fires instead of corrupting. Canonical tokens
(3, 03, 3A, 3.3, "3 of 5", "1 — Setup") are preserved unchanged.

Regression (tests/state.test.cjs, complete-phase suite): `state complete-phase`
on a "Milestone v0.5 complete" STATE.md now rejects and does not mine "0.5".
Demonstrated fail-first.

Refs #2125, #2121

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-09 18:11:55 -04:00
parent e7ff7be1a4
commit bd5fcfceeb
2 changed files with 43 additions and 5 deletions

View File

@@ -2713,9 +2713,13 @@ function resolvePhaseIdForCompletePhase(content: string, overridePhase: string |
stateExtractField(content, 'Phase') ||
'';
// Accept canonical phase token only (e.g. 3, 03, 3A, 3.3, 10.2)
const phaseMatch = String(candidate).match(/(\d+[A-Z]?(?:\.\d+)*)/i);
return phaseMatch ? phaseMatch[1] : null;
// #2125: parse via the canonical anchored parser so a narrative `Phase:`
// body line (e.g. "Milestone v0.5 complete") does not mine a bogus token —
// the old unanchored regex yielded "0.5" and rewrote STATE.md as
// "Phase 0.5 complete". A canonical token at the start of the value
// (3, 03, 3A, 3.3, 10.2, "3 of 5", "1 — Setup") is preserved; a milestone
// closure line yields null, so the caller's "unable to resolve" guard fires.
return parsePhaseFromProse(candidate).phase;
}
function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string): void {
@@ -2742,8 +2746,9 @@ function cmdStateCompletePhase(cwd: string, raw: boolean, overridePhase?: string
// The handler is now a no-op in that case so re-invocation from downstream
// workflows cannot regress the project state.
const existingCurrentPhaseRaw = stateExtractField(content, 'Current Phase') || '';
const existingCurrentPhaseMatch = String(existingCurrentPhaseRaw).match(/(\d+[A-Z]?(?:\.\d+)*)/i);
const existingCurrentPhase = existingCurrentPhaseMatch ? existingCurrentPhaseMatch[1] : null;
// #2125: same canonical parser as resolvePhaseIdForCompletePhase so the two
// sites cannot diverge on the token they extract.
const existingCurrentPhase = parsePhaseFromProse(existingCurrentPhaseRaw).phase;
if (existingCurrentPhase && existingCurrentPhase !== resolvedPhase) {
output(
{ updated: [], phase: resolvedPhase, idempotent: true, note: 'phase already superseded; no-op' },