From 1da2b9d718a8695238dab0d69c34e5f41df91fdd Mon Sep 17 00:00:00 2001 From: mintaka Date: Sat, 12 Sep 2026 23:31:39 -0400 Subject: [PATCH 1/2] fix(design-ledger-gate): fail on unresolved merge conflict markers The gate parsed a governed file with conflict markers in it and reported OK with rc=0: markers sit between rows, so id uniqueness, status grammar and link resolution all still pass. Verified before the fix on a marker-bearing DECISIONS.md. That matters because the ledger is one append-only file every lane appends to, so conflicts are the steady state rather than an incident, and the pre-enqueue merge-result check runs the gate over an extracted tree. git merge-tree exits non-zero on a conflict but still prints a tree OID, so a recipe that skips the exit code extracts a marker-bearing file and the gate blessed it. Both git and jj marker styles are matched, across the ledger and every record. Refs RIG-3520 Co-authored-by: Matt Wilkinson --- tools/design-ledger-gate/index.test.ts | 36 ++++++++++++++++++++++++++ tools/design-ledger-gate/index.ts | 29 +++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/tools/design-ledger-gate/index.test.ts b/tools/design-ledger-gate/index.test.ts index c4adf2705..539a884d8 100644 --- a/tools/design-ledger-gate/index.test.ts +++ b/tools/design-ledger-gate/index.test.ts @@ -16,6 +16,7 @@ import { describe, expect, test } from "bun:test"; import { type Changed, + conflictMarkerViolations, type Deps, evaluate, HISTORICAL_CHAIN, @@ -1038,3 +1039,38 @@ describe("runOnce", () => { expect(await runOnce(d)).toBe(2); }); }); + +describe("conflictMarkerViolations", () => { + test("flags git-style markers that leave every DL- row valid", () => { + // The dangerous shape: markers sit between rows, so id uniqueness, status + // grammar, and link resolution all still pass. + const text = [ + "| DL-001 | a | Active (m, 2026-01-01) | [r](r.md) |", + "<<<<<<< HEAD", + "=======", + ">>>>>>> theirs", + ].join("\n"); + const got = conflictMarkerViolations(LEDGER, text); + expect(got.map((v) => v.line)).toEqual([2, 3, 4]); + expect(got[0]?.message).toContain("unresolved merge conflict marker"); + }); + + test("flags jj-style markers too", () => { + const text = [ + "<<<<<<< conflict 1 of 1", + "%%%%%%% diff", + "+++++++ side", + ">>>>>>> ends", + ].join("\n"); + expect(conflictMarkerViolations(LEDGER, text)).toHaveLength(4); + }); + + test("stays silent on ordinary prose", () => { + // Guards the false-positive edge: a table separator and a fenced diff both + // carry runs of = and +, but neither opens a conflict. + const text = ["| --- | --- |", "```diff", "+++ b/x", "```", "a === b"].join( + "\n", + ); + expect(conflictMarkerViolations(LEDGER, text)).toEqual([]); + }); +}); diff --git a/tools/design-ledger-gate/index.ts b/tools/design-ledger-gate/index.ts index e2a1d3a27..4d99e9d1f 100644 --- a/tools/design-ledger-gate/index.ts +++ b/tools/design-ledger-gate/index.ts @@ -283,6 +283,31 @@ function splitLedgerRow(row: string): string[] { return cells; } +/** + * Unresolved merge markers in a governed file. The ledger is one append-only + * file every lane appends to, so conflicts are routine — and markers can leave + * every `| DL-` row syntactically valid, which passes every other check here. + * Verified: a marker-bearing DECISIONS.md reports OK with rc=0 without this. + */ +export function conflictMarkerViolations( + file: string, + text: string, +): Violation[] { + const out: Violation[] = []; + text.split("\n").forEach((line, i) => { + // jj writes `%%%%%%%`/`+++++++` alongside git's three; match all five so a + // jj-materialized conflict cannot slip through a git-only check. + if (/^(<{7}|={7}|>{7}|%{7}|\+{7})(\s|$)/.test(line)) { + out.push({ + file, + line: i + 1, + message: `unresolved merge conflict marker: ${line.slice(0, 7)}`, + }); + } + }); + return out; +} + /** Parse DECISIONS.md text into ledger rows (topic headings/prose skipped). */ export function parseLedger(text: string): LedgerRow[] { const rows: LedgerRow[] = []; @@ -604,12 +629,16 @@ export async function runOnce(deps: Deps): Promise { }); } const ledger = ledgerText === null ? [] : parseLedger(ledgerText); + if (ledgerText !== null) { + violations.push(...conflictMarkerViolations(DECISIONS_PATH, ledgerText)); + } const records: RecordHeader[] = []; for (const path of recordFiles) { const text = await readText(root, path); if (text === null) continue; // listed but vanished — ignore records.push(parseRecordHeader(path, text)); + violations.push(...conflictMarkerViolations(path, text)); } violations.push( From df0b5b20ac9c6d3b0589e5c9ddd804169f73c1e3 Mon Sep 17 00:00:00 2001 From: mintaka Date: Sun, 13 Sep 2026 00:09:52 -0400 Subject: [PATCH 2/2] fix(design-ledger-gate): catch widened markers, skip fenced examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the exact-7 match was a silent false negative. git and jj both widen every conflict marker past 7 when the conflicting hunk itself holds a marker-like run, so an 11-char marker slipped through the check entirely — reintroducing the defect this gate change exists to close. Verified: the shipped regex returns false for the 11-char forms. The opener, closer and both jj markers now match 7-or-more, which is false-positive-free across docs/designs. `=` deliberately stays exact: six 14-char `=` setext underlines live in the manager-prompt record, so widening it would break the gate for every lane. Also skips fenced blocks, matching how the rest of this module reads records, so a record documenting a marker as example text stays green. Refs RIG-3520 Co-authored-by: Matt Wilkinson --- tools/design-ledger-gate/index.test.ts | 26 ++++++++++++++++++++++++++ tools/design-ledger-gate/index.ts | 16 +++++++++++----- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/tools/design-ledger-gate/index.test.ts b/tools/design-ledger-gate/index.test.ts index 539a884d8..fced66df3 100644 --- a/tools/design-ledger-gate/index.test.ts +++ b/tools/design-ledger-gate/index.test.ts @@ -1073,4 +1073,30 @@ describe("conflictMarkerViolations", () => { ); expect(conflictMarkerViolations(LEDGER, text)).toEqual([]); }); + test("flags lengthened markers — both tools widen past 7", () => { + // A conflict whose hunk holds a marker-like run makes git and jj emit + // wider markers; an exact-7 match misses the conflict entirely. + const text = [ + "<<<<<<<<<<< conflict 1 of 1", + "%%%%%%%%%%% diff", + "+++++++++++ side", + ">>>>>>>>>>> ends", + ].join("\n"); + const got = conflictMarkerViolations(LEDGER, text); + expect(got).toHaveLength(4); + expect(got[0]?.message).toContain("<<<<<<<<<<<"); + }); + + test("stays silent on a long setext underline", () => { + // Governed records carry 14-char `=` underlines, so `=` must stay exact. + const text = ["A heading", "==============", "", "body"].join("\n"); + expect(conflictMarkerViolations(LEDGER, text)).toEqual([]); + }); + + test("stays silent on a marker shown as fenced example text", () => { + const text = ["```text", "<<<<<<< HEAD", ">>>>>>> theirs", "```"].join( + "\n", + ); + expect(conflictMarkerViolations(LEDGER, text)).toEqual([]); + }); }); diff --git a/tools/design-ledger-gate/index.ts b/tools/design-ledger-gate/index.ts index 4d99e9d1f..e355e6ae6 100644 --- a/tools/design-ledger-gate/index.ts +++ b/tools/design-ledger-gate/index.ts @@ -287,21 +287,27 @@ function splitLedgerRow(row: string): string[] { * Unresolved merge markers in a governed file. The ledger is one append-only * file every lane appends to, so conflicts are routine — and markers can leave * every `| DL-` row syntactically valid, which passes every other check here. - * Verified: a marker-bearing DECISIONS.md reports OK with rc=0 without this. */ export function conflictMarkerViolations( file: string, text: string, ): Violation[] { const out: Violation[] = []; + let inFence = false; text.split("\n").forEach((line, i) => { - // jj writes `%%%%%%%`/`+++++++` alongside git's three; match all five so a - // jj-materialized conflict cannot slip through a git-only check. - if (/^(<{7}|={7}|>{7}|%{7}|\+{7})(\s|$)/.test(line)) { + if (/^\s*(```|~~~)/.test(line)) inFence = !inFence; + // A record may legitimately show a marker as fenced example text; the rest + // of this module skips fences for the same reason. + if (inFence) return; + // jj adds `%%%%%%%`/`+++++++` to git's three, and both tools LENGTHEN every + // marker past 7 when the conflicting hunk itself holds a marker-like run. + // `=` stays exact: governed records use long `=` setext underlines. + const m = /^(<{7,}|>{7,}|%{7,}|\+{7,}|={7})(\s|$)/.exec(line); + if (m) { out.push({ file, line: i + 1, - message: `unresolved merge conflict marker: ${line.slice(0, 7)}`, + message: `unresolved merge conflict marker: ${m[1]}`, }); } });