Viewing files at commit 46ad54da0178Commit detailsView latest

suites/merge/mergesuite/classify.py 226 lines · 11.4 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 no_lost_change(base: Tree, ours: Tree, theirs: Tree, result: Tree, conflicted: Set[str],
69
                   texts: Optional[Dict[str, dict]] = None) -> Dict[str, List[str]]:
70
    """Path-level law. Returns {"lost": [...], "fabricated": [...], "text_merged": [...]}.
71
72
    For every non-conflicted path: a change made by exactly one side must be
73
    present; an identical change on both sides must be present; a divergent
74
    change on both sides must have been text-merged preserving both sides
75
    (checked with `texts[path] = {base, ours, theirs, merged}` bytes)."""
76
    lost, fabricated, merged_ok = [], [], []
77
    texts = texts or {}
78
    for path in sorted(set(base) | set(ours) | set(theirs) | set(result)):
79
        if path in conflicted:
80
            continue
81
        b, o, t, r = base.get(path), ours.get(path), theirs.get(path), result.get(path)
82
        if o == t:
83
            want = o
84
        elif o == b:
85
            want = t
86
        elif t == b:
87
            want = o
88
        else:
89
            ev = texts.get(path)
90
            if r is not None and r in (o, t):
91
                lost.append(path)  # one side taken wholesale: the other side's change is dropped
92
            elif r is None or ev is None:
93
                (lost if r is None else fabricated).append(path)
94
            elif r[0] not in (o and o[0], t and t[0]):
95
                fabricated.append(path)
96
            elif text_preserves_sides(ev.get("base") or b"", ev["ours"], ev["theirs"], ev["merged"]):
97
                merged_ok.append(path)
98
            else:
99
                lost.append(path)
100
            continue
101
        if r != want:
102
            # Absent, or another input's value (a side's change was dropped) -> lost;
103
            # a value no input has -> fabricated.
104
            (lost if r is None or r in (b, o, t) else fabricated).append(path)
105
    return {"lost": lost, "fabricated": fabricated, "text_merged": merged_ok}
106
107
108
def classify(git_result: Optional[dict], oak_result: Optional[dict], base: Optional[Tree], ours: Tree,
109
             theirs: Tree, *, unsupported: Optional[str] = None, harness_error: Optional[str] = None,
110
             expect: Optional[dict] = None, texts: Optional[Dict[str, dict]] = None) -> dict:
111
    """Classify one directional case. git_result: {clean, tree, conflicts{path: [types]}}.
112
    oak_result: adapter result ({clean, tree, conflicts, error, error_kind})."""
113
    if harness_error:
114
        return {"class": "harness-error", "reasons": [harness_error]}
115
    if unsupported:
116
        return {"class": "oak-unsupported", "reasons": [unsupported]}
117
    if oak_result is None or git_result is None:
118
        return {"class": "harness-error", "reasons": ["missing result"]}
119
    if oak_result.get("error_kind") == "harness":
120
        return {"class": "harness-error", "reasons": [oak_result.get("error") or "harness"]}
121
    if oak_result.get("error_kind") in ("oak", "oak-setup") or oak_result.get("clean") is None:
122
        return {"class": "oak-wrong", "reasons": ["oak_error: " + str(oak_result.get("error"))[:300]]}
123
124
    oak_tree: Tree = oak_result["tree"]
125
    oak_conf: Set[str] = set(oak_result.get("conflicts") or {})
126
    git_conf: Set[str] = set(git_result.get("conflicts") or {})
127
    reasons: List[str] = []
128
    # base None = no unique merge base (criss-cross): the path-level law is undefined
129
    # there and stays unmeasured (null); expectations and the git oracle still apply.
130
    law = no_lost_change(base, ours, theirs, oak_tree, oak_conf, texts) if base is not None else None
131
    evidence = {"law_no_lost_change": law}
132
    base = base or {}
133
134
    if expect:
135
        violations = check_expectations(expect, oak_result, oak_tree, oak_conf)
136
        if violations:
137
            return {"class": "oak-wrong", "reasons": ["expectation_violated: " + "; ".join(violations)],
138
                    "evidence": evidence}
139
    law = law or {"lost": [], "fabricated": [], "text_merged": []}
140
    if law["lost"]:
141
        reasons.append("lost_change: " + ",".join(law["lost"][:10]))
142
    if law["fabricated"]:
143
        reasons.append("fabricated_content: " + ",".join(law["fabricated"][:10]))
144
    if reasons:
145
        return {"class": "oak-wrong", "reasons": reasons, "evidence": evidence}
146
147
    git_clean_part = split_tree(git_result["tree"], git_conf)
148
    oak_clean_part = split_tree(oak_tree, oak_conf)
149
    if (bool(oak_result["clean"]) == bool(git_result["clean"]) and oak_conf == git_conf
150
            and oak_clean_part == git_clean_part):
151
        type_diff = conflict_type_differences(git_result.get("conflicts") or {}, oak_conf, base, ours, theirs)
152
        out = {"class": "equal-to-git", "reasons": [], "evidence": evidence}
153
        if type_diff:
154
            out["notes"] = ["conflict_type_differs: " + ", ".join(type_diff[:10])]
155
        return out
156
157
    false_conf = sorted(oak_conf - git_conf)
158
    missed = sorted(git_conf - oak_conf)
159
    if false_conf:
160
        reasons.append("oak_conflicts_where_git_clean: " + ",".join(false_conf[:10]))
161
    if missed:
162
        reasons.append("oak_clean_where_git_conflicts: " + ",".join(missed[:10]))
163
    shared = set(git_clean_part) & set(oak_clean_part)
164
    only_git = sorted(p for p in set(git_clean_part) - set(oak_clean_part) if p not in oak_conf)
165
    only_oak = sorted(p for p in set(oak_clean_part) - set(git_clean_part) if p not in git_conf)
166
    changed = sorted(p for p in shared if git_clean_part[p] != oak_clean_part[p])
167
    if only_git or only_oak or changed:
168
        reasons.append("tree_differs(all side changes preserved): git_only=%s oak_only=%s content=%s"
169
                       % (only_git[:6], only_oak[:6], changed[:6]))
170
    if law["text_merged"] and changed:
171
        reasons.append("text_merge_algorithm_differs: " + ",".join(sorted(set(changed) & set(law["text_merged"]))[:6]))
172
    if not reasons:
173
        reasons.append("status_differs")
174
    evidence.update({"false_conflict_paths": false_conf, "missed_conflict_paths": missed})
175
    return {"class": "differs-but-valid", "reasons": reasons, "evidence": evidence}
176
177
178
def conflict_type_differences(git_conflicts: Dict[str, List[str]], oak_conf: Set[str],
179
                              base: Tree, ours: Tree, theirs: Tree) -> List[str]:
180
    out = []
181
    for path in sorted(oak_conf & set(git_conflicts)):
182
        oak_kind = oak_conflict_type(path, base, ours, theirs)
183
        git_kinds = git_conflicts[path]
184
        if git_kinds and oak_kind not in git_kinds and not (oak_kind == "content" and "binary" in git_kinds):
185
            out.append(f"{path}: git={'/'.join(git_kinds)} oak={oak_kind}")
186
    return out
187
188
189
def check_expectations(expect: dict, oak_result: dict, tree: Tree, conflicts: Set[str]) -> List[str]:
190
    """expect = {"clean": bool?, "paths": {path: sha256 | None (absent)}, "conflicts": [paths]?}."""
191
    out = []
192
    if "clean" in expect and bool(oak_result.get("clean")) != expect["clean"]:
193
        out.append(f"clean={oak_result.get('clean')} expected {expect['clean']}")
194
    for path, digest in (expect.get("paths") or {}).items():
195
        got = tree.get(path)
196
        if digest is None and got is not None and path not in conflicts:
197
            out.append(f"{path} present, expected absent")
198
        elif digest is not None and (got is None or got[1] != digest):
199
            out.append(f"{path} content {'absent' if got is None else got[1][:12]} expected {digest[:12]}")
200
    if "conflicts" in expect and sorted(conflicts) != sorted(expect["conflicts"]):
201
        out.append(f"conflicts={sorted(conflicts)} expected {sorted(expect['conflicts'])}")
202
    return out
203
204
205
def symmetry(forward: dict, reverse: dict) -> Optional[dict]:
206
    """Merge-symmetry law over two directional Oak results (None if either is unusable).
207
208
    merge(A,B) ≅ merge(B,A): same clean flag, same conflicted path set and the
209
    same non-conflicted content. Conflicted files are excluded (side labels and
210
    marker order legitimately differ)."""
211
    if not forward or not reverse or forward.get("clean") is None or reverse.get("clean") is None:
212
        return None
213
    fc, rc = set(forward.get("conflicts") or {}), set(reverse.get("conflicts") or {})
214
    ft, rt = split_tree(forward["tree"], fc), split_tree(reverse["tree"], rc)
215
    diffs = sorted(p for p in set(ft) | set(rt) if ft.get(p) != rt.get(p) and p not in fc | rc)
216
    ok = forward["clean"] == reverse["clean"] and fc == rc and not diffs
217
    return {"holds": ok, "conflict_sets_equal": fc == rc, "clean_equal": forward["clean"] == reverse["clean"],
218
            "content_diff_paths": diffs[:20]}
219
220
221
def idempotence_holds(oak_result: dict) -> Optional[bool]:
222
    idem = oak_result.get("idempotence") if oak_result else None
223
    if not idem:
224
        return None
225
    return (idem.get("returncode") == 0 and idem.get("head_unchanged") is True
226
            and idem.get("tree_unchanged") is True)