diff --git a/.changeset/eager-lynx-squeak.md b/.changeset/eager-lynx-squeak.md new file mode 100644 index 000000000..9b8ab196c --- /dev/null +++ b/.changeset/eager-lynx-squeak.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3137 +--- +**`gsd-tools windows` no longer crashes on CRLF ledgers** — on repos with `core.autocrlf=true` (Windows default), the frontmatter parser threw on the last key of a CRLF `WINDOWS.md`, making the broken-windows status/waive/fixed subcommands unusable. (#3116) diff --git a/src/broken-windows.cts b/src/broken-windows.cts index fb404cb5f..eed91a659 100644 --- a/src/broken-windows.cts +++ b/src/broken-windows.cts @@ -401,7 +401,12 @@ function parseFrontmatterStrict(raw: string): Record { } const yamlBody = raw.slice(headerEnd, closeIdx); const out: Record = {}; - for (const line of yamlBody.split(/\r?\n/)) { + for (const rawLine of yamlBody.split(/\r?\n/)) { + // #3116: the `\n---` scan leaves the final line's CR attached on a CRLF + // ledger, and `.` never matches CR, so the key: value regex below fails on + // it. Strip the trailing CR per line so the rest of `raw` (which + // parseJsonBlock also slices by byte offset) is unaffected. + const line = rawLine.replace(/\r$/, ''); if (line.trim() === '') continue; const m = line.match(/^([a-zA-Z0-9_]+):\s*(.*)$/); if (!m) { diff --git a/tests/broken-windows.test.cjs b/tests/broken-windows.test.cjs index 6e07b794f..640f3f516 100644 --- a/tests/broken-windows.test.cjs +++ b/tests/broken-windows.test.cjs @@ -783,3 +783,57 @@ describe('broken-windows CLI: lifecycle', () => { assert.equal(status.ledger.total_count, 2); }); }); + +// --------------------------------------------------------------------------- +// #3116: parseFrontmatterStrict throws on CRLF WINDOWS.md +// On repos with core.autocrlf=true (Windows default), .planning/WINDOWS.md is +// checked out CRLF. The `\n---` close-fence scan leaves the last line's CR +// attached, and `.` doesn't match CR, so the key:value regex fails. +// --------------------------------------------------------------------------- + +describe('#3116: parseLedger handles CRLF ledgers', () => { + // Build ledgers via renderLedger (the real writer) so the JSON fence + // format (4-backtick) and structure always match what production emits. + // parseLedger validates that frontmatter counts match the entries array, + // so non-zero counts require real entries (appendWindow). + + test('CRLF empty ledger parses without throwing', () => { + const ledger = emptyLedger(); + ledger.last_updated = '2026-08-06T09:43:08.354Z'; + const lfLedger = renderLedger(ledger); + const crlfLedger = lfLedger.replace(/\n/g, '\r\n'); + + // Must not throw — before the fix this throws WINDOWS_LEDGER_MALFORMED + // on the last frontmatter key ("last_updated: ...\r") + const parsed = parseLedger(crlfLedger); + assert.equal(parsed.schema_version, 1); + assert.equal(parsed.open_count, 0); + assert.equal(parsed.last_updated, '2026-08-06T09:43:08.354Z'); + }); + + test('CRLF ledger with entries parses correctly', () => { + let ledger = emptyLedger(); + const { ledger: led1 } = appendWindow(ledger, makeEntry(), { now: '2026-08-06T12:00:00Z' }); + const { ledger: led2 } = appendWindow(led1, makeEntry({ description: 'second' }), { now: '2026-08-06T12:01:00Z' }); + ledger = led2; + const lfLedger = renderLedger(ledger); + const crlfLedger = lfLedger.replace(/\n/g, '\r\n'); + + const parsed = parseLedger(crlfLedger); + assert.equal(parsed.open_count, 2); + assert.equal(parsed.total_count, 2); + assert.equal(parsed.entries.length, 2); + }); + + test('CRLF and LF ledgers produce identical parse results', () => { + let ledger = emptyLedger(); + const { ledger: led1 } = appendWindow(ledger, makeEntry(), { now: '2026-08-06T09:43:08Z' }); + ledger = led1; + const lfLedger = renderLedger(ledger); + + const lfParsed = parseLedger(lfLedger); + const crlfParsed = parseLedger(lfLedger.replace(/\n/g, '\r\n')); + + assert.deepEqual(crlfParsed, lfParsed); + }); +});