From f80505aca31ad054961f1bbf03fec5d5eb58f77e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 9 Jul 2026 19:41:31 -0400 Subject: [PATCH] fix(#2090): restore src/state.cts to origin/next (unrelated contamination) --- src/state.cts | 40 ++++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/state.cts b/src/state.cts index 0e8199266..a4778f1c1 100644 --- a/src/state.cts +++ b/src/state.cts @@ -16,7 +16,7 @@ import configLoaderMod = require('./config-loader.cjs'); const { loadConfig } = configLoaderMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { escapeRegex, normalizePhaseName, extractPhaseToken } = phaseIdMod; +const { escapeRegex, normalizePhaseName, extractPhaseToken, parsePhaseFromProse } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); const { getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone } = roadmapParserMod; @@ -1116,22 +1116,13 @@ function matchSessionSection(body: string): RegExpMatchArray | null { } function parseProsePhaseField(value: string | null): { phase: string | null; name: string | null } { - if (!value) return { phase: null, name: null }; - const phaseMatch = value.match(/\b(\d+[A-Z]?(?:\.\d+)*)\b/i); - // #2124 review: length-bound the name quantifiers so a crafted long - // unterminated `(` / `—` run in an untrusted STATE.md field cannot drive - // O(n^2) backtracking (CPU DoS). (Phase 2 / #2125 supersedes this function - // by delegating to phase-id.cts:parsePhaseFromProse, which is bounded too.) - const parenName = value.match(/\(([^)]{1,200})\)/); - const dashName = value.match(/—\s*([^(\n]{1,200}?)(?:\s*\(|$)/); - const rawName = parenName?.[1] ?? dashName?.[1] ?? null; - const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim()) - ? rawName.trim() - : null; - return { - phase: phaseMatch ? phaseMatch[1] : null, - name, - }; + // #2121 Phase 2 (#2125): delegate to the canonical anchored parser so this + // module holds no independent prose phase-id regex. Drives #2111 — the + // anchored parser returns { phase: null } for a "Milestone vX.Y complete" + // body line (the old unanchored regex mined the minor-version digit, e.g. + // v0.5 -> "5"), so syncStateFrontmatter's #905 guard preserves the real + // current_phase instead of clobbering it. + return parsePhaseFromProse(value); } function parseProseLastActivityField(value: string | null): { date: string | null; description: string | null } { @@ -2722,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 { @@ -2751,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' },