| 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)
|