Viewing files at commit ef78687eef1aCommit detailsView latest

suites/merge/mergesuite/runner.py 414 lines · 20.1 KB · python
1
"""Run scenarios: git merge-ort oracle vs Oak's merge engine, classify, apply merge laws."""
2
from __future__ import annotations
3
4
import concurrent.futures
5
import hashlib
6
import itertools
7
import json
8
import os
9
import re
10
import fnmatch
11
from pathlib import Path
12
import queue
13
import shutil
14
import subprocess
15
import time
16
import traceback
17
from typing import Callable, Dict, List, Optional
18
19
from . import classify as cl
20
from .gitops import Git, git_conflicts, prepare_git_home
21
from .oak_adapter import (LocalOakServer, PATH_DESCRIPTION, PATH_EXERCISED, oak_env,
22
                          run_pull_sync)
23
24
# Row/reason schema. Bump whenever the reasons a row can carry change, so a
25
# resummarize of an older run (whose rows could not carry the new reasons) is
26
# detected instead of reporting known bugs as "NOT reproduced (fixed?)".
27
#   1: initial (P3 landing, main 5dd811a1)
28
#   2: an expectation-violated row also carries its law reasons; rows without a
29
#      unique merge base carry the base-free law checks (law_base_free)
30
SCHEMA_VERSION = 2
31
32
33
def stale_schema_rows(rows: List[dict]) -> Dict[str, int]:
34
    """{version: count} of rows not written under the current SCHEMA_VERSION ('missing' when absent)."""
35
    out: Dict[str, int] = {}
36
    for r in rows:
37
        version = r.get("merge_suite_schema_version")
38
        if version != SCHEMA_VERSION:
39
            key = "missing" if version is None else str(version)
40
            out[key] = out.get(key, 0) + 1
41
    return out
42
43
44
def file_sha256(path: str) -> Optional[str]:
45
    try:
46
        return hashlib.sha256(Path(path).read_bytes()).hexdigest()
47
    except OSError:
48
        return None
49
50
51
def tree_digest(tree: Optional[dict]) -> Optional[str]:
52
    if tree is None:
53
        return None
54
    return hashlib.sha256(json.dumps(sorted(tree.items())).encode()).hexdigest()
55
56
57
def tool_version(binary: str, *args: str) -> Optional[str]:
58
    try:
59
        return subprocess.run([binary, *args], capture_output=True, text=True, timeout=30).stdout.strip() or None
60
    except OSError:
61
        return None
62
63
64
def git_oracle(git: Git, direction: dict) -> dict:
65
    merge = git.merge_tree(direction["oracle"]["ours"], direction["oracle"]["theirs"])
66
    return {"clean": merge["clean"], "tree": merge["tree"], "conflicts": git_conflicts(merge),
67
            "messages": [m for m in merge["messages"] if m["type"].startswith("CONFLICT")][:20]}
68
69
70
def text_evidence(git: Git, trees: dict, oak_tree: dict, oak_conf: set, case_dir: Path,
71
                  base: dict, ours: dict, theirs: dict) -> Dict[str, dict]:
72
    """Bytes for paths both sides changed that Oak text-merged (needed by the no-lost-change law)."""
73
    out = {}
74
    for path, value in oak_tree.items():
75
        if path in oak_conf:
76
            continue
77
        b, o, t = base.get(path), ours.get(path), theirs.get(path)
78
        if o is None or t is None or o == t or o == b or t == b or value in (b, o, t):
79
            continue
80
81
        def blob(commit):
82
            p = git.run("cat-file", "-p", f"{commit}:{path}", check=False)
83
            return p.stdout if p.returncode == 0 else b""
84
        merged_path = case_dir / "ours" / path
85
        if merged_path.is_symlink():
86
            merged = os.fsencode(os.readlink(merged_path))  # symlink "content" is its target
87
        else:
88
            merged = merged_path.read_bytes() if merged_path.is_file() else b""
89
        out[path] = {"base": blob(trees["base"]) if b else b"", "ours": blob(trees["ours"]),
90
                     "theirs": blob(trees["theirs"]), "merged": merged}
91
    return out
92
93
94
class Suite:
95
    def __init__(self, workdir: Path, oak_bin: str, git_bin: str, jobs: int = 4,
96
                 keep_equal: bool = False, run_id: Optional[str] = None):
97
        self.workdir, self.oak_bin, self.git_bin = workdir, oak_bin, git_bin
98
        self.jobs, self.keep_equal = max(1, jobs), keep_equal
99
        self.run_id = run_id or time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
100
        self.git_home = workdir / "git-home"
101
        prepare_git_home(self.git_home, git_bin)
102
        self.homes: "queue.Queue[Path]" = queue.Queue()
103
        for i in range(self.jobs):
104
            home = workdir / "oak-homes" / f"h{i}"
105
            home.mkdir(parents=True, exist_ok=True)
106
            prepare_git_home(home, git_bin)
107
            self.homes.put(home)
108
        self.meta = {
109
            "oak": {"bin": oak_bin, "sha256": file_sha256(shutil.which(oak_bin) or oak_bin),
110
                    "version": tool_version(oak_bin, "--version"),
111
                    "path_exercised": PATH_EXERCISED, "path_description": PATH_DESCRIPTION},
112
            "git": {"bin": git_bin, "sha256": file_sha256(git_bin), "version": tool_version(git_bin, "--version"),
113
                    "role": "reference oracle (merge-ort via merge-tree --write-tree) and fixture builder"},
114
        }
115
        self._keys = itertools.count(1)
116
        self.bugs = load_known_bugs()
117
118
    def run(self, scenarios: List[dict], emit: Callable[[dict], None]) -> List[dict]:
119
        server_env = oak_env(self.workdir / "oak-homes" / "server", self.git_bin)
120
        (self.workdir / "oak-homes" / "server").mkdir(parents=True, exist_ok=True)
121
        server = LocalOakServer(self.workdir, self.oak_bin, server_env)
122
        rows: List[dict] = []
123
        try:
124
            with concurrent.futures.ThreadPoolExecutor(self.jobs) as pool:
125
                futures = [pool.submit(self._scenario, s, server) for s in scenarios]
126
                for fut in concurrent.futures.as_completed(futures):
127
                    for row in fut.result():
128
                        rows.append(row)
129
                        emit(row)
130
        finally:
131
            server.close()
132
        return rows
133
134
    def _next_key(self) -> str:
135
        return f"suite/m{next(self._keys):05d}"
136
137
    def _scenario(self, scenario: dict, server: LocalOakServer) -> List[dict]:
138
        home = self.homes.get()
139
        try:
140
            return self._scenario_rows(scenario, server, home)
141
        finally:
142
            self.homes.put(home)
143
144
    def _scenario_rows(self, scenario: dict, server: LocalOakServer, home: Path) -> List[dict]:
145
        git = Git(scenario["repo"], self.git_bin, self.git_home)
146
        safe = scenario["id"].replace("/", "__")
147
        scen_dir = self.workdir / "cases" / safe
148
        rows, results = [], {}
149
        bare = scen_dir / "src.git"
150
        for direction in scenario["directions"]:
151
            start = time.monotonic()
152
            case_dir = scen_dir / direction["name"]
153
            row = self._base_row(scenario, direction)
154
            oak_result, git_result, harness = None, None, None
155
            base, ours, theirs = None, {}, {}
156
            try:
157
                if scenario.get("unsupported") is None:
158
                    git_result = git_oracle(git, direction)
159
                    trees = direction["trees"]
160
                    unique_base = trees.get("base") and len(direction.get("merge_bases") or [1]) == 1
161
                    base = git.tree(trees["base"]) if unique_base else None
162
                    ours, theirs = git.tree(trees["ours"]), git.tree(trees["theirs"])
163
                    if not bare.exists():
164
                        self._make_bare(git, bare, scenario)
165
                    if case_dir.exists():
166
                        shutil.rmtree(case_dir)
167
                    case_dir.mkdir(parents=True)
168
                    env = oak_env(home, self.git_bin)
169
                    oak_result = run_pull_sync(server, self.oak_bin, env, Git(bare, self.git_bin, self.git_home),
170
                                               bare, case_dir, self._next_key(), direction["plan"])
171
            except Exception as exc:  # instrument failure, never an Oak verdict
172
                harness = f"{type(exc).__name__}: {exc}"
173
                row["traceback"] = traceback.format_exc()[-2000:]
174
            texts = None
175
            if oak_result and oak_result.get("tree") is not None and git_result:
176
                texts = text_evidence(git, direction["trees"], oak_result["tree"],
177
                                      set(oak_result.get("conflicts") or {}), case_dir, base or {}, ours, theirs)
178
            verdict = cl.classify(git_result, oak_result, base, ours, theirs,
179
                                  unsupported=scenario.get("unsupported"), harness_error=harness,
180
                                  expect=direction.get("expect"), texts=texts)
181
            row.update(self._result_fields(git_result, oak_result, verdict))
182
            row["known_bug"] = match_known_bug(row, self.bugs)
183
            if direction.get("intent_note"):
184
                row["intent_note"] = direction["intent_note"]
185
            row["elapsed_ms"] = round((time.monotonic() - start) * 1000)
186
            row["case_dir"] = str(case_dir) if case_dir.exists() else None
187
            if verdict["class"] == "equal-to-git" and not self.keep_equal and case_dir.exists():
188
                shutil.rmtree(case_dir, ignore_errors=True)
189
                row["case_dir"] = None
190
            results[direction["name"]] = oak_result
191
            rows.append(row)
192
        if len(rows) == 2:
193
            sym = cl.symmetry(*(results[d["name"]] for d in scenario["directions"]))
194
            for row in rows:
195
                row["laws"]["symmetric"] = None if sym is None else sym["holds"]
196
                if sym and not sym["holds"]:
197
                    row.setdefault("evidence", {})["symmetry"] = sym
198
        if not self.keep_equal and all(r["class"] == "equal-to-git" for r in rows):
199
            shutil.rmtree(scen_dir, ignore_errors=True)
200
        return rows
201
202
    def _make_bare(self, git: Git, bare: Path, scenario: dict) -> None:
203
        """Bare mirror holding every plan commit under a ref (builder commits are unreferenced;
204
        `oak clone <x.git>` imports from the mirror's HEAD, which the adapter points per step)."""
205
        wanted = set()
206
        for d in scenario["directions"]:
207
            plan = d["plan"]
208
            wanted.update(x for x in (plan.get("main"), plan.get("fork"), plan.get("final_stage")) if x)
209
            for step in plan["steps"]:
210
                wanted.update(x for x in (step.get("tree"), step.get("stage")) if x)
211
        ns = "refs/mergesuite/" + hashlib.sha256(scenario["id"].encode()).hexdigest()[:16]
212
        for i, commit in enumerate(sorted(wanted)):
213
            git.run("update-ref", f"{ns}/keep{i}", commit)
214
        bare.parent.mkdir(parents=True, exist_ok=True)
215
        Git(bare.parent, self.git_bin, self.git_home).run("init", "-q", "--bare", str(bare))
216
        Git(bare, self.git_bin, self.git_home).run("fetch", "-q", str(scenario["repo"]),
217
                                                   f"+{ns}/*:refs/mergesuite/*")
218
219
    def _base_row(self, scenario: dict, direction: dict) -> dict:
220
        return {
221
            "merge_suite_schema_version": SCHEMA_VERSION,
222
            "evidence_class": "diagnostic_merge_correctness",
223
            "run_id": self.run_id,
224
            "scenario": scenario["id"], "scenario_version": scenario["version"],
225
            "direction": direction["name"], "origin": scenario["origin"], "upstream": scenario["upstream"],
226
            "notes": scenario.get("notes") or None,
227
            "oak": self.meta["oak"], "git": self.meta["git"],
228
            "laws": {"no_lost_change": None, "idempotent": None, "symmetric": None,
229
                     "false_conflict": None, "missed_conflict": None, "prediction_agrees": None},
230
            "repro": ("python3 suites/merge/run_merge_suite.py --workdir <dir> --only "
231
                      f"'{scenario['id']}'"),
232
        }
233
234
    def _result_fields(self, git_result: Optional[dict], oak_result: Optional[dict], verdict: dict) -> dict:
235
        out = {"class": verdict["class"], "reasons": verdict.get("reasons", [])}
236
        if verdict.get("notes"):
237
            out["class_notes"] = verdict["notes"]
238
        evidence = dict(verdict.get("evidence") or {})
239
        laws = {"no_lost_change": None, "idempotent": None, "symmetric": None,
240
                "false_conflict": None, "missed_conflict": None, "prediction_agrees": None}
241
        if git_result:
242
            out["git_result"] = {"clean": git_result["clean"], "conflicts": git_result["conflicts"],
243
                                 "tree_digest": tree_digest(git_result["tree"]),
244
                                 "conflict_messages": git_result.get("messages", [])}
245
        if oak_result:
246
            out["oak_result"] = {
247
                "path_exercised": oak_result.get("path_exercised"),
248
                "clean": oak_result.get("clean"), "conflicts": oak_result.get("conflicts"),
249
                "tree_digest": tree_digest(oak_result.get("tree")),
250
                "pull_returncode": oak_result.get("pull_returncode"),
251
                "pull_receipt": oak_result.get("pull_receipt"),
252
                "prediction": oak_result.get("prediction"),
253
                "idempotence": oak_result.get("idempotence"),
254
                "worktree_matches_head": oak_result.get("worktree_matches_head"),
255
                "result_source": oak_result.get("result_source"),
256
                "conflict_worktree_stale_paths": oak_result.get("conflict_worktree_stale_paths") or None,
257
                "error": oak_result.get("error"), "error_kind": oak_result.get("error_kind"),
258
                "shim_log": oak_result.get("shim_log") or None,
259
            }
260
            if verdict["class"] not in ("equal-to-git",):
261
                out["oak_steps"] = [{k: s[k] for k in ("label", "argv", "returncode", "ms")}
262
                                    | {"stderr_tail": s["stderr"][-300:]} for s in oak_result.get("steps", [])]
263
            if oak_result.get("clean") is not None and git_result:
264
                law = evidence.get("law_no_lost_change")
265
                laws["no_lost_change"] = None if law is None else not any(
266
                    law.get(k) for k in ("lost", "mode_lost", "markers", "markers_no_final_newline",
267
                                         "markers_symlink", "fabricated"))
268
                laws["idempotent"] = cl.idempotence_holds(oak_result)
269
                oak_conf = set(oak_result.get("conflicts") or {})
270
                git_conf = set(git_result["conflicts"])
271
                laws["false_conflict"] = bool(oak_conf - git_conf)
272
                laws["missed_conflict"] = bool(git_conf - oak_conf)
273
                pred = oak_result.get("prediction") or {}
274
                if pred.get("available"):
275
                    laws["prediction_agrees"] = (bool(pred.get("clean")) == bool(oak_result["clean"])
276
                                                 and set(pred.get("conflict_files") or []) == oak_conf)
277
            elif oak_result.get("error_kind") == "oak":
278
                # The real pull failed: a prediction that promised a merge (clean or a conflict
279
                # set) disagrees with reality; count it rather than dropping the row.
280
                pred = oak_result.get("prediction") or {}
281
                if pred.get("available"):
282
                    laws["prediction_agrees"] = False
283
                pf = oak_result.get("post_failure") or {}
284
                if pf.get("hazard_head_moved_worktree_stale") or pf.get("hazard_worktree_dirty_head_unmoved"):
285
                    out["post_failure_hazard"] = True
286
            if oak_result.get("post_failure"):
287
                out["oak_result"]["post_failure"] = oak_result["post_failure"]
288
        out["laws"] = laws
289
        cl.validate_class(out["class"])
290
        if evidence:
291
            out["evidence"] = evidence
292
        return out
293
294
295
KNOWN_BUGS = Path(__file__).resolve().parents[1] / "known_bugs.json"
296
297
298
def load_known_bugs(path: Path = KNOWN_BUGS) -> List[dict]:
299
    return json.loads(path.read_text())["bugs"] if path.exists() else []
300
301
302
def row_id(row: dict) -> str:
303
    return f"{row['scenario']}|{row['direction']}"
304
305
306
def _bug_covers(bug: dict, reason: str) -> bool:
307
    return any(re.search(p, reason) for p in bug.get("reason_patterns") or [])
308
309
310
def match_known_bug(row: dict, bugs: List[dict]) -> Optional[str]:
311
    """The known bug that explains an oak-wrong row, or None.
312
313
    A row maps to a bug only when EVERY reason on the row is covered by that one
314
    bug's reason patterns (and its optional scenario glob matches). A row with an
315
    extra, unexplained reason stays unmapped, so a new bug cannot hide behind a
316
    known one."""
317
    if row["class"] != "oak-wrong" or not row.get("reasons"):
318
        return None
319
    for bug in bugs:
320
        glob = bug.get("scenario_glob")
321
        if glob and not fnmatch.fnmatch(row["scenario"], glob):
322
            continue
323
        if all(_bug_covers(bug, reason) for reason in row["reasons"]):
324
            return bug["id"]
325
    return None
326
327
328
def missed_conflict_kind(row: dict) -> str:
329
    kinds = set()
330
    for types in ((row.get("git_result") or {}).get("conflicts") or {}).values():
331
        kinds.update(types)
332
    if any("rename" in k for k in kinds):
333
        return "rename-or-directory-rename"
334
    return "/".join(sorted(kinds)) or "unknown"
335
336
337
def summarize(rows: List[dict], bugs: Optional[List[dict]] = None) -> dict:
338
    bugs = load_known_bugs() if bugs is None else bugs
339
    stale = stale_schema_rows(rows)
340
    mapped: Dict[str, List[str]] = {b["id"]: [] for b in bugs}
341
    unmapped: List[str] = []
342
    for r in rows:
343
        bug = match_known_bug(r, bugs)
344
        r["known_bug"] = bug
345
        if bug:
346
            mapped[bug].append(row_id(r))
347
        elif r["class"] == "oak-wrong":
348
            unmapped.append(row_id(r))
349
350
    in_run = {row_id(r) for r in rows}
351
    # A pinned row that is now a harness error (or unsupported) was not judged:
352
    # its bug is neither reproduced nor fixed there.
353
    unjudged = {row_id(r) for r in rows if r["class"] in ("harness-error", "oak-unsupported")}
354
355
    def bug_report(bug: dict) -> dict:
356
        # Only pinned rows this run actually executed can vanish: a subset run
357
        # (--groups/--only) must not report a bug whose rows it never ran as fixed.
358
        pinned = set(bug.get("expected_rows") or [])
359
        got, want = set(mapped[bug["id"]]), pinned & in_run
360
        new = sorted(got - pinned)
361
        indeterminate = sorted((want - got) & unjudged)
362
        vanished = sorted(want - got - unjudged)
363
        if stale:
364
            # Rows written under another reason schema cannot be mapped reliably:
365
            # a missing bug may just be a renamed/extended reason, not a fix.
366
            status = f"indeterminate (rows not on reason schema v{SCHEMA_VERSION}: {stale})"
367
        elif indeterminate and not vanished and not new:
368
            status = "harness-error (indeterminate)"
369
        elif not want and not got:
370
            status = "not in this run (no pinned row executed)"
371
        elif not got:
372
            status = "NOT reproduced (fixed?)"
373
        elif vanished or new:
374
            status = "changed"
375
        else:
376
            status = "reproduced as pinned"
377
        return {"family": bug.get("family"), "severity": bug.get("severity"), "rows": len(got),
378
                "expected_rows": len(want), "expected_rows_total": len(pinned), "new_rows": new,
379
                "vanished_rows": vanished, "indeterminate_rows": indeterminate, "status": status}
380
    missed_by_class: Dict[str, int] = {}
381
    missed_by_kind: Dict[str, int] = {}
382
    for r in rows:
383
        if r["laws"].get("missed_conflict"):
384
            missed_by_class[r["class"]] = missed_by_class.get(r["class"], 0) + 1
385
            k = missed_conflict_kind(r)
386
            missed_by_kind[k] = missed_by_kind.get(k, 0) + 1
387
    base = _summarize(rows)
388
    base.update({
389
        "known_bugs": {b["id"]: bug_report(b) for b in bugs},
390
        "oak_wrong_unmapped": len(unmapped),
391
        "oak_wrong_unmapped_rows": unmapped,
392
        "missed_conflict_by_class": missed_by_class,
393
        "missed_conflict_by_git_kind": missed_by_kind,
394
        "post_failure_hazard_rows": sum(1 for r in rows if r.get("post_failure_hazard")),
395
        "schema_version": SCHEMA_VERSION,
396
        "stale_schema_rows": stale,
397
    })
398
    return base
399
400
401
def _summarize(rows: List[dict]) -> dict:
402
    counts = {c: 0 for c in cl.CLASSES}
403
    by_origin: Dict[str, Dict[str, int]] = {}
404
    for r in rows:
405
        counts[r["class"]] += 1
406
        by_origin.setdefault(r["origin"], {c: 0 for c in cl.CLASSES})[r["class"]] += 1
407
408
    def rate(key):
409
        vals = [r["laws"][key] for r in rows if r["laws"].get(key) is not None]
410
        return {"true": sum(1 for v in vals if v), "measured": len(vals)}
411
    return {"cases": len(rows), "scenarios": len({r["scenario"] for r in rows}), "counts": counts,
412
            "by_origin": by_origin,
413
            "laws": {k: rate(k) for k in ("no_lost_change", "idempotent", "symmetric", "false_conflict",
414
                                          "missed_conflict", "prediction_agrees")}}