import { describe, expect, test } from "bun:test"; import { mergeText } from "../git/merge3"; import type { MergeConflict } from "../git/merge"; import { assembleResolution, blockContext } from "./candidates"; import { DEFAULT_THRESHOLDS, decideFile, floorRisk } from "./decide"; import type { ClefRequest, ClefResponse } from "./decide"; import { mockProvider } from "./mock"; import { DEFAULT_LIMITS, planMergeFix } from "./plan"; import type { FixPlan, MergeFixProvider } from "./plan"; import { belowThreshold, shouldAutoApply } from "./proposal"; import { bracketsBalanced, candidateProblem } from "./validate"; const preamble = `// Package catalog lists the storefront's products. package catalog import "sort" type Product struct { \tID string \tName string \tPrice int } var products = []Product{ `; const tail = `} func All() []Product { \tsorted := append([]Product(nil), products...) \tsort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID }) \treturn sorted } `; const row = (id: string, price: number) => `\t{ID: "${id}", Name: "${id}", Price: ${price}},\n`; const earlier = ["beanie", "scarf", "gloves", "tote"].map((id, index) => row(id, 1000 + index)).join(""); const base = preamble + row("tee", 2000) + row("hoodie", 5000) + earlier + row("mug", 1200) + tail; const main = preamble + row("tee", 2000) + row("hoodie", 5000) + row("giftcard", 2500) + earlier + row("mug", 1200) + tail; const head = preamble + row("tee", 2000) + row("hoodie", 5000) + row("socks", 900) + earlier + row("mug", 1200) + tail; const path = "internal/catalog/catalog.go"; function storefront(): MergeConflict { const merged = mergeText(base, head, main, { ours: "socks", theirs: "main" }); return { path, kind: "content", content: merged.text, hunks: merged.hunks }; } /** What the bad "AI resolution 2" on acme/storefront#25 produced: the preamble pasted into the slice. */ const pasted = preamble.split("\n").slice(0, -1).join("\n") + "\n" + row("socks", 900) + row("giftcard", 2500); describe("storefront#25 hunk extraction", () => { test("the hunk is just the two insertions after hoodie, anchored there", () => { const conflict = storefront(); expect(conflict.hunks).toEqual([{ base: "", ours: row("socks", 900), theirs: row("giftcard", 2500) }]); const both = assembleResolution(conflict.content ?? "", conflict.hunks ?? [], [ row("socks", 900) + row("giftcard", 2500), ]); expect(both).toBe( preamble + row("tee", 2000) + row("hoodie", 5000) + row("socks", 900) + row("giftcard", 2500) + earlier + row("mug", 1200) + tail, ); const context = blockContext(conflict.content ?? "", 0); expect(context.before.endsWith(row("hoodie", 5000))).toBe(true); expect(context.after.startsWith(row("beanie", 1000))).toBe(true); }); test("an earlier insertion still sitting on one side keeps the base empty and the anchor right", () => { const short = preamble + row("hoodie", 5000) + row("mug", 1200) + tail; const ours = preamble + row("hoodie", 5000) + earlier + row("giftcard", 2500) + row("mug", 1200) + tail; const theirs = preamble + row("hoodie", 5000) + row("socks", 900) + row("mug", 1200) + tail; const merged = mergeText(short, ours, theirs, { ours: "a", theirs: "b" }); expect(merged.hunks).toEqual([{ base: "", ours: earlier + row("giftcard", 2500), theirs: row("socks", 900) }]); expect(merged.text.startsWith(preamble + row("hoodie", 5000) + "<<<<<<< a\n")).toBe(true); }); }); describe("candidate validation", () => { const conflict = storefront(); const input = { path, content: conflict.content ?? "", hunks: conflict.hunks ?? [], index: 0 }; test("accepts the correct resolution", () => { expect(candidateProblem(row("socks", 900) + row("giftcard", 2500), input)).toBeNull(); }); test("rejects the pasted preamble", () => { expect(candidateProblem(pasted, input)).not.toBeNull(); }); test("rejects markers, runaway growth, unbalanced braces and a second package clause", () => { expect(candidateProblem("<<<<<<< x\n", input)).toBe("contains conflict markers"); expect(candidateProblem(Array.from({ length: 20 }, (_, i) => row(`n${i}`, i)).join(""), input)).toBe( "is far larger than both sides together", ); expect(candidateProblem(`${row("socks", 900)}\t{ID: "x",\n`, input)).toBe("leaves brackets unbalanced"); expect(candidateProblem(`package other\n${row("socks", 900)}`, input)).toBe("declares the package more than once"); expect(candidateProblem(`import "sort"\n${row("socks", 900)}`, input)).not.toBeNull(); }); test("bracket balance skips strings, runes and comments", () => { expect(bracketsBalanced("x := \"{\" + `(` // )\n/* [ */ r := '}'\n")).toBe(true); expect(bracketsBalanced("f(a[1)]\n")).toBe(false); }); }); function provider(text: string, top: number, risk: [number, number, number]): MergeFixProvider { return { async generate() { return { text: `\`\`\`merge\n${text}\`\`\``, usage: { inputTokens: 1, outputTokens: 1 } }; }, async decide(request: ClefRequest): Promise { const ids = Object.keys((request.questions.hunk_0 as { criteria: Record }).criteria); const pick = ids.find((id) => id.startsWith("ai_")) ?? ids[0]; const rest = (1 - top) / Math.max(1, ids.length - 1); return { answers: { hunk_0: { type: "choice", choice: pick, probabilities: Object.fromEntries(ids.map((id) => [id, id === pick ? top : rest])), confidence: top, }, risk: { type: "score", score: 0, probabilities: { "0": risk[0], "1": risk[1], "2": risk[2] }, confidence: 1 }, needs_human: { type: "noul", noul: 0 }, }, }; }, }; } const options = (p: MergeFixProvider) => ({ title: "Add socks", body: "", provider: p, thresholds: DEFAULT_THRESHOLDS, limits: DEFAULT_LIMITS, clefModel: "clef" as const, meter: async () => {}, }); describe("storefront#25 end to end", () => { test("the pasted candidate is dropped, never offered", async () => { const plan = await planMergeFix([storefront()], options(provider(pasted, 0.32, [0.9, 0.05, 0.05]))); const file = plan.files[0]; if (file.status !== "planned") throw new Error(file.status); expect(file.hunks[0].candidates.some((candidate) => candidate.source === "ai")).toBe(false); expect(file.resolution ?? "").not.toContain("package catalog\npackage"); expect((file.resolution?.match(/^package /gm) ?? []).length).toBe(1); }); test("a below-threshold pick is not cosmetic, needs confirmation and never auto-applies", async () => { const plan = await planMergeFix( [storefront()], options(provider(row("socks", 900) + row("giftcard", 2500), 0.32, [0.9, 0.05, 0.05])), ); const file = plan.files[0]; if (file.status !== "planned") throw new Error(file.status); expect(file.decision.risk.level).toBe("logic"); expect(plan.autoApply).toBe(false); expect(shouldAutoApply("auto", plan)).toBe(false); expect(belowThreshold(plan, [path])).toEqual([path]); }); test("auto mode refuses a stored plan whose flag says yes but whose hunks are unsure", () => { const decision = decideFile([], { answers: {} }, DEFAULT_THRESHOLDS); const plan: FixPlan = { calls: 1, autoApply: true, files: [{ path, status: "planned", hunks: [], decision, resolution: "x\n" }], }; expect(shouldAutoApply("auto", plan)).toBe(false); }); test("a confident code change is labelled logic, a confident comment-only change stays cosmetic", () => { const hunk = { base: "", ours: "// a\n", theirs: "// b\n" }; const decision = decideFile( [{ hunk, candidates: [{ id: "both", source: "ours_then_theirs", label: "", text: "// a\n// b\n" }] }], { answers: { hunk_0: { type: "choice", choice: "both", probabilities: { both: 0.95 }, confidence: 0.95 }, risk: { type: "score", score: 0, probabilities: { "0": 0.9, "1": 0.1, "2": 0 }, confidence: 1 }, needs_human: { type: "noul", noul: 0 }, }, }, DEFAULT_THRESHOLDS, ); floorRisk(decision, [{ hunk, candidates: [] }], [{ id: "both", source: "ai", label: "", text: "// a\n// b\n" }]); expect(decision.risk.level).toBe("cosmetic"); floorRisk(decision, [{ hunk, candidates: [] }], [{ id: "x", source: "ai", label: "", text: "x := 1\n" }]); expect(decision.risk.level).toBe("logic"); }); test("the mock provider's resolution passes validation and assembles valid Go", async () => { const plan = await planMergeFix([storefront()], options(mockProvider())); const file = plan.files[0]; if (file.status !== "planned") throw new Error(file.status); expect(file.resolution).toBe( preamble + row("tee", 2000) + row("hoodie", 5000) + row("socks", 900) + row("giftcard", 2500) + earlier + row("mug", 1200) + tail, ); expect(bracketsBalanced(file.resolution ?? "")).toBe(true); }); });