Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions tools/design-ledger-gate/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import { describe, expect, test } from "bun:test";
import {
type Changed,
conflictMarkerViolations,
type Deps,
evaluate,
HISTORICAL_CHAIN,
Expand Down Expand Up @@ -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([]);
});
});
35 changes: 35 additions & 0 deletions tools/design-ledger-gate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -604,12 +635,16 @@ export async function runOnce(deps: Deps): Promise<number> {
});
}
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(
Expand Down
Loading