From ef7a71fc0971ad65880f7f323f558500667eabf7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 30 Jul 2026 14:34:28 -0400 Subject: [PATCH] =?UTF-8?q?fix(#2754):=20make=20parseStateMd=20CRLF-safe?= =?UTF-8?q?=20=E2=80=94=20frontmatter=20no=20longer=20drops=20on=20Windows?= =?UTF-8?q?-authored=20STATE.md=20(#2865)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#2754): parseStateMd must parse CRLF STATE.md identically to LF The frontmatter fence regex and downstream splits used literal \n, so a CRLF STATE.md dropped the ENTIRE frontmatter block. Assert CRLF/LF parity (the production contract) across full frontmatter, next_phases flow + block forms, the progress nested block, and null handling. * fix(#2754): make parseStateMd CRLF-safe — frontmatter fence + splits use \r?\n The fence regex, scalar-line split, next_phases block-list regex, and progress block regex all used literal \n, so a CRLF (Windows-authored) STATE.md dropped the ENTIRE frontmatter block — every statusline field was silently absent. Use \r?\n throughout, mirroring the CRLF-safe extractFrontmatter in src/frontmatter.cts. LF behavior unchanged. * chore(#2754): changeset fragment * test(#2754): pin parseStateMd↔extractFrontmatter parity (Generative-Fix Divergence guard) The statusline parseStateMd and the canonical extractFrontmatter both derive GSD state from STATE.md frontmatter and diverged once already (the CRLF bug this PR fixes). Add a cross-parser parity assertion (CLAUDE.md parallel-surfaces rule) over the overlapping fields, under both LF and CRLF, so a future divergence on a scalar shape or line ending is caught here. (isolated-review minor finding) * chore(#2754): backfill changeset PR number (2865) --------- Co-authored-by: Test --- .changeset/eager-dogs-jump.md | 5 ++ hooks/gsd-statusline.js | 15 ++-- tests/gsd-statusline.test.cjs | 125 ++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 6 deletions(-) create mode 100644 .changeset/eager-dogs-jump.md diff --git a/.changeset/eager-dogs-jump.md b/.changeset/eager-dogs-jump.md new file mode 100644 index 000000000..d1ec5d183 --- /dev/null +++ b/.changeset/eager-dogs-jump.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2865 +--- +**The statusline now renders GSD state correctly on Windows-authored (CRLF) STATE.md** — `parseStateMd` no longer drops the entire frontmatter block on CRLF input. The fence regex and downstream splits now accept CRLF line endings, matching the canonical `extractFrontmatter` parser. Previously a CRLF STATE.md silently produced an empty GSD-state segment (no status, phase, or milestone) with no error. (#2754) diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index 9256f4bd8..899f14c5a 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -143,12 +143,15 @@ function readGsdState(dir) { function parseStateMd(content) { const state = {}; - // YAML frontmatter between --- markers (anchored at file start) - const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + // YAML frontmatter between --- markers (anchored at file start). + // #2754: \r?\n (not literal \n) so a CRLF STATE.md (Windows-authored) parses + // identically to LF — pre-fix the literal-\n fence dropped the ENTIRE block. + // Mirrors the CRLF-safe extractFrontmatter in src/frontmatter.cts. + const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (fmMatch) { const fm = fmMatch[1]; // Top-level scalar key: value - for (const line of fm.split('\n')) { + for (const line of fm.split(/\r?\n/)) { const m = line.match(/^(\w+):\s*(.+)/); if (!m) continue; const [, key, val] = m; @@ -169,10 +172,10 @@ function parseStateMd(content) { const items = npFlowMatch[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean); state.nextPhases = items.length > 0 ? items : null; } else { - const npBlockMatch = fm.match(/^next_phases:\s*\n((?:[ \t]*-[ \t]*[^\n]+\n?)*)/m); + const npBlockMatch = fm.match(/^next_phases:\s*\r?\n((?:[ \t]*-[ \t]*[^\r\n]+\r?\n?)*)/m); if (npBlockMatch) { const items = npBlockMatch[1] - .split('\n') + .split(/\r?\n/) .map(line => line.match(/^[ \t]*-[ \t]*(.+)$/)) .filter(Boolean) .map(m => m[1].trim().replace(/^["']|["']$/g, '')) @@ -181,7 +184,7 @@ function parseStateMd(content) { } } // progress nested block: completed_phases / total_phases / percent (2-space indent) - const progMatch = fm.match(/^progress:\s*\n((?:[ \t]+\w+:.+\n?)+)/m); + const progMatch = fm.match(/^progress:\s*\r?\n((?:[ \t]+\w+:.+\r?\n?)+)/m); if (progMatch) { const cp = progMatch[1].match(/^[ \t]+completed_phases:\s*(\d+)/m); const tp = progMatch[1].match(/^[ \t]+total_phases:\s*(\d+)/m); diff --git a/tests/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs index 082fce1ad..ffd2eb7f1 100644 --- a/tests/gsd-statusline.test.cjs +++ b/tests/gsd-statusline.test.cjs @@ -146,6 +146,131 @@ describe('parseStateMd', () => { assert.equal(s.nextAction, 'execute'); assert.deepEqual(s.nextPhases, ['4.5', '4.6']); }); + + // #2754 — the frontmatter fence regex and downstream splits used literal \n, + // so a CRLF STATE.md dropped the ENTIRE frontmatter block (every field absent). + // The invariant is CRLF/LF parity — parseStateMd must yield the same state for + // the same content regardless of line endings, matching the canonical + // extractFrontmatter parser (src/frontmatter.cts), which is CRLF-safe. + const crlf = (lfContent) => lfContent.replace(/\n/g, '\r\n'); + + test('parses full YAML frontmatter identically under CRLF (#2754)', () => { + const lf = [ + '---', + 'status: executing', + 'milestone: v1.9', + 'milestone_name: Code Quality', + 'active_phase: 4', + 'next_action: execute', + '---', + '', + '# State', + 'Phase: 1 of 5 (fix-graphiti-deployment)', + ].join('\n'); + + const lfState = parseStateMd(lf); + const crlfState = parseStateMd(crlf(lf)); + assert.deepStrictEqual(crlfState, lfState, + 'CRLF STATE.md must parse identically to LF — pre-fix the entire frontmatter block was dropped (#2754)'); + // pin the specific fields the issue names so a vacuous deepEqual({},{}) can't pass: + assert.equal(crlfState.status, 'executing'); + assert.equal(crlfState.milestone, 'v1.9'); + assert.equal(crlfState.milestoneName, 'Code Quality'); + assert.equal(crlfState.activePhase, '4'); + assert.equal(crlfState.nextAction, 'execute'); + assert.equal(crlfState.phaseNum, '1'); + assert.equal(crlfState.phaseTotal, '5'); + }); + + test('parses next_phases flow-array form identically under CRLF (#2754)', () => { + const lf = [ + '---', + 'next_phases: [4.5, 4.6]', + '---', + ].join('\n'); + + assert.deepStrictEqual(parseStateMd(crlf(lf)), parseStateMd(lf)); + assert.deepEqual(parseStateMd(crlf(lf)).nextPhases, ['4.5', '4.6']); + }); + + test('parses next_phases block-list form identically under CRLF (#2754)', () => { + const lf = [ + '---', + 'next_phases:', + ' - 4.5', + ' - 4.6', + '---', + ].join('\n'); + + assert.deepStrictEqual(parseStateMd(crlf(lf)).nextPhases, parseStateMd(lf).nextPhases, + 'block-list regex used a literal \\n — CRLF must still parse the list'); + assert.deepEqual(parseStateMd(crlf(lf)).nextPhases, ['4.5', '4.6']); + }); + + test('parses the progress nested block identically under CRLF (#2754)', () => { + const lf = [ + '---', + 'progress:', + ' completed_phases: 3', + ' total_phases: 5', + ' percent: 60', + '---', + ].join('\n'); + + assert.deepStrictEqual(parseStateMd(crlf(lf)), parseStateMd(lf), + 'progress block regex used a literal \\n — CRLF must still parse completed/total/percent'); + assert.equal(parseStateMd(crlf(lf)).completedPhases, '3'); + assert.equal(parseStateMd(crlf(lf)).totalPhases, '5'); + assert.equal(parseStateMd(crlf(lf)).percent, '60'); + }); + + test('treats literal "null" values as null identically under CRLF (#2754)', () => { + const lf = [ + '---', + 'status: null', + 'milestone: null', + 'milestone_name: null', + '---', + ].join('\n'); + + assert.deepStrictEqual(parseStateMd(crlf(lf)), parseStateMd(lf)); + assert.equal(parseStateMd(crlf(lf)).status, null); + assert.equal(parseStateMd(crlf(lf)).milestone, null); + }); + + // #2754 (Generative-Fix Divergence guard, CLAUDE.md "parallel surfaces sharing a + // parser"): parseStateMd and the canonical extractFrontmatter both derive GSD + // state from the same STATE.md frontmatter, and they diverged once already (this + // very CRLF bug). Pin the overlap so a future divergence on a scalar shape or + // line ending is caught here, not in a live Windows report. + const { extractFrontmatter } = require('../gsd-core/bin/lib/frontmatter.cjs'); + + test('parseStateMd frontmatter-derived fields agree with extractFrontmatter (LF + CRLF, #2754)', () => { + const lf = [ + '---', + 'status: executing', + 'milestone: v1.9', + 'milestone_name: Code Quality', + 'active_phase: 4', + 'next_action: execute', + '---', + '', + '# State', + 'Phase: 1 of 5 (fix-graphiti-deployment)', + ].join('\n'); + + for (const [label, content] of [['LF', lf], ['CRLF', crlf(lf)]]) { + const fm = extractFrontmatter(content); + const st = parseStateMd(content); + // extractFrontmatter yields the raw frontmatter object; parseStateMd projects + // a subset onto its own keys. Assert the projection matches the raw values. + assert.equal(st.status, fm.status, `status mismatch (${label})`); + assert.equal(st.milestone, fm.milestone, `milestone mismatch (${label})`); + assert.equal(st.milestoneName, fm.milestone_name, `milestone_name mismatch (${label})`); + assert.equal(st.activePhase, String(fm.active_phase), `active_phase mismatch (${label})`); + assert.equal(st.nextAction, fm.next_action, `next_action mismatch (${label})`); + } + }); }); // ─── formatGsdState ─────────────────────────────────────────────────────────