88e86b3faccd
Rebuild benchmarks as a clean single-root reposi
2 months ago
| 1 | #!/usr/bin/env python3
|
| 2 | """Generate the curated conflict branch-pair corpus (bench-conflict-corpus-v1).
|
| 3 |
|
| 4 | For each conflict kind a small git repository is created at <out>/<kind>/ with
|
| 5 | branch `ours` (checked out) and branch `theirs` diverging from `main`, built so
|
| 6 | that `git merge theirs` produces exactly the intended outcome: a conflict for
|
| 7 | every kind except adjacent-lines, which merges CLEAN but breaks an in-repo
|
| 8 | invariant (the "clean merge, broken build" case β check.py is committed on
|
| 9 | main and exits 1 against the merged tree).
|
| 10 |
|
| 11 | The resolution oracle is kept OUT of the repository content: it is written as
|
| 12 | a sidecar file <out>/<kind>.RESOLUTION.json (schema_version 1) recording the
|
| 13 | expected conflict outcome, the unmerged paths, the sha256 of the correct
|
| 14 | merged content for the primary conflicted path (plus the content itself, so
|
| 15 | oracles are self-contained), and β for adjacent-lines β the post-merge check
|
| 16 | command and its expected non-zero return code.
|
| 17 |
|
| 18 | Kinds: text-overlap-{small,medium,large}, adjacent-lines, rename-rename,
|
| 19 | rename-edit, delete-edit, binary-binary, lockfile (package-lock.json-style,
|
| 20 | the most common real agent conflict). Mined-real pairs arrive later through
|
| 21 | the GitGoodBench pipeline and are out of scope here.
|
| 22 |
|
| 23 | Determinism follows make_monorepo_fixture.py conventions: fixed identity,
|
| 24 | timestamps stepping from a fixed epoch via GIT_AUTHOR_DATE/GIT_COMMITTER_DATE,
|
| 25 | all content derived from the seed through hashed sub-RNGs, and git config
|
| 26 | isolated from the host. The same seed yields byte-identical repos and oracles.
|
| 27 |
|
| 28 | Every kind is self-tested after generation ("the pipe is an instrument"): the
|
| 29 | merge is run in a scratch clone and the conflict/clean outcome, unmerged path
|
| 30 | set, resolution sha, and (for adjacent-lines) the broken-build check are all
|
| 31 | asserted against the oracle; any mismatch exits 1.
|
| 32 |
|
| 33 | Exit codes: 0 ok, 1 usage/generation/self-test error, 3 git unavailable
|
| 34 | (skip-row honesty: the caller can record a skip instead of a bogus result).
|
| 35 |
|
| 36 | Usage:
|
| 37 | python3 scripts/make_conflict_corpus.py --out /tmp/conflicts \\
|
| 38 | --seed oakbench-conflicts-v1 [--kinds text-overlap-small,lockfile] [--quick]
|
| 39 | """
|
| 40 |
|
| 41 | from __future__ import annotations
|
| 42 |
|
| 43 | import argparse
|
| 44 | import base64
|
| 45 | import hashlib
|
| 46 | import json
|
| 47 | import os
|
| 48 | import random
|
| 49 | import shutil
|
| 50 | import subprocess
|
| 51 | import sys
|
| 52 | import tempfile
|
| 53 | from pathlib import Path
|
| 54 |
|
| 55 | GENERATOR_VERSION = "conflict-corpus-v1"
|
| 56 | SCHEMA_VERSION = 1
|
| 57 | IDENTITY_NAME = "Oak Bench"
|
| 58 | IDENTITY_EMAIL = "[email protected]"
|
| 59 | EPOCH = 1_700_000_000 # first commit timestamp; +60s per commit
|
| 60 | ORACLE_SUFFIX = ".RESOLUTION.json"
|
| 61 |
|
| 62 | KINDS = (
|
| 63 | "text-overlap-small",
|
| 64 | "text-overlap-medium",
|
| 65 | "text-overlap-large",
|
| 66 | "adjacent-lines",
|
| 67 | "rename-rename",
|
| 68 | "rename-edit",
|
| 69 | "delete-edit",
|
| 70 | "binary-binary",
|
| 71 | "lockfile",
|
| 72 | )
|
| 73 |
|
| 74 | # (full_lines, quick_lines, conflict_regions) per text-overlap size.
|
| 75 | TEXT_OVERLAP_SHAPES = {
|
| 76 | "text-overlap-small": (40, 12, 1),
|
| 77 | "text-overlap-medium": (400, 30, 3),
|
| 78 | "text-overlap-large": (4000, 80, 6),
|
| 79 | }
|
| 80 |
|
| 81 | WORDS = (
|
| 82 | "handler request response buffer stream parse encode decode commit tree "
|
| 83 | "branch merge index cache token batch worker queue retry timeout config "
|
| 84 | "schema field record column filter reduce visit walk node edge graph "
|
| 85 | "mount hydrate snapshot manifest oracle subject lane scenario operation"
|
| 86 | ).split()
|
| 87 |
|
| 88 | CHECK_PY = '''#!/usr/bin/env python3
|
| 89 | """Build invariant: len(ITEMS) must equal EXPECTED_COUNT in data.py."""
|
| 90 | import os
|
| 91 | import sys
|
| 92 |
|
| 93 | here = os.path.dirname(os.path.abspath(__file__))
|
| 94 | namespace = {}
|
| 95 | with open(os.path.join(here, "data.py")) as fh:
|
| 96 | exec(compile(fh.read(), "data.py", "exec"), namespace)
|
| 97 | items = namespace["ITEMS"]
|
| 98 | expected = namespace["EXPECTED_COUNT"]
|
| 99 | if len(items) != expected:
|
| 100 | print(f"BROKEN: len(ITEMS)={len(items)} != EXPECTED_COUNT={expected}")
|
| 101 | sys.exit(1)
|
| 102 | print("ok")
|
| 103 | '''
|
| 104 |
|
| 105 |
|
| 106 | def make_rng(seed: str, *scope: object) -> random.Random:
|
| 107 | digest = hashlib.sha256(":".join([seed, *map(str, scope)]).encode()).digest()
|
| 108 | return random.Random(int.from_bytes(digest[:8], "big"))
|
| 109 |
|
| 110 |
|
| 111 | def sha256_hex(data: bytes) -> str:
|
| 112 | return hashlib.sha256(data).hexdigest()
|
| 113 |
|
| 114 |
|
| 115 | def git_env(tick: int = 0) -> dict[str, str]:
|
| 116 | """Isolated, identity-fixed git environment with deterministic timestamps."""
|
| 117 | stamp = f"{EPOCH + 60 * tick} +0000"
|
| 118 | env = dict(os.environ)
|
| 119 | env.update(
|
| 120 | GIT_CONFIG_GLOBAL=os.devnull,
|
| 121 | GIT_CONFIG_NOSYSTEM="1",
|
| 122 | GIT_AUTHOR_NAME=IDENTITY_NAME,
|
| 123 | GIT_AUTHOR_EMAIL=IDENTITY_EMAIL,
|
| 124 | GIT_COMMITTER_NAME=IDENTITY_NAME,
|
| 125 | GIT_COMMITTER_EMAIL=IDENTITY_EMAIL,
|
| 126 | GIT_AUTHOR_DATE=stamp,
|
| 127 | GIT_COMMITTER_DATE=stamp,
|
| 128 | )
|
| 129 | return env
|
| 130 |
|
| 131 |
|
| 132 | def run_git(args: list[str], cwd: Path, tick: int = 0,
|
| 133 | check: bool = True) -> subprocess.CompletedProcess:
|
| 134 | return subprocess.run(["git", *args], cwd=cwd, env=git_env(tick),
|
| 135 | check=check, capture_output=True, text=True)
|
| 136 |
|
| 137 |
|
| 138 | class Repo:
|
| 139 | """A scratch-built fixture repo with deterministic commits."""
|
| 140 |
|
| 141 | def __init__(self, path: Path):
|
| 142 | self.path = path
|
| 143 | self.tick = 0
|
| 144 | path.mkdir(parents=True, exist_ok=True)
|
| 145 | run_git(["init", "-q"], path)
|
| 146 | run_git(["symbolic-ref", "HEAD", "refs/heads/main"], path)
|
| 147 |
|
| 148 | def write(self, rel: str, data: bytes) -> None:
|
| 149 | target = self.path / rel
|
| 150 | target.parent.mkdir(parents=True, exist_ok=True)
|
| 151 | target.write_bytes(data)
|
| 152 |
|
| 153 | def commit(self, message: str) -> None:
|
| 154 | run_git(["add", "-A"], self.path, self.tick)
|
| 155 | run_git(["commit", "-q", "-m", message], self.path, self.tick)
|
| 156 | self.tick += 1
|
| 157 |
|
| 158 | def checkout(self, branch: str, create: bool = False) -> None:
|
| 159 | args = ["checkout", "-q"] + (["-b"] if create else []) + [branch]
|
| 160 | run_git(args, self.path, self.tick)
|
| 161 |
|
| 162 | def mv(self, src: str, dst: str) -> None:
|
| 163 | run_git(["mv", src, dst], self.path, self.tick)
|
| 164 |
|
| 165 | def rm(self, rel: str) -> None:
|
| 166 | run_git(["rm", "-q", rel], self.path, self.tick)
|
| 167 |
|
| 168 |
|
| 169 | def text_resolution(path: str, content: str) -> dict[str, object]:
|
| 170 | data = content.encode()
|
| 171 | return {"path": path, "content_sha256": sha256_hex(data), "content": content}
|
| 172 |
|
| 173 |
|
| 174 | def binary_resolution(path: str, data: bytes) -> dict[str, object]:
|
| 175 | return {"path": path, "content_sha256": sha256_hex(data),
|
| 176 | "content_b64": base64.b64encode(data).decode()}
|
| 177 |
|
| 178 |
|
| 179 | def source_lines(seed: str, kind: str, count: int) -> list[str]:
|
| 180 | rng = make_rng(seed, kind, "base")
|
| 181 | lines = [f"# {GENERATOR_VERSION} {kind}"]
|
| 182 | for i in range(count - 1):
|
| 183 | w = [rng.choice(WORDS) for _ in range(4)]
|
| 184 | lines.append(f"{w[0]}_{i} = {w[1]}({w[2]}, {w[3]}) # line {i}")
|
| 185 | return lines
|
| 186 |
|
| 187 |
|
| 188 | def variant_line(seed: str, kind: str, side: str, region: int) -> str:
|
| 189 | rng = make_rng(seed, kind, side, region)
|
| 190 | w = [rng.choice(WORDS) for _ in range(3)]
|
| 191 | return f"{side}_{region} = {w[0]}({w[1]}, {w[2]}) # {side} rewrite r{region}"
|
| 192 |
|
| 193 |
|
| 194 | def region_positions(seed: str, kind: str, count: int, regions: int) -> list[int]:
|
| 195 | segment = max(1, (count - 4) // regions)
|
| 196 | rng = make_rng(seed, kind, "regions")
|
| 197 | return [min(count - 2, 2 + i * segment + rng.randrange(max(1, segment - 2)))
|
| 198 | for i in range(regions)]
|
| 199 |
|
| 200 |
|
| 201 | # --- kind builders ----------------------------------------------------------
|
| 202 | # Each builder populates an initialized Repo (leaving HEAD on `ours` with a
|
| 203 | # clean tree) and returns the oracle body for the sidecar.
|
| 204 |
|
| 205 |
|
| 206 | def build_text_overlap(kind: str, repo: Repo, seed: str, quick: bool) -> dict[str, object]:
|
| 207 | full, quick_lines, regions = TEXT_OVERLAP_SHAPES[kind]
|
| 208 | count = quick_lines if quick else full
|
| 209 | path = "src/module.py"
|
| 210 | base = source_lines(seed, kind, count)
|
| 211 | positions = region_positions(seed, kind, count, regions)
|
| 212 |
|
| 213 | repo.write(path, ("\n".join(base) + "\n").encode())
|
| 214 | repo.commit(f"base: {kind} ({count} lines, {regions} regions)")
|
| 215 |
|
| 216 | ours = list(base)
|
| 217 | theirs = list(base)
|
| 218 | resolved = list(base)
|
| 219 | for ridx, pos in enumerate(positions):
|
| 220 | ours[pos] = variant_line(seed, kind, "ours", ridx)
|
| 221 | theirs[pos] = variant_line(seed, kind, "theirs", ridx)
|
| 222 | # Each earlier region inserted one extra line into `resolved`.
|
| 223 | resolved[pos + ridx:pos + ridx + 1] = [ours[pos], theirs[pos]]
|
| 224 |
|
| 225 | repo.checkout("ours", create=True)
|
| 226 | repo.write(path, ("\n".join(ours) + "\n").encode())
|
| 227 | repo.commit("ours: rewrite conflict regions")
|
| 228 | repo.checkout("main")
|
| 229 | repo.checkout("theirs", create=True)
|
| 230 | repo.write(path, ("\n".join(theirs) + "\n").encode())
|
| 231 | repo.commit("theirs: rewrite conflict regions")
|
| 232 | repo.checkout("ours")
|
| 233 |
|
| 234 | return {
|
| 235 | "expected_conflict": True,
|
| 236 | "conflicted_paths": [path],
|
| 237 | "resolution": text_resolution(path, "\n".join(resolved) + "\n"),
|
| 238 | "post_merge_check": None,
|
| 239 | }
|
| 240 |
|
| 241 |
|
| 242 | def build_adjacent_lines(kind: str, repo: Repo, seed: str, quick: bool) -> dict[str, object]:
|
| 243 | rng = make_rng(seed, kind, "items")
|
| 244 | n = 8
|
| 245 | items = [f"{rng.choice(WORDS)}-{rng.randrange(1000)}" for _ in range(n)]
|
| 246 | ours_item = f"ours-{make_rng(seed, kind, 'ours').choice(WORDS)}"
|
| 247 | theirs_item = f"theirs-{make_rng(seed, kind, 'theirs').choice(WORDS)}"
|
| 248 |
|
| 249 | def data_py(entries: list[str], expected: int) -> str:
|
| 250 | body = "\n".join(f' "{e}",' for e in entries)
|
| 251 | return f"ITEMS = [\n{body}\n]\nEXPECTED_COUNT = {expected}\n"
|
| 252 |
|
| 253 | repo.write("data.py", data_py(items, n).encode())
|
| 254 | repo.write("check.py", CHECK_PY.encode())
|
| 255 | repo.commit("base: data.py invariant guarded by check.py")
|
| 256 |
|
| 257 | # ours prepends + bumps the count; theirs appends + bumps the count.
|
| 258 | # The count bumps are identical, the list edits do not overlap, so the
|
| 259 | # merge is CLEAN β but the merged list has n+2 items vs EXPECTED_COUNT n+1.
|
| 260 | repo.checkout("ours", create=True)
|
| 261 | repo.write("data.py", data_py([ours_item] + items, n + 1).encode())
|
| 262 | repo.commit("ours: prepend item, bump count")
|
| 263 | repo.checkout("main")
|
| 264 | repo.checkout("theirs", create=True)
|
| 265 | repo.write("data.py", data_py(items + [theirs_item], n + 1).encode())
|
| 266 | repo.commit("theirs: append item, bump count")
|
| 267 | repo.checkout("ours")
|
| 268 |
|
| 269 | resolved = data_py([ours_item] + items + [theirs_item], n + 2)
|
| 270 | return {
|
| 271 | "expected_conflict": False,
|
| 272 | "conflicted_paths": [],
|
| 273 | "resolution": text_resolution("data.py", resolved),
|
| 274 | "post_merge_check": {
|
| 275 | "command": ["python3", "check.py"],
|
| 276 | "expected_returncode_clean_merge": 1,
|
| 277 | },
|
| 278 | }
|
| 279 |
|
| 280 |
|
| 281 | def build_rename_rename(kind: str, repo: Repo, seed: str, quick: bool) -> dict[str, object]:
|
| 282 | content = "\n".join(source_lines(seed, kind, 16)) + "\n"
|
| 283 | repo.write("src/util.py", content.encode())
|
| 284 | repo.commit("base: src/util.py")
|
| 285 |
|
| 286 | repo.checkout("ours", create=True)
|
| 287 | repo.mv("src/util.py", "src/helpers.py")
|
| 288 | repo.commit("ours: rename util.py -> helpers.py")
|
| 289 | repo.checkout("main")
|
| 290 | repo.checkout("theirs", create=True)
|
| 291 | repo.mv("src/util.py", "src/tools.py")
|
| 292 | repo.commit("theirs: rename util.py -> tools.py")
|
| 293 | repo.checkout("ours")
|
| 294 |
|
| 295 | return {
|
| 296 | "expected_conflict": True,
|
| 297 | "conflicted_paths": ["src/helpers.py", "src/tools.py", "src/util.py"],
|
| 298 | "resolution": text_resolution("src/helpers.py", content),
|
| 299 | "post_merge_check": None,
|
| 300 | }
|
| 301 |
|
| 302 |
|
| 303 | def build_rename_edit(kind: str, repo: Repo, seed: str, quick: bool) -> dict[str, object]:
|
| 304 | count = 12 if quick else 24
|
| 305 | base = source_lines(seed, kind, count)
|
| 306 | pos = region_positions(seed, kind, count, 1)[0]
|
| 307 | repo.write("src/config.py", ("\n".join(base) + "\n").encode())
|
| 308 | repo.commit("base: src/config.py")
|
| 309 |
|
| 310 | ours = list(base)
|
| 311 | ours[pos] = variant_line(seed, kind, "ours", 0)
|
| 312 | theirs = list(base)
|
| 313 | theirs[pos] = variant_line(seed, kind, "theirs", 0)
|
| 314 | resolved = list(base)
|
| 315 | resolved[pos:pos + 1] = [ours[pos], theirs[pos]]
|
| 316 |
|
| 317 | repo.checkout("ours", create=True)
|
| 318 | repo.mv("src/config.py", "src/settings.py")
|
| 319 | repo.write("src/settings.py", ("\n".join(ours) + "\n").encode())
|
| 320 | repo.commit("ours: rename config.py -> settings.py and edit")
|
| 321 | repo.checkout("main")
|
| 322 | repo.checkout("theirs", create=True)
|
| 323 | repo.write("src/config.py", ("\n".join(theirs) + "\n").encode())
|
| 324 | repo.commit("theirs: edit config.py in place")
|
| 325 | repo.checkout("ours")
|
| 326 |
|
| 327 | return {
|
| 328 | "expected_conflict": True,
|
| 329 | "conflicted_paths": ["src/settings.py"],
|
| 330 | "resolution": text_resolution("src/settings.py", "\n".join(resolved) + "\n"),
|
| 331 | "post_merge_check": None,
|
| 332 | }
|
| 333 |
|
| 334 |
|
| 335 | def build_delete_edit(kind: str, repo: Repo, seed: str, quick: bool) -> dict[str, object]:
|
| 336 | count = 12 if quick else 24
|
| 337 | base = source_lines(seed, kind, count)
|
| 338 | pos = region_positions(seed, kind, count, 1)[0]
|
| 339 | repo.write("src/legacy.py", ("\n".join(base) + "\n").encode())
|
| 340 | repo.commit("base: src/legacy.py")
|
| 341 |
|
| 342 | theirs = list(base)
|
| 343 | theirs[pos] = variant_line(seed, kind, "theirs", 0)
|
| 344 |
|
| 345 | repo.checkout("ours", create=True)
|
| 346 | repo.rm("src/legacy.py")
|
| 347 | repo.commit("ours: delete legacy.py")
|
| 348 | repo.checkout("main")
|
| 349 | repo.checkout("theirs", create=True)
|
| 350 | repo.write("src/legacy.py", ("\n".join(theirs) + "\n").encode())
|
| 351 | repo.commit("theirs: edit legacy.py")
|
| 352 | repo.checkout("ours")
|
| 353 |
|
| 354 | # Correct resolution: the edit proves the file is still live; keep theirs.
|
| 355 | return {
|
| 356 | "expected_conflict": True,
|
| 357 | "conflicted_paths": ["src/legacy.py"],
|
| 358 | "resolution": text_resolution("src/legacy.py", "\n".join(theirs) + "\n"),
|
| 359 | "post_merge_check": None,
|
| 360 | }
|
| 361 |
|
| 362 |
|
| 363 | def build_binary_binary(kind: str, repo: Repo, seed: str, quick: bool) -> dict[str, object]:
|
| 364 | size = 64 if quick else 4096
|
| 365 | # Leading NUL guarantees git treats the blob as binary.
|
| 366 | blob = lambda side: b"\x00\xff" + make_rng(seed, kind, side).randbytes(size) # noqa: E731
|
| 367 | repo.write("assets/logo.bin", blob("base"))
|
| 368 | repo.commit("base: assets/logo.bin")
|
| 369 |
|
| 370 | repo.checkout("ours", create=True)
|
| 371 | repo.write("assets/logo.bin", blob("ours"))
|
| 372 | repo.commit("ours: regenerate logo")
|
| 373 | repo.checkout("main")
|
| 374 | repo.checkout("theirs", create=True)
|
| 375 | theirs_bytes = blob("theirs")
|
| 376 | repo.write("assets/logo.bin", theirs_bytes)
|
| 377 | repo.commit("theirs: regenerate logo")
|
| 378 | repo.checkout("ours")
|
| 379 |
|
| 380 | # Binary has no textual merge; the canonical resolution takes theirs
|
| 381 | # (declared the newer asset export).
|
| 382 | return {
|
| 383 | "expected_conflict": True,
|
| 384 | "conflicted_paths": ["assets/logo.bin"],
|
| 385 | "resolution": binary_resolution("assets/logo.bin", theirs_bytes),
|
| 386 | "post_merge_check": None,
|
| 387 | }
|
| 388 |
|
| 389 |
|
| 390 | def _integrity(seed: str, name: str, version: str) -> str:
|
| 391 | digest = hashlib.sha512(f"{seed}:{name}@{version}".encode()).digest()
|
| 392 | return "sha512-" + base64.b64encode(digest).decode()
|
| 393 |
|
| 394 |
|
| 395 | def _lockfile_text(seed: str, pinned: dict[str, str], ranges: dict[str, str]) -> str:
|
| 396 | packages: dict[str, object] = {
|
| 397 | "": {"name": "fixture-app", "version": "1.0.0", "dependencies": ranges},
|
| 398 | }
|
| 399 | for name in sorted(pinned):
|
| 400 | version = pinned[name]
|
| 401 | packages[f"node_modules/{name}"] = {
|
| 402 | "version": version,
|
| 403 | "resolved": f"https://registry.example/{name}/-/{name}-{version}.tgz",
|
| 404 | "integrity": _integrity(seed, name, version),
|
| 405 | }
|
| 406 | doc = {
|
| 407 | "name": "fixture-app",
|
| 408 | "version": "1.0.0",
|
| 409 | "lockfileVersion": 3,
|
| 410 | "requires": True,
|
| 411 | "packages": packages,
|
| 412 | }
|
| 413 | return json.dumps(doc, indent=2) + "\n"
|
| 414 |
|
| 415 |
|
| 416 | def build_lockfile(kind: str, repo: Repo, seed: str, quick: bool) -> dict[str, object]:
|
| 417 | ranges = {"alpha-core": "^1.4.0", "pad-utils": "^1.0.0", "zeta-utils": "^2.1.0"}
|
| 418 | base_pins = {"alpha-core": "1.4.2", "pad-utils": "1.1.0", "zeta-utils": "2.1.3"}
|
| 419 | package_json = json.dumps(
|
| 420 | {"name": "fixture-app", "version": "1.0.0", "dependencies": ranges},
|
| 421 | indent=2) + "\n"
|
| 422 |
|
| 423 | repo.write("package.json", package_json.encode())
|
| 424 | repo.write("package-lock.json", _lockfile_text(seed, base_pins, ranges).encode())
|
| 425 | repo.commit("base: package.json + package-lock.json")
|
| 426 |
|
| 427 | # Classic agent conflict: each side ran its own install and pinned a
|
| 428 | # different pad-utils version (version/resolved/integrity lines collide).
|
| 429 | repo.checkout("ours", create=True)
|
| 430 | repo.write("package-lock.json",
|
| 431 | _lockfile_text(seed, {**base_pins, "pad-utils": "1.2.0"}, ranges).encode())
|
| 432 | repo.commit("ours: lockfile pins pad-utils 1.2.0")
|
| 433 | repo.checkout("main")
|
| 434 | repo.checkout("theirs", create=True)
|
| 435 | repo.write("package-lock.json",
|
| 436 | _lockfile_text(seed, {**base_pins, "pad-utils": "1.3.0"}, ranges).encode())
|
| 437 | repo.commit("theirs: lockfile pins pad-utils 1.3.0")
|
| 438 | repo.checkout("ours")
|
| 439 |
|
| 440 | # Correct resolution: the highest compatible pin wins (theirs, 1.3.0).
|
| 441 | resolved = _lockfile_text(seed, {**base_pins, "pad-utils": "1.3.0"}, ranges)
|
| 442 | return {
|
| 443 | "expected_conflict": True,
|
| 444 | "conflicted_paths": ["package-lock.json"],
|
| 445 | "resolution": text_resolution("package-lock.json", resolved),
|
| 446 | "post_merge_check": None,
|
| 447 | }
|
| 448 |
|
| 449 |
|
| 450 | BUILDERS = {
|
| 451 | "text-overlap-small": build_text_overlap,
|
| 452 | "text-overlap-medium": build_text_overlap,
|
| 453 | "text-overlap-large": build_text_overlap,
|
| 454 | "adjacent-lines": build_adjacent_lines,
|
| 455 | "rename-rename": build_rename_rename,
|
| 456 | "rename-edit": build_rename_edit,
|
| 457 | "delete-edit": build_delete_edit,
|
| 458 | "binary-binary": build_binary_binary,
|
| 459 | "lockfile": build_lockfile,
|
| 460 | }
|
| 461 |
|
| 462 |
|
| 463 | # --- self-test ---------------------------------------------------------------
|
| 464 |
|
| 465 |
|
| 466 | def resolution_bytes(resolution: dict[str, object]) -> bytes:
|
| 467 | if "content" in resolution:
|
| 468 | return str(resolution["content"]).encode()
|
| 469 | return base64.b64decode(str(resolution["content_b64"]))
|
| 470 |
|
| 471 |
|
| 472 | def verify_kind(repo_path: Path, oracle: dict[str, object]) -> list[str]:
|
| 473 | """Re-run the merge in a scratch clone and check it against the oracle."""
|
| 474 | problems: list[str] = []
|
| 475 | resolution = oracle["resolution"]
|
| 476 | if sha256_hex(resolution_bytes(resolution)) != resolution["content_sha256"]:
|
| 477 | problems.append("resolution content does not match its content_sha256")
|
| 478 |
|
| 479 | scratch_root = tempfile.mkdtemp(prefix="conflict-verify-")
|
| 480 | scratch = Path(scratch_root) / "clone"
|
| 481 | try:
|
| 482 | run_git(["clone", "--quiet", str(repo_path), str(scratch)], repo_path.parent)
|
| 483 | run_git(["branch", "theirs", "origin/theirs"], scratch)
|
| 484 |
|
| 485 | check = oracle["post_merge_check"]
|
| 486 | if check is not None:
|
| 487 | # Both sides must pass the check on their own branch first.
|
| 488 | for branch in ("theirs", "ours"):
|
| 489 | run_git(["checkout", "-q", branch], scratch)
|
| 490 | pre = subprocess.run(check["command"], cwd=scratch,
|
| 491 | capture_output=True, text=True)
|
| 492 | if pre.returncode != 0:
|
| 493 | problems.append(f"pre-merge check failed on branch {branch}")
|
| 494 |
|
| 495 | merge = run_git(["merge", "--no-edit", "theirs"], scratch,
|
| 496 | tick=1000, check=False)
|
| 497 | conflicted = merge.returncode != 0
|
| 498 | if conflicted != oracle["expected_conflict"]:
|
| 499 | problems.append(
|
| 500 | f"merge outcome mismatch: conflicted={conflicted}, "
|
| 501 | f"expected_conflict={oracle['expected_conflict']}")
|
| 502 | unmerged = run_git(["diff", "--name-only", "--diff-filter=U"], scratch)
|
| 503 | observed = sorted(p for p in unmerged.stdout.splitlines() if p)
|
| 504 | if observed != list(oracle["conflicted_paths"]):
|
| 505 | problems.append(
|
| 506 | f"conflicted paths mismatch: observed={observed}, "
|
| 507 | f"oracle={oracle['conflicted_paths']}")
|
| 508 |
|
| 509 | if check is not None and not conflicted:
|
| 510 | post = subprocess.run(check["command"], cwd=scratch,
|
| 511 | capture_output=True, text=True)
|
| 512 | if post.returncode != check["expected_returncode_clean_merge"]:
|
| 513 | problems.append(
|
| 514 | f"post-merge check returned {post.returncode}, expected "
|
| 515 | f"{check['expected_returncode_clean_merge']} (build should break)")
|
| 516 | finally:
|
| 517 | shutil.rmtree(scratch_root, ignore_errors=True)
|
| 518 | return problems
|
| 519 |
|
| 520 |
|
| 521 | # --- entry point -------------------------------------------------------------
|
| 522 |
|
| 523 |
|
| 524 | def generate_kind(out: Path, kind: str, seed: str, quick: bool) -> dict[str, object]:
|
| 525 | repo = Repo(out / kind)
|
| 526 | body = BUILDERS[kind](kind, repo, seed, quick)
|
| 527 | oracle = {
|
| 528 | "schema_version": SCHEMA_VERSION,
|
| 529 | "kind": kind,
|
| 530 | "seed": seed,
|
| 531 | "generator_version": GENERATOR_VERSION,
|
| 532 | "expected_conflict": body["expected_conflict"],
|
| 533 | "conflicted_paths": body["conflicted_paths"],
|
| 534 | "resolution": body["resolution"],
|
| 535 | "post_merge_check": body["post_merge_check"],
|
| 536 | }
|
| 537 | sidecar = out / f"{kind}{ORACLE_SUFFIX}"
|
| 538 | sidecar.write_text(json.dumps(oracle, indent=2) + "\n")
|
| 539 | return oracle
|
| 540 |
|
| 541 |
|
| 542 | def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
| 543 | ap = argparse.ArgumentParser(
|
| 544 | description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 545 | ap.add_argument("--out", type=Path, required=True,
|
| 546 | help="corpus root; one repo per kind plus oracle sidecars")
|
| 547 | ap.add_argument("--seed", required=True)
|
| 548 | ap.add_argument("--kinds", default=",".join(KINDS),
|
| 549 | help=f"comma-separated subset of: {', '.join(KINDS)}")
|
| 550 | ap.add_argument("--quick", action="store_true", help="tiny sizes for tests")
|
| 551 | return ap.parse_args(argv)
|
| 552 |
|
| 553 |
|
| 554 | def main(argv: list[str] | None = None) -> int:
|
| 555 | args = parse_args(argv)
|
| 556 | if shutil.which("git") is None:
|
| 557 | print("make_conflict_corpus: git not found on PATH; cannot generate "
|
| 558 | "fixture β record this run as skipped, not failed", file=sys.stderr)
|
| 559 | return 3
|
| 560 |
|
| 561 | kinds = [k.strip() for k in args.kinds.split(",") if k.strip()]
|
| 562 | unknown = [k for k in kinds if k not in BUILDERS]
|
| 563 | if unknown or not kinds:
|
| 564 | print(f"unknown kinds: {', '.join(unknown) or '(none requested)'}; "
|
| 565 | f"valid: {', '.join(KINDS)}", file=sys.stderr)
|
| 566 | return 1
|
| 567 |
|
| 568 | out = args.out
|
| 569 | out.mkdir(parents=True, exist_ok=True)
|
| 570 | for kind in kinds:
|
| 571 | kind_dir = out / kind
|
| 572 | if kind_dir.exists() and any(kind_dir.iterdir()):
|
| 573 | print(f"refusing to write into non-empty {kind_dir}", file=sys.stderr)
|
| 574 | return 1
|
| 575 |
|
| 576 | failures = 0
|
| 577 | for kind in kinds:
|
| 578 | oracle = generate_kind(out, kind, args.seed, args.quick)
|
| 579 | problems = verify_kind(out / kind, oracle)
|
| 580 | if problems:
|
| 581 | failures += 1
|
| 582 | for problem in problems:
|
| 583 | print(f"{kind}: SELF-TEST FAILED: {problem}", file=sys.stderr)
|
| 584 | else:
|
| 585 | print(f"{kind}: expected_conflict={oracle['expected_conflict']} "
|
| 586 | f"conflicted_paths={len(oracle['conflicted_paths'])} verified")
|
| 587 |
|
| 588 | if failures:
|
| 589 | print(f"{failures} kind(s) failed self-test", file=sys.stderr)
|
| 590 | return 1
|
| 591 | print(f"{GENERATOR_VERSION} seed={args.seed} kinds={len(kinds)} -> {out}")
|
| 592 | return 0
|
| 593 |
|
| 594 |
|
| 595 | if __name__ == "__main__":
|
| 596 | raise SystemExit(main())
|