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
This commit is contained in:
Tom Boucher
2026-06-24 13:52:47 -04:00
committed by GitHub
parent 752df8adb4
commit 80607bec93
3 changed files with 45 additions and 1 deletions

View File

@@ -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.

View File

@@ -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 ───────────────────────────

View File

@@ -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',
);
});
});
// ─────────────────────────────────────────────────────────────────────────────