fix(#3657): tolerate commonmark fence widths in ledger readers (#3733)

* test(#3657): fence-width tolerance regression rows

* test(#3657): fix pure-row fixtures to use appendWindow result shape

* fix(#3657): tolerate commonmark fence widths in ledger readers

* fix(#3657): restore throw-block indentation in parseJsonBlock

* chore(#3657): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-21 03:45:06 -04:00
committed by GitHub
parent 72819a4616
commit 1f76861202
5 changed files with 380 additions and 31 deletions

View File

@@ -19,9 +19,9 @@
* ---
* # Broken Windows Ledger
* <human-readable prose>
* ```json
* ````json
* [ <entries array, canonical JSON> ]
* ```
* ````
*
* Frontmatter holds scalar counts (the FAST path the ship gate reads via jq
* without parsing JSON). The JSON code block is the AUTHORITATIVE entries
@@ -379,6 +379,84 @@ const JSON_FENCE_OPEN = '````json';
const JSON_FENCE_CLOSE = '````';
const FORBIDDEN_BACKTICK_RUN = '````';
// Reader-side fence tolerance (#3657): CommonMark formatters (Prettier et al.)
// normalize the written 4-backtick fence down to the shortest legal width (3)
// whenever the block body holds no backtick run — and a canonical-JSON ledger
// body never does. Both widths are valid CommonMark, so the reader locates the
// block by a line-anchored 3+ fence and closes on a run at least as wide as
// the opening one (CommonMark: a shorter run does not close). The writer above
// is unchanged — 4 backticks stay what renderLedger emits (#1950 review H1).
type JsonBlockSpan = { bodyStart: number; bodyEnd: number; afterClose: number };
type JsonBlockLookup =
| { ok: true; span: JsonBlockSpan }
| { ok: false; reason: 'missing-open' | 'unterminated' };
/**
* Locate the entries JSON block by CommonMark fence rules rather than a fixed
* literal width. Both parseJsonBlock (strict) and writeLedgerAtomic's #2893
* prose preservation (lenient) go through this one function so read tolerance
* and splice tolerance cannot drift (#3657). A backtick-only line can never
* occur inside a body: JSON.stringify renders strings single-line-escaped, so
* an inline run inside a description is never a close-fence candidate.
*
* Disambiguation (#3657 security review): an entry description may contain
* newlines and 3-backtick runs (append validation rejects only 4+ runs), and
* renderTable renders descriptions into the prose ABOVE the JSON block — so
* hostile or accidental text can plant a second json fence above the real
* one. renderLedger always emits the entries block as the FINAL fenced
* section, so spans are scanned in REVERSE: prefer the latest span whose
* entries length equals the frontmatter total_count (the real block always
* satisfies it — parseLedger cross-checks that invariant), else the latest
* span whose body is a JSON array, else the first span so corrupt bodies keep
* their fail-closed parse errors. A mirror planted below with identical
* length and identical entries is indistinguishable by construction — and
* harmless.
*/
function locateJsonBlock(raw: string, expectedTotal?: number): JsonBlockLookup {
const spans: JsonBlockSpan[] = [];
for (const open of raw.matchAll(/^(`{3,})json[ \t]*\r?$/gm)) {
const width = open[1].length;
const bodyStart = (open.index ?? 0) + open[0].length;
for (const close of raw.slice(bodyStart).matchAll(/^(`{3,})[ \t]*\r?$/gm)) {
if (close[1].length < width) continue;
const bodyEnd = bodyStart + (close.index ?? 0);
const closeLineEnd = raw.indexOf('\n', bodyEnd);
spans.push({
bodyStart,
bodyEnd,
afterClose: closeLineEnd === -1 ? raw.length : closeLineEnd + 1,
});
break; // CommonMark: the first qualifying close ends this fence block
}
}
if (spans.length === 0) {
const sawOpen = /^(`{3,})json[ \t]*\r?$/m.test(raw);
return { ok: false, reason: sawOpen ? 'unterminated' : 'missing-open' };
}
const parseBody = (s: JsonBlockSpan): unknown => {
try {
return JSON.parse(raw.slice(s.bodyStart, s.bodyEnd).trim());
} catch {
return undefined;
}
};
if (expectedTotal !== undefined) {
for (let i = spans.length - 1; i >= 0; i--) {
const body = parseBody(spans[i]);
if (Array.isArray(body) && body.length === expectedTotal) {
return { ok: true, span: spans[i] };
}
}
}
for (let i = spans.length - 1; i >= 0; i--) {
if (Array.isArray(parseBody(spans[i]))) {
return { ok: true, span: spans[i] };
}
}
return { ok: true, span: spans[0] };
}
/**
* Minimal strict frontmatter parser for flat scalar keys. Only supports the
* shape this module emits: `key: <number|string>` per line. Throws on any
@@ -433,22 +511,17 @@ function parseFrontmatterStrict(raw: string): Record<string, number | string> {
return out;
}
function parseJsonBlock(raw: string): WindowEntry[] {
const start = raw.indexOf(JSON_FENCE_OPEN);
if (start === -1) {
function parseJsonBlock(raw: string, expectedTotal?: number): WindowEntry[] {
const span = locateJsonBlock(raw, expectedTotal);
if (!span.ok) {
throw new WindowsError(
REASON.WINDOWS_LEDGER_MALFORMED,
'Ledger missing JSON code block for entries.',
span.reason === 'missing-open'
? 'Ledger missing JSON code block for entries.'
: 'Ledger JSON code block not terminated.',
);
}
const end = raw.indexOf(JSON_FENCE_CLOSE, start + JSON_FENCE_OPEN.length);
if (end === -1) {
throw new WindowsError(
REASON.WINDOWS_LEDGER_MALFORMED,
'Ledger JSON code block not terminated.',
);
}
const jsonText = raw.slice(start + JSON_FENCE_OPEN.length, end).trim();
const jsonText = raw.slice(span.span.bodyStart, span.span.bodyEnd).trim();
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
@@ -556,7 +629,7 @@ export function parseLedger(raw: string): Ledger {
);
}
const entries = parseJsonBlock(raw);
const entries = parseJsonBlock(raw, typeof fm.total_count === 'number' ? fm.total_count : undefined);
const ledger: Ledger = {
schema_version: SCHEMA_VERSION,
open_count: typeof fm.open_count === 'number' ? fm.open_count : 0,
@@ -734,17 +807,16 @@ function writeLedgerAtomic(cwd: string, ledger: Ledger): void {
let trailingProse = '';
try {
const existing = fs.readFileSync(p, 'utf8');
// #2893: search for the CLOSING fence starting AFTER the opening fence,
// mirroring parseJsonBlock — indexOf(JSON_FENCE_CLOSE) alone would match
// the opening fence ('````json' starts with '````').
const openIdx = existing.indexOf(JSON_FENCE_OPEN);
if (openIdx !== -1) {
const fenceEnd = existing.indexOf(JSON_FENCE_CLOSE, openIdx + JSON_FENCE_OPEN.length);
if (fenceEnd !== -1) {
const afterFence = existing.slice(fenceEnd + JSON_FENCE_CLOSE.length);
// Drop leading newlines; keep the rest as prose.
trailingProse = afterFence.replace(/^(?:\r?\n)+/, '');
}
// #2893: search for the CLOSING fence starting AFTER the opening fence.
// The span is located with the same tolerant + disambiguated fence rules
// parseJsonBlock uses (#3657), so a formatter-normalized 3-backtick ledger
// keeps its prose too — a literal-width search here would find no block
// and silently drop everything below the ledger on the next write.
const span = locateJsonBlock(existing, ledger.total_count);
if (span.ok) {
const afterFence = existing.slice(span.span.afterClose);
// Drop leading newlines; keep the rest as prose.
trailingProse = afterFence.replace(/^(?:\r?\n)+/, '');
}
} catch {
// File doesn't exist yet (first write) — no prose to preserve.

View File

@@ -874,6 +874,29 @@ export function reanchorBaseline(
const PROPOSAL_JSON_FENCE_OPEN = '````json';
const PROPOSAL_JSON_FENCE_CLOSE = '````';
// Reader-side fence tolerance (#3657 — same defect class as the WINDOWS.md
// ledger): CommonMark formatters (Prettier et al.) narrow the written
// 4-backtick fence to the shortest legal width (3) whenever the body holds no
// backtick run, and a canonical-JSON candidates array never does. Locate the
// block by a line-anchored 3+ fence and close on a run at least as wide
// (CommonMark: a shorter run does not close). The writer above is unchanged.
// This module is a leaf (CONTEXT.md — imports only node:fs/node:path), so the
// span logic is local rather than imported from broken-windows.
const PROPOSAL_FENCE_OPEN_RE = /^(`{3,})json[ \t]*\r?$/m;
function locateProposalJsonBlock(text: string): { jsonText: string } | null {
const open = text.match(PROPOSAL_FENCE_OPEN_RE);
if (!open || open.index === undefined) return null;
const width = open[1].length;
const bodyStart = open.index + open[0].length;
for (const close of text.slice(bodyStart).matchAll(/^(`{3,})[ \t]*\r?$/gm)) {
if (close[1].length < width) continue;
const bodyEnd = close.index ?? 0;
return { jsonText: text.slice(bodyStart, bodyStart + bodyEnd).trim() };
}
return null;
}
export function renderProposal(p: Proposal): string {
const fm = [
'---',
@@ -931,11 +954,9 @@ export function parseProposal(text: string): Proposal | null {
fm[m[1]] = m[2].trim();
}
const jsonStart = text.indexOf(PROPOSAL_JSON_FENCE_OPEN);
if (jsonStart === -1) return null;
const jsonEnd = text.indexOf(PROPOSAL_JSON_FENCE_CLOSE, jsonStart + PROPOSAL_JSON_FENCE_OPEN.length);
if (jsonEnd === -1) return null;
const jsonText = text.slice(jsonStart + PROPOSAL_JSON_FENCE_OPEN.length, jsonEnd).trim();
const span = locateProposalJsonBlock(text);
if (span === null) return null;
const jsonText = span.jsonText;
let candidates: unknown;
try {
candidates = JSON.parse(jsonText);