Turns the dispatch axis from a binary changed/unchanged into a "how much" measure
of code change — the original goal. ams.normalize compares two body fingerprints
(the ordered leaf-call anchors) with difflib after collapsing consecutive-duplicate
anchors (a load-twice codegen artefact), yielding a 0-100 similarity and the exact
leaves that appeared/vanished.
Every dispatch `changed` entry now carries body={similarity, added, removed}, and the
block carries a summary={shared, identical, changed, mean_similarity}.
Golden pair (cross-compiler): 470 shared bodies, 131 identical, mean 66% similar;
Animo SHOW/HIDE/PAUSE/RESUME come out 100% despite MSVC6 vs MSVC8, LOAD 50% with the
swapped leaves spelled out.
- normalize.py: canonical / body_similarity / body_delta
- diff: _dispatch_diff enriches changed with body + adds summary
- render: METHOD BODIES shows %, leaf delta, summary line
- UI: similarity % + leaf delta + axis summary
- tests: 5 new -> 34/34
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
174 lines
7.6 KiB
Python
174 lines
7.6 KiB
Python
"""Human-readable rendering of a snapshot diff (see diff.compute_diff)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Callable
|
|
|
|
|
|
def _counts(block: dict[str, Any]) -> str:
|
|
return "+{0} -{1} ~{2}".format(
|
|
len(block["added"]), len(block["removed"]), len(block["changed"]))
|
|
|
|
|
|
def _fmt_changes(changes: dict[str, list]) -> str:
|
|
return "; ".join("{0}: {1} -> {2}".format(f, v[0], v[1]) for f, v in sorted(changes.items()))
|
|
|
|
|
|
def _group_by(items: list[dict], owner_of: Callable[[dict], str]) -> dict[str, list[dict]]:
|
|
out: dict[str, list[dict]] = {}
|
|
for it in items:
|
|
out.setdefault(owner_of(it) or "?", []).append(it)
|
|
return out
|
|
|
|
|
|
# --- per-axis item formatting ------------------------------------------------------------------
|
|
def _fmt_type(t: dict) -> str:
|
|
tag = " [via module iface]" if t.get("via_module_iface") else ""
|
|
size = t.get("object_size")
|
|
cls = t.get("cpp_class") or "?"
|
|
return "{0} -> {1} (size {2}){3}".format(t["script_name"], cls, size, tag)
|
|
|
|
|
|
def _fmt_method(m: dict) -> str:
|
|
return "{0} (id {1})".format(m["name"], m.get("id"))
|
|
|
|
|
|
def _fmt_event(e: dict) -> str:
|
|
return "{0} (#{1})".format(e["name"], e.get("order"))
|
|
|
|
|
|
def _fmt_field(f: dict) -> str:
|
|
return "{0}: {1}".format(f["name"], f.get("type"))
|
|
|
|
|
|
def _fmt_layout(x: dict) -> str:
|
|
vt = " vtable" if x.get("is_vtable") else ""
|
|
return "@{0:#x} size {1}{2}".format(x["offset"], x.get("size"), vt)
|
|
|
|
|
|
# --- section renderers -------------------------------------------------------------------------
|
|
def _section_flat(out: list[str], title: str, block: dict, fmt: Callable[[dict], str],
|
|
name_of: Callable[[dict], str]) -> None:
|
|
out.append("")
|
|
out.append("{0:<16} {1}".format(title, _counts(block)))
|
|
for it in sorted(block["added"], key=name_of):
|
|
out.append(" + {0}".format(fmt(it)))
|
|
for it in sorted(block["removed"], key=name_of):
|
|
out.append(" - {0}".format(name_of(it)))
|
|
for ch in sorted(block["changed"], key=lambda c: name_of(c["item"])):
|
|
out.append(" ~ {0:<22} {1}".format(name_of(ch["item"]), _fmt_changes(ch["changes"])))
|
|
|
|
|
|
def _section_owned(out: list[str], title: str, block: dict, fmt: Callable[[dict], str],
|
|
owner_of: Callable[[dict], str], name_of: Callable[[dict], str]) -> None:
|
|
out.append("")
|
|
out.append("{0:<16} {1}".format(title, _counts(block)))
|
|
added = _group_by(block["added"], owner_of)
|
|
removed = _group_by(block["removed"], owner_of)
|
|
changed = _group_by([c["item"] for c in block["changed"]], owner_of)
|
|
change_by_id = {id(c["item"]): c for c in block["changed"]}
|
|
for owner in sorted(set(added) | set(removed) | set(changed)):
|
|
out.append(" {0}".format(owner))
|
|
for it in sorted(added.get(owner, []), key=name_of):
|
|
out.append(" + {0}".format(fmt(it)))
|
|
for it in sorted(removed.get(owner, []), key=name_of):
|
|
out.append(" - {0}".format(name_of(it)))
|
|
for it in sorted(changed.get(owner, []), key=name_of):
|
|
out.append(" ~ {0:<22} {1}".format(
|
|
name_of(it), _fmt_changes(change_by_id[id(it)]["changes"])))
|
|
|
|
|
|
def _dispatch_name(r: dict) -> str:
|
|
return r.get("name") or "id {0}".format(r.get("id"))
|
|
|
|
|
|
def _leaves(items: list, cap: int = 4) -> str:
|
|
shown = items[:cap]
|
|
extra = "…+{0}".format(len(items) - cap) if len(items) > cap else ""
|
|
return ", ".join(shown) + extra
|
|
|
|
|
|
def _section_dispatch(out: list[str], block: dict) -> None:
|
|
"""Method bodies (per owner+id), normalised. Each changed entry shows a similarity score and
|
|
the leaf-level delta (which calls appeared/vanished); a summary line gives the overall drift."""
|
|
out.append("")
|
|
summ = block.get("summary")
|
|
head = "METHOD BODIES"
|
|
if summ:
|
|
head = "{0} (shared {1}, ~{2} changed, mean {3}% similar)".format(
|
|
"METHOD BODIES", summ["shared"], summ["changed"], summ["mean_similarity"])
|
|
out.append("{0}".format(head))
|
|
out.append("{0:<16} {1}".format("", _counts(block)))
|
|
owner_of = lambda r: r["owner"]
|
|
added = _group_by(block["added"], owner_of)
|
|
removed = _group_by(block["removed"], owner_of)
|
|
changed = _group_by([c["item"] for c in block["changed"]], owner_of)
|
|
change_by_id = {id(c["item"]): c for c in block["changed"]}
|
|
for owner in sorted(set(added) | set(removed) | set(changed)):
|
|
out.append(" {0}".format(owner))
|
|
for it in sorted(added.get(owner, []), key=_dispatch_name):
|
|
out.append(" + {0}".format(_dispatch_name(it)))
|
|
for it in sorted(removed.get(owner, []), key=_dispatch_name):
|
|
out.append(" - {0}".format(_dispatch_name(it)))
|
|
for it in sorted(changed.get(owner, []), key=_dispatch_name):
|
|
ch = change_by_id[id(it)]
|
|
body = ch.get("body", {})
|
|
sim = body.get("similarity")
|
|
bits = []
|
|
if body.get("added"):
|
|
bits.append("+[{0}]".format(_leaves(body["added"])))
|
|
if body.get("removed"):
|
|
bits.append("-[{0}]".format(_leaves(body["removed"])))
|
|
if not bits and "impl" in ch["changes"]:
|
|
bits.append("impl {0} -> {1}".format(*ch["changes"]["impl"]))
|
|
label = "{0} {1}%".format(_dispatch_name(it), sim) if sim is not None else _dispatch_name(it)
|
|
out.append(" ~ {0:<26} {1}".format(label, " ".join(bits)))
|
|
|
|
|
|
_EMPTY = {"added": [], "removed": [], "changed": []}
|
|
|
|
|
|
def _is_empty(block: dict) -> bool:
|
|
return not (block["added"] or block["removed"] or block["changed"])
|
|
|
|
|
|
def render_text(diff: dict[str, Any], only: set[str] | None = None) -> str:
|
|
b = diff["binary"]
|
|
out: list[str] = ["Engine surface diff"]
|
|
out.append(" from: {0} [{1}/{2}]".format(
|
|
b["from"].get("name", "?"), b["from"].get("engine", "?"), b["from"].get("compiler", "?")))
|
|
out.append(" to: {0} [{1}/{2}]".format(
|
|
b["to"].get("name", "?"), b["to"].get("engine", "?"), b["to"].get("compiler", "?")))
|
|
|
|
def want(axis: str) -> bool:
|
|
return only is None or axis in only
|
|
|
|
if want("types") and not _is_empty(diff["types"]):
|
|
_section_flat(out, "TYPES", diff["types"], _fmt_type, lambda t: t["script_name"])
|
|
if want("methods") and not _is_empty(diff["methods"]):
|
|
_section_owned(out, "METHODS", diff["methods"], _fmt_method,
|
|
lambda m: m["owner"], lambda m: m["name"])
|
|
if want("events") and not _is_empty(diff["events"]):
|
|
_section_owned(out, "EVENTS", diff["events"], _fmt_event,
|
|
lambda e: e["owner"], lambda e: e["name"])
|
|
if want("fields") and not _is_empty(diff["fields"]):
|
|
_section_owned(out, "FIELDS", diff["fields"], _fmt_field,
|
|
lambda f: f["owner"], lambda f: f["name"])
|
|
if want("layout") and not _is_empty(diff["struct_layout"]):
|
|
_section_owned(out, "STRUCT LAYOUT", diff["struct_layout"], _fmt_layout,
|
|
lambda x: x["owner"], lambda x: "@{0:#x}".format(x["offset"]))
|
|
if want("dispatch") and not _is_empty(diff.get("method_dispatch", _EMPTY)):
|
|
_section_dispatch(out, diff["method_dispatch"])
|
|
if want("methods") and diff["moved_methods"]:
|
|
out.append("")
|
|
out.append("MOVED METHODS {0}".format(len(diff["moved_methods"])))
|
|
for m in sorted(diff["moved_methods"], key=lambda x: x["name"]):
|
|
out.append(" {0}: {1} -> {2}".format(
|
|
m["name"], ",".join(m["from_owners"]), ",".join(m["to_owners"])))
|
|
|
|
if all(_is_empty(diff.get(a, _EMPTY))
|
|
for a in ("types", "methods", "events", "fields", "struct_layout", "method_dispatch")):
|
|
out.append("")
|
|
out.append("(no differences)")
|
|
return "\n".join(out)
|