mirror of
https://github.com/Graphify-Labs/REAgraph.git
synced 2026-11-07 09:35:57 -10:00
diff: kind-aware signature (IPC channels) + second real-app benchmark corpus
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]>
This commit is contained in:
1 parent
21a53119f0
commit
8c751aeb96
8 files changed
+4386
-14
No files matched your search
+12
-10
@@ -74,23 +74,25 @@ if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from reagraph import memory
|
||||
from reagraph.diff import _LOCATION_UNSTABLE_KINDS
|
||||
from reagraph.diff import diff as structural_diff
|
||||
from reagraph.diff import normalize_label, rea_signature
|
||||
|
||||
|
||||
# -- descriptors ---------------------------------------------------------------
|
||||
# A descriptor is the stable, cross-build identity of a node: the fields REAgraph
|
||||
# stores regardless of where the bytes landed. Never crash on a missing field.
|
||||
# The label is range-normalized (reagraph.diff.normalize_label) so a symbol whose
|
||||
# source line:col merely shifted is still the same descriptor.
|
||||
# A descriptor is the stable, cross-build identity of a node -- the SAME identity
|
||||
# reagraph.diff's signature mode uses, so the benchmark's "true delta" is measured
|
||||
# against the stable identity (not the raw per-build id). The label is
|
||||
# range-normalized, and for kinds whose source attribution drifts between runs
|
||||
# (_LOCATION_UNSTABLE_KINDS, e.g. an IPC channel that spans main+preload) the
|
||||
# source file is left out -- exactly as rea_signature does. Never crashes.
|
||||
|
||||
def descriptor(node: dict) -> tuple:
|
||||
"""The (rea_kind, normalized label, source_file) identity of a node."""
|
||||
return (
|
||||
node.get("rea_kind") or "",
|
||||
normalize_label(node.get("label") or ""),
|
||||
node.get("source_file") or "",
|
||||
)
|
||||
"""The stable (rea_kind, normalized label, source_file-or-"") identity of a node."""
|
||||
kind = node.get("rea_kind") or ""
|
||||
label = normalize_label(node.get("label") or "")
|
||||
source = "" if kind in _LOCATION_UNSTABLE_KINDS else (node.get("source_file") or "")
|
||||
return (kind, label, source)
|
||||
|
||||
|
||||
def _sig_desc(sig_key: str) -> tuple:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"_comment": "Real REA graphs of a bigger (7-module) Electron app, generated via examples/rea-docker. Stress-tests the signature diff on harder cases. Note: REA models modules/imports/IPC/endpoints/windows, NOT individual function definitions, so a function-level change (movefunc) is below its node granularity and correctly reads as no change -- the diff must not hallucinate it.",
|
||||
"pairs": [
|
||||
{"name": "reorder", "a": "graphs/base.json", "b": "graphs/reorder.json", "note": "pure intra-file reorder of store.js -> ideal diff empty (the #4520 case at scale)"},
|
||||
{"name": "addmod", "a": "graphs/base.json", "b": "graphs/addmod.json", "note": "added src/cache.js module + its import -> detected"},
|
||||
{"name": "movefunc", "a": "graphs/base.json", "b": "graphs/movefunc.json", "note": "removed a helper FUNCTION (rand) -> below REA's node granularity, correctly 0"}
|
||||
]
|
||||
}
|
||||
+20
-4
@@ -41,17 +41,33 @@ def normalize_label(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.
|
||||
"""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 the file it lives in) rather than how this build
|
||||
happened to hash it. Used as ``diff(a, b, signature=rea_signature)`` to diff
|
||||
two REA builds without the per-build id churn swamping the result.
|
||||
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}"
|
||||
|
||||
|
||||
@@ -116,6 +116,26 @@ def test_default_diff_is_unchanged_backcompat():
|
||||
assert d["summary"]["nodes_added"] == 0
|
||||
|
||||
|
||||
def test_ipc_channel_source_file_is_not_part_of_identity():
|
||||
# An IPC channel spans main (handle) + preload (invoke); REA attributes the
|
||||
# channel node to one file or the other from run to run. Its name is the
|
||||
# stable identity, so a source-file flip on an untouched channel must NOT
|
||||
# register as a change.
|
||||
a = _graph([_n("a1", "ipc-channel", "notes:list", "src/preload.js")])
|
||||
b = _graph([_n("z1", "ipc-channel", "notes:list", "src/main.js")])
|
||||
|
||||
sig = diff.diff(a, b, signature=diff.rea_signature)
|
||||
assert sig["summary"]["nodes_added"] == 0
|
||||
assert sig["summary"]["nodes_removed"] == 0
|
||||
assert sig["summary"]["unchanged_nodes"] == 1
|
||||
|
||||
# A genuinely different channel name is still a change.
|
||||
c = _graph([_n("z1", "ipc-channel", "notes:save", "src/main.js")])
|
||||
sig2 = diff.diff(a, c, signature=diff.rea_signature)
|
||||
assert sig2["summary"]["nodes_added"] == 1
|
||||
assert sig2["summary"]["nodes_removed"] == 1
|
||||
|
||||
|
||||
def test_normalize_label_strips_only_ranges():
|
||||
assert diff.normalize_label("BrowserWindow@7:14-9:4") == "BrowserWindow"
|
||||
assert diff.normalize_label("handler@24:33-28:1") == "handler"
|
||||
|
||||
Reference in new issue
Block a user