Log in
scripts/make_conflict_corpus.py 596 lines · 22.7 KB · python Blame
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())