diff --git a/tools/design-ledger-gate/index.test.ts b/tools/design-ledger-gate/index.test.ts index c4adf2705..fced66df3 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,64 @@ 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([]); + }); + 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 e2a1d3a27..e355e6ae6 100644 --- a/tools/design-ledger-gate/index.ts +++ b/tools/design-ledger-gate/index.ts @@ -283,6 +283,37 @@ 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. + */ +export function conflictMarkerViolations( + file: string, + text: string, +): Violation[] { + const out: Violation[] = []; + let inFence = false; + text.split("\n").forEach((line, i) => { + 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: ${m[1]}`, + }); + } + }); + return out; +} + /** Parse DECISIONS.md text into ledger rows (topic headings/prose skipped). */ export function parseLedger(text: string): LedgerRow[] { const rows: LedgerRow[] = []; @@ -604,12 +635,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(