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:
Tom Boucher
2026-07-30 14:34:28 -04:00
committed by GitHub
parent 82ca13f5a5
commit ef7a71fc09
3 changed files with 139 additions and 6 deletions

View File

@@ -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);