mirror of
https://github.com/Graphify-Labs/REAgraph.git
synced 2026-11-07 09:35:57 -10:00
Stress-tested the signature diff on a bigger (7-module) real REA app and found one
source of false movement: an Electron IPC channel spans the main process
(ipcMain.handle) and the preload (ipcRenderer.invoke), and REA attributes the
channel node to one file or the other from run to run. Including source_file in the
signature made an untouched channel flip add/remove on every build.
Fix: rea_signature is now kind-aware. For kinds whose source attribution drifts
(_LOCATION_UNSTABLE_KINDS = {"ipc-channel"}), the signature is kind+label only (the
channel name is the stable identity); all other kinds keep kind+label+source_file.
bench_diff_quality's descriptor uses the same rule so the measured "true delta" is
the stable-identity delta. Regression test added.
Also establishes what REA's JS graph does and does not model: modules, imports,
IPC channels/handlers, endpoints, windows, assets -- NOT individual function
definitions. So a function-level rename/removal is below its node granularity and
correctly reads as no change (the diff must not hallucinate it); the
security-relevant diffs (added/removed endpoint, IPC handler, module) are detected
exactly.
Results on TWO real corpora (bench/fixtures/diff/real = first app,
bench/fixtures/diff/real2 = bigger app), both generated via examples/rea-docker:
- pure reorder / reformat / sub-granularity change -> 0 changes (was 75-80);
- added exfil endpoint, added module, removed endpoint+handler -> detected exactly;
- precision/recall/F1 = 1.00, noise_floor = 0.00, zero spurious, zero data loss.
185 tests pass.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
180 lines
7.5 KiB
Python
180 lines
7.5 KiB
Python
"""Structural, byte-agnostic diff of two graphs.
|
|
|
|
The bug this fixes (REA #4520): diffing by byte offset reports a module as
|
|
`changed` when its bytes merely moved, so a real app-version diff can come back
|
|
"0 unchanged". Here, identity is the node id and the (source,target,relation)
|
|
edge key -- never a file location -- so a thing that only moved reads as
|
|
UNCHANGED, and only real structural change (added/removed nodes, rewired calls)
|
|
shows up.
|
|
|
|
Two identity regimes, because two kinds of graph flow through here:
|
|
|
|
- graphify source graphs (and a graph re-folded from the SAME build) carry
|
|
STABLE node ids, so the default id-keyed diff above is exactly right.
|
|
- REA graphs of two different BUILDS do NOT: REA's node_id embeds a per-build
|
|
hash and some identities are context-dependent (the same unchanged module can
|
|
get a different identity scheme between builds), so an id-keyed diff reports
|
|
nearly everything as changed -- the very #4520 shape, reintroduced by the id.
|
|
For that, pass ``signature=`` (e.g. ``rea_signature``): nodes are matched by a
|
|
build-stable signature -- kind + range-normalized label + source file -- with
|
|
duplicates disambiguated by a stable ordinal, so a reformatted-but-identical
|
|
build reads as UNCHANGED and only real movement shows up.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from .memory import edge_key
|
|
|
|
# A source span embedded in a label ("BrowserWindow@7:14-9:4"): "@"? line:col,
|
|
# optionally a range to a second line:col. Stripping it makes a label stable when
|
|
# code merely moves. Only an exact colon-separated numeric span is treated as a
|
|
# range, so a real name with digits ("base64") is never touched.
|
|
_LABEL_RANGE = re.compile(r"@?\d+:\d+(?:-\d+:\d+)?")
|
|
|
|
|
|
def normalize_label(label):
|
|
"""Strip embedded source line:col ranges from a label (for stable matching)."""
|
|
if not isinstance(label, str):
|
|
return label
|
|
return _LABEL_RANGE.sub("", label).strip()
|
|
|
|
|
|
# Node kinds whose SOURCE FILE is not a stable part of their identity, because the
|
|
# entity legitimately spans more than one file and REA attributes it to one or the
|
|
# other from run to run. An Electron IPC channel is the canonical case: the same
|
|
# channel is `ipcMain.handle(...)` in the main process and `ipcRenderer.invoke(...)`
|
|
# in the preload, and REA may file the channel node under either. Its NAME (the
|
|
# channel string) is the stable identity, so these kinds are keyed on kind+label
|
|
# only -- including source_file would make an untouched channel flip add/remove.
|
|
_LOCATION_UNSTABLE_KINDS = frozenset({"ipc-channel"})
|
|
|
|
|
|
def rea_signature(node: dict) -> str:
|
|
"""A build-stable signature for a REA node: kind + normalized label (+ source file).
|
|
|
|
REA node_ids are not stable across builds; this signature is, because it is
|
|
built from what the node MEANS (its kind, its symbol/label with any source
|
|
range normalized out, and -- for file-local entities -- the file it lives in)
|
|
rather than how this build happened to hash it. For kinds that legitimately
|
|
span files and whose source attribution drifts between runs
|
|
(``_LOCATION_UNSTABLE_KINDS``, e.g. an IPC channel), the source file is left
|
|
OUT so an untouched entity is not reported as moved. Used as
|
|
``diff(a, b, signature=rea_signature)`` to diff two REA builds without the
|
|
per-build id churn (or that attribution drift) swamping the result.
|
|
"""
|
|
kind = node.get("rea_kind") or node.get("kind") or ""
|
|
label = normalize_label(node.get("label") or "")
|
|
if kind in _LOCATION_UNSTABLE_KINDS:
|
|
return f"{kind}|{label}"
|
|
source = node.get("source_file") or ""
|
|
return f"{kind}|{label}|{source}"
|
|
|
|
|
|
def _remap_by_signature(graph: dict, signature) -> dict:
|
|
"""Return a shallow graph copy whose node ids (and edge endpoints) are replaced
|
|
by ``signature(node)`` plus a stable per-signature ordinal.
|
|
|
|
The ordinal (#0, #1, ...) disambiguates several nodes that share a signature
|
|
(e.g. two `https` references in one file) so matching is MULTISET-correct: two
|
|
builds with the same count of a signature match all of them; a build with one
|
|
more gets exactly one `added`. Ordinals are assigned in node order; since
|
|
same-signature nodes are interchangeable by construction, order does not affect
|
|
the node-level result.
|
|
"""
|
|
counts: dict = {}
|
|
id_map: dict = {}
|
|
for n in graph.get("nodes", []) or []:
|
|
if "id" not in n:
|
|
continue
|
|
sig = signature(n)
|
|
idx = counts.get(sig, 0)
|
|
counts[sig] = idx + 1
|
|
id_map[n["id"]] = f"{sig}#{idx}"
|
|
|
|
nodes = []
|
|
for n in graph.get("nodes", []) or []:
|
|
if "id" not in n:
|
|
continue
|
|
m = dict(n)
|
|
m["id"] = id_map[n["id"]]
|
|
nodes.append(m)
|
|
|
|
links = []
|
|
for e in graph.get("links", []) or graph.get("edges", []) or []:
|
|
m = dict(e)
|
|
for key in ("source", "target", "source_node_id", "target_node_id"):
|
|
if key in m and m[key] in id_map:
|
|
m[key] = id_map[m[key]]
|
|
links.append(m)
|
|
|
|
return {"nodes": nodes, "links": links}
|
|
|
|
|
|
def _edge_set(graph: dict) -> set:
|
|
return {edge_key(e) for e in graph.get("links", []) or graph.get("edges", [])}
|
|
|
|
|
|
def diff(a: dict, b: dict, *, signature=None) -> dict:
|
|
"""Structural diff of two graphs. By default nodes are keyed by id (correct for
|
|
stable-id graphs); pass ``signature`` (e.g. ``rea_signature``) to match nodes by
|
|
a build-stable signature instead, for diffing two REA builds. Return shape is
|
|
identical either way (in signature mode the reported ids are signature keys)."""
|
|
if signature is not None:
|
|
a = _remap_by_signature(a, signature)
|
|
b = _remap_by_signature(b, signature)
|
|
|
|
a_nodes = {n["id"] for n in a.get("nodes", []) if "id" in n}
|
|
b_nodes = {n["id"] for n in b.get("nodes", []) if "id" in n}
|
|
|
|
a_edges = _edge_set(a)
|
|
b_edges = _edge_set(b)
|
|
|
|
nodes_added = sorted(b_nodes - a_nodes)
|
|
nodes_removed = sorted(a_nodes - b_nodes)
|
|
|
|
edges_added = sorted(b_edges - a_edges)
|
|
edges_removed = sorted(a_edges - b_edges)
|
|
|
|
# A common node is "rewired" if any incident edge appeared or disappeared.
|
|
touched = {ek[0] for ek in edges_added} | {ek[1] for ek in edges_added}
|
|
touched |= {ek[0] for ek in edges_removed} | {ek[1] for ek in edges_removed}
|
|
rewired = sorted((touched & a_nodes & b_nodes))
|
|
|
|
unchanged = sorted((a_nodes & b_nodes) - set(rewired))
|
|
|
|
return {
|
|
"nodes_added": nodes_added,
|
|
"nodes_removed": nodes_removed,
|
|
"edges_added": [list(e) for e in edges_added],
|
|
"edges_removed": [list(e) for e in edges_removed],
|
|
"rewired_nodes": rewired,
|
|
"unchanged_nodes": unchanged,
|
|
"summary": {
|
|
"nodes_added": len(nodes_added),
|
|
"nodes_removed": len(nodes_removed),
|
|
"edges_added": len(edges_added),
|
|
"edges_removed": len(edges_removed),
|
|
"rewired_nodes": len(rewired),
|
|
"unchanged_nodes": len(unchanged),
|
|
},
|
|
}
|
|
|
|
|
|
def format_report(report: dict) -> str:
|
|
s = report["summary"]
|
|
lines = [
|
|
"Structural diff (byte-agnostic):",
|
|
f" unchanged nodes : {s['unchanged_nodes']}",
|
|
f" added nodes : {s['nodes_added']}",
|
|
f" removed nodes : {s['nodes_removed']}",
|
|
f" rewired nodes : {s['rewired_nodes']}",
|
|
f" edges +{s['edges_added']} / -{s['edges_removed']}",
|
|
]
|
|
for nid in report["nodes_added"][:20]:
|
|
lines.append(f" + {nid}")
|
|
for nid in report["nodes_removed"][:20]:
|
|
lines.append(f" - {nid}")
|
|
return "\n".join(lines)
|