* 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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user