import { describe, expect, test } from "bun:test"; import { mergeText } from "../git/merge3"; import type { MergeConflict } from "../git/merge"; import { acceptGenerated, assembleResolution, baseCandidates, redactSecrets, restoreSecrets } from "./candidates"; import { applyChoices, parseConflictText } from "~/lib/conflict-blocks"; import { above, buildClefRequest, decideFile, DEFAULT_THRESHOLDS } from "./decide"; import type { ClefRequest, ClefResponse } from "./decide"; import { mockProvider } from "./mock"; import { DEFAULT_LIMITS, isInterleaving, planMergeFix } from "./plan"; import type { MergeFixProvider } from "./plan"; import { isStale, resolveMode, selectResolutions, shouldAutoApply } from "./proposal"; const labels = { ours: "feature", theirs: "main" }; const base = "a\nb\nc\nd\ne\n"; const ours = "a\nB-ours\nc\nd\ne\n"; const theirs = "a\nB-theirs\nc\nd\ne\n"; function conflictFor(baseText: string, ourText: string, theirText: string, path = "src/app.ts"): MergeConflict { const merged = mergeText(baseText, ourText, theirText, labels); return { path, kind: "content", content: merged.text, hunks: merged.hunks }; } const plain = { title: "Rename b", body: "Updates b" }; const noopMeter = async () => {}; describe("hunk capture and parsing", () => { test("mergeText records base, ours and theirs per hunk", () => { const merged = mergeText(base, ours, theirs, labels); expect(merged.hunks).toEqual([{ base: "b\n", ours: "B-ours\n", theirs: "B-theirs\n" }]); const parsed = parseConflictText(merged.text); expect(parsed?.blocks).toEqual([{ ours: "B-ours\n", theirs: "B-theirs\n" }]); }); test("assembles a clean file from chosen texts", () => { const conflict = conflictFor(base, ours, theirs); const text = assembleResolution(conflict.content ?? "", conflict.hunks ?? [], ["B-both\n"]); expect(text).toBe("a\nB-both\nc\nd\ne\n"); }); test("refuses when a choice reintroduces markers or blocks do not line up", () => { const conflict = conflictFor(base, ours, theirs); expect(assembleResolution(conflict.content ?? "", conflict.hunks ?? [], ["<<<<<<< x\n"])).toBeNull(); expect(assembleResolution(conflict.content ?? "", [], [])).toBeNull(); expect(parseConflictText("a\n=======\nb\n")).toBeNull(); }); test("applyChoices keeps undecided blocks verbatim", () => { const merged = mergeText("a\nb\nc\nd\ne\nf\ng\n", "a\nB1\nc\nd\ne\nF1\ng\n", "a\nB2\nc\nd\ne\nF2\ng\n", labels); const partial = applyChoices(merged.text, ["B\n", null]); expect(partial?.startsWith("a\nB\nc\n")).toBe(true); expect(partial).toContain("<<<<<<< feature\nF1\n=======\nF2\n>>>>>>> main\n"); expect(applyChoices(merged.text, ["B\n", "F\n"])).toBe("a\nB\nc\nd\ne\nF\ng\n"); }); }); describe("candidates", () => { test("builds four deterministic candidates", () => { const candidates = baseCandidates({ base: "b\n", ours: "x\n", theirs: "y\n" }); expect(candidates.map((candidate) => [candidate.id, candidate.text])).toEqual([ ["ours", "x\n"], ["theirs", "y\n"], ["ours_then_theirs", "x\ny\n"], ["theirs_then_ours", "y\nx\n"], ]); }); test("dedupes identical candidates (one side deleted the hunk)", () => { const candidates = baseCandidates({ base: "b\n", ours: "", theirs: "y\n" }); expect(candidates.map((candidate) => candidate.id)).toEqual(["ours", "theirs"]); }); test("rejects generated output with markers, without a fence, or with unknown placeholders", () => { const vault = new Map(); expect(acceptGenerated("```merge\nok\n```", vault, 100)).toBe("ok\n"); expect(acceptGenerated("ok", vault, 100)).toBeNull(); expect(acceptGenerated("```merge\n<<<<<<< HEAD\nx\n=======\ny\n>>>>>>> main\n```", vault, 100)).toBeNull(); expect(acceptGenerated("```merge\n__GITFOR_REDACTED_7__\n```", vault, 100)).toBeNull(); expect(acceptGenerated(`\`\`\`merge\n${"x".repeat(200)}\n\`\`\``, vault, 100)).toBeNull(); }); }); describe("redaction", () => { const secret = "ghp_" + "a".repeat(36); test("replaces secrets with stable placeholders and restores them", () => { const vault = new Map(); const redacted = redactSecrets(`token = "${secret}"\nagain ${secret}\n`, vault); expect(redacted).not.toContain(secret); expect(redacted.match(/__GITFOR_REDACTED_0__/g)?.length).toBe(2); expect(restoreSecrets(redacted, vault)).toContain(secret); }); test("secrets never reach the generative model or Clef", async () => { const seen: string[] = []; const inner = mockProvider(); const spy: MergeFixProvider = { generate: async (input) => { seen.push(JSON.stringify(input.messages)); return inner.generate(input); }, decide: async (request) => { seen.push(JSON.stringify(request)); return inner.decide(request); }, }; const conflict = conflictFor("x\nkey = 1\ny\n", `x\nkey = "${secret}"\ny\n`, "x\nkey = 2\ny\n"); const plan = await planMergeFix([conflict], { title: `Use ${secret}`, body: secret, provider: spy, thresholds: DEFAULT_THRESHOLDS, limits: DEFAULT_LIMITS, clefModel: "clef", meter: noopMeter, }); expect(seen.length).toBe(3); for (const payload of seen) expect(payload).not.toContain(secret); const file = plan.files[0]; expect(file.status).toBe("planned"); if (file.status === "planned") expect(file.hunks[0].candidates[0].text).toContain(secret); }); }); function response( choice: string, probabilities: Record, risk = [0.9, 0.08, 0.02], human = 0.05, ): ClefResponse { return { answers: { hunk_0: { type: "choice", choice, probabilities, confidence: probabilities[choice] }, risk: { type: "score", score: risk[1] + 2 * risk[2], probabilities: { 0: risk[0], 1: risk[1], 2: risk[2] }, confidence: 0.9, }, needs_human: { type: "noul", noul: human }, }, }; } describe("gating", () => { const hunk = { base: "b\n", ours: "x\n", theirs: "y\n" }; const inputs = [{ hunk, candidates: baseCandidates(hunk) }]; test("above requires both confidence and margin", () => { expect(above({ a: 0.85, b: 0.15 }, "a", DEFAULT_THRESHOLDS)).toBe(true); expect(above({ a: 0.79, b: 0.21 }, "a", DEFAULT_THRESHOLDS)).toBe(false); expect(above({ a: 0.8, b: 0.55 }, "a", { confidence: 0.5, margin: 0.3 })).toBe(false); }); test("auto-applies a confident, cosmetic, unambiguous decision", () => { const decision = decideFile( inputs, response("ours_then_theirs", { ours: 0.03, theirs: 0.03, ours_then_theirs: 0.9, theirs_then_ours: 0.04 }), DEFAULT_THRESHOLDS, ); expect(decision.autoApply).toBe(true); expect(decision.hunks[0].selected).toBe("ours_then_theirs"); }); test("blocks low confidence, security risk, needs-human and unknown options", () => { const close = { ours: 0.5, theirs: 0.4, ours_then_theirs: 0.05, theirs_then_ours: 0.05 }; expect(decideFile(inputs, response("ours", close), DEFAULT_THRESHOLDS).autoApply).toBe(false); const sure = { ours: 0.95, theirs: 0.03, ours_then_theirs: 0.01, theirs_then_ours: 0.01 }; expect(decideFile(inputs, response("ours", sure, [0.1, 0.3, 0.6]), DEFAULT_THRESHOLDS).autoApply).toBe(false); expect(decideFile(inputs, response("ours", sure, [0.6, 0.1, 0.3]), DEFAULT_THRESHOLDS).autoApply).toBe(false); expect(decideFile(inputs, response("ours", sure, undefined, 0.8), DEFAULT_THRESHOLDS).autoApply).toBe(false); expect(decideFile(inputs, response("bogus", { bogus: 0.99 }), DEFAULT_THRESHOLDS).autoApply).toBe(false); expect(decideFile(inputs, { answers: {} }, DEFAULT_THRESHOLDS).autoApply).toBe(false); }); test("respects configured thresholds", () => { const probs = { ours: 0.7, theirs: 0.1, ours_then_theirs: 0.1, theirs_then_ours: 0.1 }; expect(decideFile(inputs, response("ours", probs), DEFAULT_THRESHOLDS).autoApply).toBe(false); expect(decideFile(inputs, response("ours", probs), { confidence: 0.6, margin: 0.3 }).autoApply).toBe(true); }); test("one Clef request per file carries a choice per hunk plus risk and needs_human", () => { const request = buildClefRequest({ model: "clef", path: "a.ts", title: "t", body: "b", hunks: [...inputs, ...inputs], vault: new Map(), }); expect(Object.keys(request.questions)).toEqual(["hunk_0", "hunk_1", "risk", "needs_human"]); expect(request.questions.risk.type).toBe("score"); expect(request.questions.needs_human.type).toBe("noul"); }); }); const planOptions = { ...plain, provider: mockProvider(), thresholds: DEFAULT_THRESHOLDS, limits: DEFAULT_LIMITS, clefModel: "clef" as const, meter: noopMeter, }; describe("planMergeFix", () => { const options = planOptions; test("plans an auto-applicable fix with the mock provider", async () => { const plan = await planMergeFix([conflictFor(base, ours, theirs)], options); expect(plan.autoApply).toBe(true); expect(plan.calls).toBe(3); const file = plan.files[0]; if (file.status !== "planned") throw new Error("expected a plan"); expect(file.resolution).toBe("a\nB-ours\nB-theirs\nc\nd\ne\n"); expect(file.hunks[0].candidates).toHaveLength(4); expect(file.hunks[0].selected).toBe("ours_then_theirs"); }); test("security-looking changes are not auto-applied", async () => { const conflict = conflictFor("x\ncheck()\ny\n", "x\ncheckAuth(user)\ny\n", "x\ncheckPermission(user)\ny\n"); const plan = await planMergeFix([conflict], options); expect(plan.autoApply).toBe(false); const file = plan.files[0]; if (file.status === "planned") expect(file.decision.reasons).toContain("Changes look security-sensitive"); }); test("bounds files, hunk size and model calls", async () => { const big = "z".repeat(5000) + "\n"; const files = await planMergeFix([conflictFor(base, ours, theirs), conflictFor(base, big, theirs, "big.ts")], { ...options, limits: { ...DEFAULT_LIMITS, maxFiles: 1 }, }); expect(files.files[1]).toMatchObject({ status: "skipped" }); const sized = await planMergeFix([conflictFor(base, big, theirs, "big.ts")], options); expect(sized.files[0]).toMatchObject({ status: "skipped", reason: "A conflict hunk is too large" }); expect(sized.calls).toBe(0); const capped = await planMergeFix([conflictFor(base, ours, theirs)], { ...options, limits: { ...DEFAULT_LIMITS, maxModelCalls: 1 }, }); expect(capped.calls).toBeLessThanOrEqual(1); expect(capped.files[0].status).toBe("planned"); }); test("meter can stop the run", async () => { const stop = new Error("cap"); await expect( planMergeFix([conflictFor(base, ours, theirs)], { ...options, meter: async () => Promise.reject(stop) }), ).rejects.toBe(stop); }); test("timed-out generation falls back to deterministic candidates", async () => { const slow: MergeFixProvider = { generate: () => new Promise(() => {}), decide: (request: ClefRequest) => mockProvider().decide(request), }; const plan = await planMergeFix([conflictFor(base, ours, theirs)], { ...options, provider: slow, limits: { ...DEFAULT_LIMITS, callTimeoutMs: 10 }, }); const file = plan.files[0]; if (file.status !== "planned") throw new Error("expected a plan"); expect(file.hunks[0].candidates.every((candidate) => candidate.source !== "ai")).toBe(true); }); test("AI candidates with lines from neither side are suggested, not applied", async () => { const inner = mockProvider(); const inventive: MergeFixProvider = { generate: async () => ({ text: "```merge\nrm -rf /\n```", usage: { inputTokens: 1, outputTokens: 1 } }), decide: (request) => inner.decide(request), }; const hunk = { base: "b\n", ours: "x1\nx2\n", theirs: "y1\n" }; expect(isInterleaving("x1\ny1\nx2\n", hunk)).toBe(true); expect(isInterleaving("y1\nx1\nx2\n", hunk)).toBe(true); expect(isInterleaving("x2\nx1\ny1\n", hunk)).toBe(false); expect(isInterleaving("x1\nx2\n", hunk)).toBe(false); expect(isInterleaving("x1\nx2\ny1\nb\n", hunk)).toBe(false); const plan = await planMergeFix([conflictFor(base, ours, theirs)], { ...options, provider: inventive }); expect(plan.autoApply).toBe(false); const file = plan.files[0]; if (file.status !== "planned") throw new Error("expected a plan"); expect(file.hunks[0].selected).toBe("ai_1"); expect(file.decision.reasons.join()).toContain("instead of combining"); }); test("malformed or missing Clef answers never auto-apply", async () => { const broken: MergeFixProvider = { generate: (input) => mockProvider().generate(input), decide: async () => ({ answers: { hunk_0: { type: "choice", choice: "ours" } } }) as unknown as ClefResponse, }; const plan = await planMergeFix([conflictFor(base, ours, theirs)], { ...options, provider: broken }); expect(plan.autoApply).toBe(false); const hung: MergeFixProvider = { generate: broken.generate, decide: () => new Promise(() => {}) }; const timed = await planMergeFix([conflictFor(base, ours, theirs)], { ...options, provider: hung, limits: { ...DEFAULT_LIMITS, callTimeoutMs: 10 }, }); expect(timed.files[0]).toMatchObject({ status: "skipped" }); expect(timed.autoApply).toBe(false); }); }); describe("proposals", () => { const safePlan = () => planMergeFix([conflictFor(base, ours, theirs)], planOptions); const twoFiles = () => planMergeFix([conflictFor(base, ours, theirs), conflictFor(base, ours, theirs, "src/other.ts")], planOptions); test("propose is the default mode; explicit requests and the repo opt-in choose auto", () => { expect(resolveMode(undefined, false)).toBe("propose"); expect(resolveMode(undefined, true)).toBe("auto"); expect(resolveMode("auto", false)).toBe("auto"); expect(resolveMode("propose", true)).toBe("propose"); }); test("propose mode never auto-applies, even when decide.ts says it is safe", async () => { const plan = await safePlan(); expect(plan.autoApply).toBe(true); expect(shouldAutoApply("propose", plan)).toBe(false); expect(shouldAutoApply("auto", plan)).toBe(true); }); test("auto mode falls back to a proposal when decide.ts reports reasons", async () => { const conflict = conflictFor("x\ncheck()\ny\n", "x\ncheckAuth(user)\ny\n", "x\ncheckPermission(user)\ny\n"); const plan = await planMergeFix([conflict], planOptions); expect(shouldAutoApply("auto", plan)).toBe(false); }); test("stores a diff from the head side to the proposed resolution", async () => { const file = (await safePlan()).files[0]; if (file.status !== "planned") throw new Error("expected a plan"); expect(file.diff).toContain("--- a/src/app.ts"); expect(file.diff).toContain("+B-theirs"); expect(file.diff).not.toContain("-B-ours"); }); test("selects all resolutions by default, or only the accepted paths", async () => { const plan = await twoFiles(); const all = selectResolutions(plan); expect(all.ok && all.resolutions.map((entry) => entry.path)).toEqual(["src/app.ts", "src/other.ts"]); const one = selectResolutions(plan, ["src/other.ts", "src/other.ts"]); expect(one.ok && one.resolutions).toEqual([{ path: "src/other.ts", content: "a\nB-ours\nB-theirs\nc\nd\ne\n" }]); expect(selectResolutions(plan, ["nope.ts"])).toEqual({ ok: false, error: "No resolution is proposed for nope.ts" }); expect(selectResolutions(plan, []).ok).toBe(false); expect(selectResolutions({ files: [], calls: 0, autoApply: false }).ok).toBe(false); }); test("a proposal is stale once the head or base moves", () => { const run = { head_sha: "h1", base_sha: "b1" }; expect(isStale(run, { headSha: "h1", baseSha: "b1" })).toBe(false); expect(isStale(run, { headSha: "h2", baseSha: "b1" })).toBe(true); expect(isStale(run, { headSha: "h1", baseSha: "b2" })).toBe(true); expect(isStale({ head_sha: "h1", base_sha: null }, { headSha: "h1", baseSha: "b1" })).toBe(true); }); });