From e0e75315305d67f4918f4844ac695fa7204f9ba5 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 9 Sep 2026 14:02:59 -0400 Subject: [PATCH] fix(#4469): anchor beginPhaseCore's focusPattern regex to line start (#4577) * fix(#4469): anchor beginPhaseCore's focusPattern regex to line start focusPattern (src/state-transition.cts, beginPhaseCore's first-time- execution branch) rewrites the **Current focus:** body line via a regex with no line anchor and \s* (crosses newlines) instead of a same-line whitespace class -- the same defect class already fixed for a different function, stateReplaceField, in #4243/PR #4453. A bold **Current focus:** quoted mid-sentence anywhere in the body (e.g. an Accumulated Context bullet documenting the field) matched first and had the rest of its line silently overwritten with the new focus label -- the #4010 data-loss class, applied to a different field. Anchored using the exact same idiom PR #4453 established: ^([ \t]*\*\*Current focus:\*\*[ \t]*)(.*)$ with the /m flag. [ \t]* (not \s*) avoids consuming the newlines before the label into the match; $ documents the match ends at end-of-line. Added a regression test to tests/state-transition.test.cjs: a body with NO real **Current focus:** field yet present, but a mid-sentence prose mention of the same bold text in an Accumulated Context bullet -- proving the unanchored pattern would corrupt the prose (empirically verified via direct node -e execution before wiring the test) while the anchored pattern's .test() correctly returns false and leaves it untouched. stateReplaceProgressPercent's bold branch (~line 51-71) is explicitly NOT touched -- the issue itself flags it as entangled with the recorded #2177 form-priority decision, needing a maintainer call. Co-Authored-By: Claude Sonnet 5 * docs(#4469): backfill changeset PR number Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: sim Co-authored-by: Claude Sonnet 5 --- .changeset/mellow-foxes-tumble.md | 5 ++++ src/state-transition.cts | 12 ++++++++- tests/state-transition.test.cjs | 45 +++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 .changeset/mellow-foxes-tumble.md diff --git a/.changeset/mellow-foxes-tumble.md b/.changeset/mellow-foxes-tumble.md new file mode 100644 index 000000000..bc60ef351 --- /dev/null +++ b/.changeset/mellow-foxes-tumble.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4577 +--- +**`state begin-phase` no longer rewrites prose that merely quotes the Current-focus field label** — the bold-form rewrite was unanchored, so a bold label quoted mid-sentence elsewhere in the body (e.g. a historical note documenting the format) captured the update and silently destroyed the rest of its line while the real field went unset. Same fix shape as the #4243 fix to the shared field-replacement helper: anchored to line start, same-line whitespace only. diff --git a/src/state-transition.cts b/src/state-transition.cts index 699190d61..9a1875c25 100644 --- a/src/state-transition.cts +++ b/src/state-transition.cts @@ -1252,7 +1252,17 @@ function beginPhaseCore( const focusLabel = intent.phaseName ? `Phase ${intent.phaseNumber} — ${intent.phaseName}` : `Phase ${intent.phaseNumber}`; - const focusPattern = /(\*\*Current focus:\*\*\s*).*/i; + // #4469: anchored to line start with same-line whitespace only, mirroring + // #4243/PR #4453's fix to stateReplaceField's bold branch. The pre-fix + // pattern carried no `^`/`m` and used `\s*` (crosses newlines), so a bold + // `**Current focus:**` quoted mid-sentence elsewhere in the body (e.g. an + // Accumulated Context bullet) matched first and had the rest of its line + // silently overwritten with the new focus label -- the same #4010 + // data-loss class. `[ \t]*` (not `\s*`) avoids consuming the newlines + // before the label into the match; `$` documents the match ends at + // end-of-line (inert here since `.` never crosses line terminators + // without `/s`, which is not set). + const focusPattern = /^([ \t]*\*\*Current focus:\*\*[ \t]*)(.*)$/im; if (focusPattern.test(body)) { body = body.replace(focusPattern, (_match, prefix: string) => `${prefix}${focusLabel}`); updated.push('Current focus'); diff --git a/tests/state-transition.test.cjs b/tests/state-transition.test.cjs index 4221464ca..79873b9e2 100644 --- a/tests/state-transition.test.cjs +++ b/tests/state-transition.test.cjs @@ -374,6 +374,51 @@ describe('ADR-1769 Phase 1: beginPhase first-time body field updates', () => { assert.ok(result.updated.includes('Current focus'), `updated should include 'Current focus'; got ${JSON.stringify(result.updated)}`); }); + + // #4469: same defect class as #4243/PR #4453's stateReplaceField bold-branch + // fix, applied here to beginPhaseCore's own standalone `focusPattern` regex, + // which carried no `^` anchor / `/m` flag and used `\s*` (crosses newlines) + // rather than same-line whitespace. A body with NO real `**Current focus:**` + // field yet present at the top, but a mid-sentence prose mention of the same + // bold text further down (an Accumulated Context bullet documenting the + // format), demonstrates the corruption: the unanchored pattern's first (and + // only, since it's not global) match is the prose occurrence, and its rest- + // of-line gets silently overwritten with the new focus label. + test('anchored **Current focus:** rewrite leaves a mid-sentence prose lookalike untouched (#4469)', () => { + const body = [ + '# Project State', + '', + '**Status:** Planning', + '**Current Phase:** 02', + '**Current Phase Name:** Previous Phase', + '**Current Plan:** 02', + '**Total Plans in Phase:** 3', + '**Last Activity:** 2026-06-20', + '**Last Activity Description:** previous work', + '', + '## Accumulated Context', + '', + '### Decisions', + '', + '- Archived phases historically rendered `**Current focus:**` inline in prose. Must not change.', + '', + '## Current Position', + '', + 'Phase: 2 (Previous Phase)', + 'Plan: 2 of 3', + 'Status: Planning', + 'Last activity: 2026-06-20 — context gathered', + '', + ].join('\n'); + + const result = transitionCore(body, intent, deps); + const proseLine = result.content.split('\n').find((l) => l.includes('Archived phases historically')); + assert.strictEqual( + proseLine, + '- Archived phases historically rendered `**Current focus:**` inline in prose. Must not change.', + `a mid-sentence prose mention of **Current focus:** must survive byte-identically, got: ${JSON.stringify(proseLine)}`, + ); + }); }); // Fixture for resume: a STATE.md body where Status already contains