Viewing files at commit 5dd811a1487aCommit detailsView latest

suites/merge/mergesuite/classify.py 313 lines · 16.1 KB · python
1
"""Outcome classification and merge laws. Pure functions; no I/O.
2
3
Classes (every case gets exactly one; nothing passes silently):
4
5
- equal-to-git: same clean/conflicted status, same conflicted path set, and
6
  identical content+mode for every non-conflicted path.
7
- differs-but-valid: differs from git, but no change from either side is lost
8
  or fabricated at any non-conflicted path (e.g. Oak conflicts where git's
9
  rename detection resolved cleanly; git conflicts where Oak's path-level merge
10
  kept both sides). Reasons say which.
11
- oak-wrong: a non-conflicted path loses a side's change, carries content no
12
  input or valid text merge explains, a scenario's stated expectation (e.g.
13
  "revert stays reverted") is violated, or Oak errors on a valid input.
14
- oak-unsupported: the input cannot be represented through Oak's public
15
  surface (gitlinks, multiple git merge bases or unrelated histories for the
16
  fork+squash construction), decided before Oak runs.
17
- harness-error: the instrument failed (fixture build, oracle, shim).
18
"""
19
from __future__ import annotations
20
21
import difflib
22
from typing import Dict, Iterable, List, Optional, Set, Tuple
23
24
CLASSES = ("equal-to-git", "differs-but-valid", "oak-wrong", "oak-unsupported", "harness-error")
25
MARKERS = (b"<<<<<<< ", b"=======", b">>>>>>> ")
26
27
Value = Tuple[str, str]
28
Tree = Dict[str, Value]
29
30
31
def split_tree(tree: Tree, conflicted: Iterable[str]) -> Tree:
32
    conflicted = set(conflicted)
33
    return {p: v for p, v in tree.items() if p not in conflicted}
34
35
36
def oak_conflict_type(path: str, base: Tree, ours: Tree, theirs: Tree) -> str:
37
    """Oak's per-path conflict kind, derived from the three inputs (Oak merges path-by-path)."""
38
    b, o, t = base.get(path), ours.get(path), theirs.get(path)
39
    if o is None or t is None:
40
        return "modify/delete" if b is not None else "file/directory"
41
    if b is None:
42
        return "add/add"
43
    if o[1] == t[1] and o[0] != t[0]:
44
        return "mode"
45
    return "content"
46
47
48
def text_preserves_sides(base: bytes, ours: bytes, theirs: bytes, merged: bytes) -> bool:
49
    """Heuristic line-level check: every block a side inserted/replaced appears in the merge,
50
    and the merge carries no conflict markers. Deletions are not checked (lines repeat)."""
51
    if any(m in merged for m in MARKERS) and not any(m in x for x in (base, ours, theirs) for m in MARKERS):
52
        return False
53
    b, m = base.splitlines(), merged.splitlines()
54
    for side in (ours.splitlines(), theirs.splitlines()):
55
        for tag, _i1, _i2, j1, j2 in difflib.SequenceMatcher(None, b, side, autojunk=False).get_opcodes():
56
            if tag in ("insert", "replace") and not _contains(m, side[j1:j2]):
57
                return False
58
    return True
59
60
61
def _contains(haystack: List[bytes], needle: List[bytes]) -> bool:
62
    if not needle:
63
        return True
64
    n = len(needle)
65
    return any(haystack[i:i + n] == needle for i in range(len(haystack) - n + 1))
66
67
68
def _three_way_kind(b: Optional[Value], o: Optional[Value], t: Optional[Value]) -> Optional[str]:
69
    """Expected entry kind (file/exec/symlink) of a both-sides-changed path, or None when
70
    the two sides changed the kind incompatibly (a mode conflict)."""
71
    bk, ok, tk = (b and b[0]), (o and o[0]), (t and t[0])
72
    if ok == tk:
73
        return ok
74
    if ok == bk:
75
        return tk
76
    if tk == bk:
77
        return ok
78
    return None
79
80
81
def lacks_final_newline(ev: dict) -> bool:
82
    """True when a non-empty merge input does not end in a newline (the fb-537 trigger)."""
83
    return any(x and not x.endswith(b"\n") for x in (ev.get("base"), ev.get("ours"), ev.get("theirs")))
84
85
86
def has_new_markers(ev: dict) -> bool:
87
    inputs = (ev.get("base") or b"", ev.get("ours") or b"", ev.get("theirs") or b"")
88
    return any(m in ev.get("merged", b"") and not any(m in x for x in inputs) for m in MARKERS)
89
90
91
def no_lost_change(base: Tree, ours: Tree, theirs: Tree, result: Tree, conflicted: Set[str],
92
                   texts: Optional[Dict[str, dict]] = None) -> Dict[str, List[str]]:
93
    """Path-level law. Returns {"lost", "mode_lost", "markers", "markers_no_final_newline",
94
    "markers_symlink", "fabricated",
95
    "text_merged"}.
96
97
    For every non-conflicted path: a change made by exactly one side must be
98
    present; an identical change on both sides must be present; a divergent
99
    change on both sides must have been text-merged preserving both sides'
100
    content AND the 3-way-merged entry kind/mode (checked with
101
    `texts[path] = {base, ours, theirs, merged}` bytes). Merged bytes carrying
102
    conflict markers that no input had are reported as `markers`."""
103
    lost, mode_lost, markers, markers_nofinal, markers_symlink, fabricated, merged_ok = [], [], [], [], [], [], []
104
    texts = texts or {}
105
    for path in sorted(set(base) | set(ours) | set(theirs) | set(result)):
106
        if path in conflicted:
107
            continue
108
        b, o, t, r = base.get(path), ours.get(path), theirs.get(path), result.get(path)
109
        if o == t:
110
            want = o
111
        elif o == b:
112
            want = t
113
        elif t == b:
114
            want = o
115
        else:
116
            ev = texts.get(path)
117
            kind = _three_way_kind(b, o, t)
118
            if r is not None and r in (o, t):
119
                # One side taken wholesale: the other side's change is dropped. When the dropped
120
                # side changed only the kind/mode (same content as base), call it a mode loss.
121
                other = t if r == o else o
122
                same_content = o is not None and t is not None and o[1] == t[1]
123
                mode_only = other is not None and (same_content or (
124
                    b is not None and other[1] == b[1] and other[0] != b[0]))
125
                (mode_lost if mode_only else lost).append(path)
126
            elif r is None:
127
                lost.append(path)
128
            elif o is not None and t is not None and o[1] == t[1] and r[1] == o[1]:
129
                # Same content both sides, kinds differ: only the mode merge is at stake.
130
                (merged_ok if kind is not None and r[0] == kind else mode_lost).append(path)
131
            elif ev is None:
132
                fabricated.append(path)
133
            elif has_new_markers(ev):
134
                symlinky = "symlink" in (o and o[0], t and t[0], r[0])
135
                if symlinky:
136
                    markers_symlink.append(path)
137
                else:
138
                    (markers_nofinal if lacks_final_newline(ev) else markers).append(path)
139
            elif kind is None or r[0] != kind:
140
                mode_lost.append(path)  # content merged, but a side's mode/kind change dropped
141
            elif text_preserves_sides(ev.get("base") or b"", ev["ours"], ev["theirs"], ev["merged"]):
142
                merged_ok.append(path)
143
            else:
144
                lost.append(path)
145
            continue
146
        if r != want:
147
            # Absent, or another input's value (a side's change was dropped) -> lost;
148
            # a value no input has -> fabricated.
149
            (lost if r is None or r in (b, o, t) else fabricated).append(path)
150
    return {"lost": lost, "mode_lost": mode_lost, "markers": markers, "markers_no_final_newline": markers_nofinal,
151
            "markers_symlink": markers_symlink,
152
            "fabricated": fabricated,
153
            "text_merged": merged_ok}
154
155
156
def validate_class(value: str) -> str:
157
    if value not in CLASSES:
158
        raise ValueError(f"unknown merge-suite class {value!r}; expected one of {CLASSES}")
159
    return value
160
161
162
def _classify(git_result: Optional[dict], oak_result: Optional[dict], base: Optional[Tree], ours: Tree,
163
             theirs: Tree, *, unsupported: Optional[str] = None, harness_error: Optional[str] = None,
164
             expect: Optional[dict] = None, texts: Optional[Dict[str, dict]] = None) -> dict:
165
    """Classify one directional case. git_result: {clean, tree, conflicts{path: [types]}}.
166
    oak_result: adapter result ({clean, tree, conflicts, error, error_kind})."""
167
    if harness_error:
168
        return {"class": "harness-error", "reasons": [harness_error]}
169
    if unsupported:
170
        return {"class": "oak-unsupported", "reasons": [unsupported]}
171
    if oak_result is None or git_result is None:
172
        return {"class": "harness-error", "reasons": ["missing result"]}
173
    if oak_result.get("error_kind") == "harness":
174
        return {"class": "harness-error", "reasons": [oak_result.get("error") or "harness"]}
175
    if oak_result.get("error_kind") == "oak-setup":
176
        # A scripted setup step (import, push, staged pull) failed before the measured merge.
177
        # That is not a merge verdict and must never be absorbed by a known merge bug.
178
        return {"class": "harness-error", "reasons": ["setup_failed: " + str(oak_result.get("error"))[:300]]}
179
    if oak_result.get("error_kind") == "oak" or oak_result.get("clean") is None:
180
        reasons = ["oak_error: " + str(oak_result.get("error"))[:300]]
181
        pf = oak_result.get("post_failure") or {}
182
        if pf.get("hazard_head_moved_worktree_stale"):
183
            reasons.append("failed_pull_moved_head_worktree_stale: head %s -> %s, %d worktree path(s) "
184
                           "differ from HEAD, re-pull says %s" % (
185
                               str(pf.get("head_before"))[:12], str(pf.get("head_after"))[:12],
186
                               pf.get("worktree_changes_vs_head", 0), pf.get("repull_parent_sync")))
187
        if pf.get("hazard_worktree_dirty_head_unmoved"):
188
            reasons.append("failed_pull_left_worktree_dirty: HEAD unchanged, %d worktree path(s) differ "
189
                           "from HEAD (e.g. %s)" % (pf.get("worktree_changes_vs_head", 0),
190
                                                    ",".join((pf.get("worktree_changes_sample") or [])[:3])))
191
        return {"class": "oak-wrong", "reasons": reasons}
192
193
    oak_tree: Tree = oak_result["tree"]
194
    oak_conf: Set[str] = set(oak_result.get("conflicts") or {})
195
    git_conf: Set[str] = set(git_result.get("conflicts") or {})
196
    reasons: List[str] = []
197
    # base None = no unique merge base (criss-cross): the path-level law is undefined
198
    # there and stays unmeasured (null); expectations and the git oracle still apply.
199
    law = no_lost_change(base, ours, theirs, oak_tree, oak_conf, texts) if base is not None else None
200
    evidence = {"law_no_lost_change": law}
201
    base = base or {}
202
203
    if expect:
204
        violations = check_expectations(expect, oak_result, oak_tree, oak_conf)
205
        if violations:
206
            label = f"[{expect['label']}]" if expect.get("label") else ""
207
            return {"class": "oak-wrong", "reasons": [f"expectation_violated{label}: " + "; ".join(violations)],
208
                    "evidence": evidence}
209
    law = law or {"lost": [], "mode_lost": [], "markers": [], "markers_no_final_newline": [],
210
                  "markers_symlink": [], "fabricated": [], "text_merged": []}
211
    if law["markers_no_final_newline"]:
212
        reasons.append("committed_conflict_markers_no_final_newline: "
213
                       + ",".join(law["markers_no_final_newline"][:10]))
214
    if law["markers_symlink"]:
215
        reasons.append("committed_conflict_markers_symlink: " + ",".join(law["markers_symlink"][:10]))
216
    if law["markers"]:
217
        reasons.append("committed_conflict_markers: " + ",".join(law["markers"][:10]))
218
    if law["lost"]:
219
        reasons.append("lost_change: " + ",".join(law["lost"][:10]))
220
    if law["mode_lost"]:
221
        reasons.append("lost_mode_change: " + ",".join(law["mode_lost"][:10]))
222
    if law["fabricated"]:
223
        reasons.append("fabricated_content: " + ",".join(law["fabricated"][:10]))
224
    if reasons:
225
        return {"class": "oak-wrong", "reasons": reasons, "evidence": evidence}
226
227
    git_clean_part = split_tree(git_result["tree"], git_conf)
228
    oak_clean_part = split_tree(oak_tree, oak_conf)
229
    if (bool(oak_result["clean"]) == bool(git_result["clean"]) and oak_conf == git_conf
230
            and oak_clean_part == git_clean_part):
231
        type_diff = conflict_type_differences(git_result.get("conflicts") or {}, oak_conf, base, ours, theirs)
232
        out = {"class": "equal-to-git", "reasons": [], "evidence": evidence}
233
        if type_diff:
234
            out["notes"] = ["conflict_type_differs: " + ", ".join(type_diff[:10])]
235
        return out
236
237
    false_conf = sorted(oak_conf - git_conf)
238
    missed = sorted(git_conf - oak_conf)
239
    if false_conf:
240
        reasons.append("oak_conflicts_where_git_clean: " + ",".join(false_conf[:10]))
241
    if missed:
242
        reasons.append("oak_clean_where_git_conflicts: " + ",".join(missed[:10]))
243
    shared = set(git_clean_part) & set(oak_clean_part)
244
    only_git = sorted(p for p in set(git_clean_part) - set(oak_clean_part) if p not in oak_conf)
245
    only_oak = sorted(p for p in set(oak_clean_part) - set(git_clean_part) if p not in git_conf)
246
    changed = sorted(p for p in shared if git_clean_part[p] != oak_clean_part[p])
247
    if only_git or only_oak or changed:
248
        reasons.append("tree_differs(all side changes preserved): git_only=%s oak_only=%s content=%s"
249
                       % (only_git[:6], only_oak[:6], changed[:6]))
250
    if law["text_merged"] and changed:
251
        reasons.append("text_merge_algorithm_differs: " + ",".join(sorted(set(changed) & set(law["text_merged"]))[:6]))
252
    if not reasons:
253
        reasons.append("status_differs")
254
    evidence.update({"false_conflict_paths": false_conf, "missed_conflict_paths": missed})
255
    return {"class": "differs-but-valid", "reasons": reasons, "evidence": evidence}
256
257
258
def conflict_type_differences(git_conflicts: Dict[str, List[str]], oak_conf: Set[str],
259
                              base: Tree, ours: Tree, theirs: Tree) -> List[str]:
260
    out = []
261
    for path in sorted(oak_conf & set(git_conflicts)):
262
        oak_kind = oak_conflict_type(path, base, ours, theirs)
263
        git_kinds = git_conflicts[path]
264
        if git_kinds and oak_kind not in git_kinds and not (oak_kind == "content" and "binary" in git_kinds):
265
            out.append(f"{path}: git={'/'.join(git_kinds)} oak={oak_kind}")
266
    return out
267
268
269
def check_expectations(expect: dict, oak_result: dict, tree: Tree, conflicts: Set[str]) -> List[str]:
270
    """expect = {"clean": bool?, "paths": {path: sha256 | None (absent)}, "conflicts": [paths]?}."""
271
    out = []
272
    if "clean" in expect and bool(oak_result.get("clean")) != expect["clean"]:
273
        out.append(f"clean={oak_result.get('clean')} expected {expect['clean']}")
274
    for path, digest in (expect.get("paths") or {}).items():
275
        got = tree.get(path)
276
        if digest is None and got is not None and path not in conflicts:
277
            out.append(f"{path} present, expected absent")
278
        elif digest is not None and (got is None or got[1] != digest):
279
            out.append(f"{path} content {'absent' if got is None else got[1][:12]} expected {digest[:12]}")
280
    if "conflicts" in expect and sorted(conflicts) != sorted(expect["conflicts"]):
281
        out.append(f"conflicts={sorted(conflicts)} expected {sorted(expect['conflicts'])}")
282
    return out
283
284
285
def symmetry(forward: dict, reverse: dict) -> Optional[dict]:
286
    """Merge-symmetry law over two directional Oak results (None if either is unusable).
287
288
    merge(A,B) ≅ merge(B,A): same clean flag, same conflicted path set and the
289
    same non-conflicted content. Conflicted files are excluded (side labels and
290
    marker order legitimately differ)."""
291
    if not forward or not reverse or forward.get("clean") is None or reverse.get("clean") is None:
292
        return None
293
    fc, rc = set(forward.get("conflicts") or {}), set(reverse.get("conflicts") or {})
294
    ft, rt = split_tree(forward["tree"], fc), split_tree(reverse["tree"], rc)
295
    diffs = sorted(p for p in set(ft) | set(rt) if ft.get(p) != rt.get(p) and p not in fc | rc)
296
    ok = forward["clean"] == reverse["clean"] and fc == rc and not diffs
297
    return {"holds": ok, "conflict_sets_equal": fc == rc, "clean_equal": forward["clean"] == reverse["clean"],
298
            "content_diff_paths": diffs[:20]}
299
300
301
def idempotence_holds(oak_result: dict) -> Optional[bool]:
302
    idem = oak_result.get("idempotence") if oak_result else None
303
    if not idem:
304
        return None
305
    return (idem.get("returncode") == 0 and idem.get("head_unchanged") is True
306
            and idem.get("tree_unchanged") is True)
307
308
309
def classify(*args, **kwargs) -> dict:
310
    """Classify one directional case (see _classify); the class is always validated."""
311
    verdict = _classify(*args, **kwargs)
312
    validate_class(verdict["class"])
313
    return verdict