fix(#3116): strip trailing CR per line in parseFrontmatterStrict for CRLF WINDOWS.md (#3137)

* test(#3116): failing-first — parseLedger 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 frontmatter
line's CR attached, and the key:value regex's . doesn't match CR, so the
parser throws WINDOWS_LEDGER_MALFORMED on the last key.

* fix(#3116): strip trailing CR per line in parseFrontmatterStrict

The \n--- close-fence scan lands on the LF of the last frontmatter line's
CRLF, so yamlBody ends with a bare \r. split(/\r?\n/) strips CR from
interior lines but the last line's \r survives. The key:value regex fails
because . doesn't match CR.

Fix: strip \r per line (rawLine.replace(/\r$/, '')) rather than normalizing
raw — the writer round-trips raw byte-exact.

* chore(#3116): add changeset fragment

* chore(#3116): backfill changeset PR number 3137

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-07 03:14:40 -04:00
committed by GitHub
parent 0e6fa2e2cf
commit a731a45cd6
3 changed files with 65 additions and 1 deletions

View File

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

View File

@@ -401,7 +401,12 @@ function parseFrontmatterStrict(raw: string): Record<string, number | string> {
}
const yamlBody = raw.slice(headerEnd, closeIdx);
const out: Record<string, number | string> = {};
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) {

View File

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