fix(#2754): make parseStateMd CRLF-safe — frontmatter no longer drops on Windows-authored STATE.md (#2865)
* 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 <test@example.com>
This commit is contained in:
5
.changeset/eager-dogs-jump.md
Normal file
5
.changeset/eager-dogs-jump.md
Normal file
@@ -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)
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user