From 80607bec937fe81910666beb6714baa4516be28a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 24 Jun 2026 13:52:47 -0400 Subject: [PATCH] fix(#1658): make byPhaseTablePattern CRLF-tolerant on STATE.md tables (#1662) * fix(#1658): make byPhaseTablePattern CRLF-tolerant on STATE.md tables byPhaseTablePattern required a bare \n after the header and separator rows, so a STATE.md with CRLF (\r\n) line endings (Windows, or hand-edited) had its By-Phase table treated as absent: phase complete never upserted the row (and the velocity-from- table derivation went stale). Make the header/separator terminators and the closing lookahead CRLF-tolerant ([ \t]*\r?\n, (?=\r?\n|$)). Backward-compatible with LF. Regression folded into tests/state.test.cjs: phase complete on a CRLF STATE.md upserts the row and removes the placeholder. CONTRIBUTING's QA matrix lists Mixed CRLF/LF as a required parser case. * chore(#1658): backfill changeset pr ref to 1662 --- .changeset/sturdy-tigers-caper.md | 5 ++++ src/state.cts | 2 +- tests/state.test.cjs | 39 +++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 .changeset/sturdy-tigers-caper.md diff --git a/.changeset/sturdy-tigers-caper.md b/.changeset/sturdy-tigers-caper.md new file mode 100644 index 000000000..a3420e698 --- /dev/null +++ b/.changeset/sturdy-tigers-caper.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1662 +--- +**`phase complete` now updates the By-Phase table on CRLF (Windows) STATE.md files** — the By-Phase table matcher required bare `\n` line endings, so a STATE.md written or hand-edited with CRLF (`\r\n`) was treated as having no table: the completed phase's row was never upserted (and, with the velocity-from-table derivation, the total went stale). The matcher is now CRLF-tolerant (`\r?\n`) on the header/separator/lookahead, so CRLF STATE.md files are handled identically to LF. diff --git a/src/state.cts b/src/state.cts index 432798f6b..716e4b851 100644 --- a/src/state.cts +++ b/src/state.cts @@ -263,7 +263,7 @@ function _stateLockBodyPid(lockPath: string): number | null { let _stateStealSeq = 0; // Hoisted to module scope — compiled once, not per call (#320). Stateless (/i, used with .match). -const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i; +const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\r?\n\|(?:[- :\t]+\|)+[ \t]*\r?\n)((?:[ \t]*\|[^\n]*\n)*)(?=\r?\n|$)/i; // ─── ADR-1372 T6: seam-based section splice helper ─────────────────────────── diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 6eb416003..204eb07b8 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -2111,6 +2111,45 @@ describe('updatePerformanceMetricsSection', () => { // Total plans count updated correctly (1 pre-existing + 2 new summaries) assert.ok(stateAfter.match(/Total plans completed:\s*3/), 'Total plans completed should be 3 after upsert'); }); + + test('#1658 — By-Phase table row upserts on a CRLF STATE.md (byPhaseTablePattern must be CRLF-tolerant)', () => { + const content = [ + '# Project State', '', + '**Current Phase:** 07', '**Status:** Executing Phase 7', '', + '## Performance Metrics', '', + '**Velocity:**', + '- Total plans completed: [N]', + '- Average duration: N/A', + '- Total execution time: 0 hours', '', + '**By Phase:**', '', + '| Phase | Plans | Total | Avg/Plan |', + '|-------|-------|-------|----------|', + '| - | - | - | - |', '', + '## Accumulated Context', '', + ].join('\n'); + const statePath = path.join(tmpDir, '.planning', 'STATE.md'); + // Force CRLF line endings across the whole STATE.md (Windows / hand-edited). + fs.writeFileSync(statePath, content.replace(/\n/g, '\r\n'), 'utf8'); + + const phaseDir = path.join(tmpDir, '.planning', 'phases', '07-crlf'); + fs.mkdirSync(phaseDir, { recursive: true }); + fs.writeFileSync(path.join(phaseDir, '07-01-PLAN.md'), '# Plan\n'); + fs.writeFileSync(path.join(phaseDir, '07-01-SUMMARY.md'), '# Summary\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n## Phase 7: CRLF\n\n- [ ] Phase 7\n'); + + const result = runGsdTools('phase complete 7', tmpDir); + assert.ok(result.success, `phase complete failed: ${result.error}`); + + const after = fs.readFileSync(statePath, 'utf8'); + assert.ok( + /\|\s*7\s*\|\s*1\s*\|/.test(after), + 'By-Phase row for phase 7 must be upserted even on a CRLF STATE.md (#1658)', + ); + assert.ok( + !/\|\s*-\s*\|\s*-\s*\|\s*-\s*\|\s*-\s*\|/.test(after), + 'placeholder row must be removed on CRLF STATE.md once a real row is upserted', + ); + }); }); // ─────────────────────────────────────────────────────────────────────────────