From 36eefa78cd4712585a63d4b2008bede4043c11de Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Sat, 15 Aug 2026 21:08:13 +0530 Subject: [PATCH 1/5] fix(structural): preserve binary snapshot provenance --- README.md | 5 +++++ diffgraph/structural.py | 29 +++++++++++++++++++++++++++-- tests/test_structural.py | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 986200a..068e0a3 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,11 @@ add optional data without weakening validation for existing consumers. The canonical schema and a complete local-only example are packaged under `diffgraph/schema/`; neither contains AI-derived symbols or relationships. +Snapshots containing NUL bytes produce a scoped `PARTIAL_ANALYSIS` warning. +Their exact pre/post object IDs and content hashes remain in file evidence, +while language, line counts, symbols, and relationships are left unset rather +than treating binary bytes as source code. + ## 📊 Example Output The canonical HTML report includes: diff --git a/diffgraph/structural.py b/diffgraph/structural.py index 1aef770..0b6685f 100644 --- a/diffgraph/structural.py +++ b/diffgraph/structural.py @@ -97,6 +97,16 @@ def _line_counts( return added, removed +def _binary_sides(old: Optional[bytes], new: Optional[bytes]) -> Tuple[str, ...]: + """Return snapshot sides matching the deterministic NUL-byte heuristic.""" + sides = [] + if old is not None and b"\0" in old: + sides.append("pre-change") + if new is not None and b"\0" in new: + sides.append("post-change") + return tuple(sides) + + def _parser(): parser = getattr(_PARSER_STATE, "python_parser", None) if parser is not None: @@ -333,13 +343,18 @@ def analyze_local_diff( ) or ( new is None and entry.new_oid is not None ) + binary_sides = () if snapshot_missing else _binary_sides(old, new) lines_added, lines_removed = ( - (None, None) if snapshot_missing else _line_counts(old, new) + (None, None) if snapshot_missing or binary_sides else _line_counts(old, new) ) file_entry = { "id": "file::" + path, "path": path, "old_path": entry.old_path if entry.status in ("R", "C") else None, - "language": "python" if Path(path).suffix.lower() == ".py" else None, + "language": ( + "python" + if not binary_sides and Path(path).suffix.lower() == ".py" + else None + ), "change_kind": _change_kind(entry.status, entry.old_oid, entry.new_oid), "lines_added": lines_added, "lines_removed": lines_removed, @@ -347,6 +362,16 @@ def analyze_local_diff( "evidence": [{"kind": "git_diff_name_status", "detail": _provenance(entry, old, new)}], } files.append(file_entry) + if binary_sides: + skipped += 1 + warnings.append(_warning( + "PARTIAL_ANALYSIS", + path, + "Binary content detected in {} snapshot; structural parsing and line counts were skipped.".format( + " and ".join(binary_sides) + ), + )) + continue if file_entry["language"] != "python": skipped += 1 warnings.append(_warning("UNSUPPORTED_LANGUAGE", path, "Deterministic extraction currently supports Python (.py) only.")) diff --git a/tests/test_structural.py b/tests/test_structural.py index c272c77..7b80a92 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -1,3 +1,4 @@ +import hashlib import json import os import subprocess @@ -146,6 +147,43 @@ def test_unsupported_language_is_explicit_and_not_overclaimed(tmp_path): assert "Python" in artifact["metadata"]["warnings"][0]["detail"] +@pytest.mark.parametrize("staged", [False, True]) +def test_binary_python_snapshot_preserves_identity_without_parsing(tmp_path, staged): + root = repo(tmp_path) + write(root, "payload.py", "def previous():\n return 1\n") + commit(root) + original = b"def previous():\n return 1\n" + binary = b"\x89PNG\r\n\x1a\n\x00not-python\xff\n" + (root / "payload.py").write_bytes(binary) + if staged: + git(root, "add", "--", "payload.py") + + artifact = analyze_local_diff(str(root), staged=staged) + + assert_valid(artifact) + assert artifact["schema_version"] == "2.0" + assert artifact["symbols"] == [] + assert artifact["relationships"] == [] + assert artifact["metadata"]["files_analyzed"] == 0 + assert artifact["metadata"]["files_skipped"] == 1 + file_entry = artifact["files"][0] + assert file_entry["language"] is None + assert file_entry["lines_added"] is None + assert file_entry["lines_removed"] is None + provenance = json.loads(file_entry["evidence"][0]["detail"]) + assert provenance["old_oid"] == git(root, "rev-parse", "HEAD:payload.py") + assert provenance["new_oid"] == git(root, "hash-object", "--path=payload.py", "payload.py") + assert provenance["old_mode"] == "100644" + assert provenance["new_mode"] == "100644" + assert provenance["old_sha256"] == hashlib.sha256(original).hexdigest() + assert provenance["new_sha256"] == hashlib.sha256(binary).hexdigest() + assert artifact["metadata"]["warnings"] == [{ + "code": "PARTIAL_ANALYSIS", + "file": "payload.py", + "detail": "Binary content detected in post-change snapshot; structural parsing and line counts were skipped.", + }] + + def test_file_fallback_reports_structural_line_statistics(tmp_path, monkeypatch): from click.testing import CliRunner from diffgraph.cli import main From b53c78e0db012ca71f6a83efbf387a0d85b7852f Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Sun, 16 Aug 2026 01:33:30 +0530 Subject: [PATCH 2/5] test(structural): cover pre-change binary snapshots --- tests/test_structural.py | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/test_structural.py b/tests/test_structural.py index 7b80a92..6456adf 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -184,6 +184,49 @@ def test_binary_python_snapshot_preserves_identity_without_parsing(tmp_path, sta }] +@pytest.mark.parametrize( + ("old_content", "new_content", "binary_sides"), + [ + (b"\x00old-binary\xff\n", b"def readable():\n return 1\n", "pre-change"), + (b"\x00old-binary\xff\n", b"\x00new-binary\xfe\n", "pre-change and post-change"), + ], + ids=["binary-to-text", "binary-to-binary"], +) +def test_pre_change_binary_snapshots_skip_all_source_analysis( + tmp_path, old_content, new_content, binary_sides +): + root = repo(tmp_path) + path = root / "payload.py" + path.write_bytes(old_content) + commit(root) + path.write_bytes(new_content) + + artifact = analyze_local_diff(str(root)) + + assert_valid(artifact) + assert artifact["symbols"] == [] + assert artifact["relationships"] == [] + assert artifact["metadata"]["files_analyzed"] == 0 + assert artifact["metadata"]["files_skipped"] == 1 + file_entry = artifact["files"][0] + assert file_entry["language"] is None + assert file_entry["lines_added"] is None + assert file_entry["lines_removed"] is None + provenance = json.loads(file_entry["evidence"][0]["detail"]) + assert provenance["old_oid"] == git(root, "rev-parse", "HEAD:payload.py") + assert provenance["new_oid"] == git(root, "hash-object", "--path=payload.py", "payload.py") + assert provenance["old_sha256"] == hashlib.sha256(old_content).hexdigest() + assert provenance["new_sha256"] == hashlib.sha256(new_content).hexdigest() + assert artifact["metadata"]["warnings"] == [{ + "code": "PARTIAL_ANALYSIS", + "file": "payload.py", + "detail": ( + f"Binary content detected in {binary_sides} snapshot; " + "structural parsing and line counts were skipped." + ), + }] + + def test_file_fallback_reports_structural_line_statistics(tmp_path, monkeypatch): from click.testing import CliRunner from diffgraph.cli import main From 53cd5f39405fcbed215c9f5888c3fae697361c2e Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Sun, 16 Aug 2026 05:33:28 +0530 Subject: [PATCH 3/5] test(structural): distinguish index and worktree binary snapshots --- tests/test_structural.py | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_structural.py b/tests/test_structural.py index 6456adf..91533b3 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -227,6 +227,47 @@ def test_pre_change_binary_snapshots_skip_all_source_analysis( }] +def test_unstaged_binary_snapshot_uses_index_before_worktree(tmp_path): + root = repo(tmp_path) + path = root / "payload.py" + committed = b"def committed():\n return 1\n" + staged_binary = b"\x00staged-binary\xff\n" + worktree_text = b"def worktree():\n return 2\n" + path.write_bytes(committed) + commit(root) + path.write_bytes(staged_binary) + git(root, "add", "--", "payload.py") + index_oid = git(root, "rev-parse", ":payload.py") + path.write_bytes(worktree_text) + + artifact = analyze_local_diff(str(root)) + + assert_valid(artifact) + assert artifact["symbols"] == [] + assert artifact["relationships"] == [] + assert artifact["metadata"]["files_analyzed"] == 0 + assert artifact["metadata"]["files_skipped"] == 1 + file_entry = artifact["files"][0] + assert file_entry["language"] is None + assert file_entry["lines_added"] is None + assert file_entry["lines_removed"] is None + provenance = json.loads(file_entry["evidence"][0]["detail"]) + assert provenance["old_oid"] == index_oid + assert provenance["new_oid"] == git( + root, "hash-object", "--path=payload.py", "payload.py" + ) + assert provenance["old_oid"] != git(root, "rev-parse", "HEAD:payload.py") + assert provenance["old_mode"] == "100644" + assert provenance["new_mode"] == "100644" + assert provenance["old_sha256"] == hashlib.sha256(staged_binary).hexdigest() + assert provenance["new_sha256"] == hashlib.sha256(worktree_text).hexdigest() + assert artifact["metadata"]["warnings"] == [{ + "code": "PARTIAL_ANALYSIS", + "file": "payload.py", + "detail": "Binary content detected in pre-change snapshot; structural parsing and line counts were skipped.", + }] + + def test_file_fallback_reports_structural_line_statistics(tmp_path, monkeypatch): from click.testing import CliRunner from diffgraph.cli import main From 4be75e574a645ea876d9ceb711fe4e4cf5793f39 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Mon, 17 Aug 2026 15:04:36 +0530 Subject: [PATCH 4/5] test(structural): cover one-sided binary snapshots --- tests/test_structural.py | 55 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/test_structural.py b/tests/test_structural.py index 91533b3..e61ab06 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -227,6 +227,61 @@ def test_pre_change_binary_snapshots_skip_all_source_analysis( }] +@pytest.mark.parametrize("change_kind", ["added", "deleted"]) +def test_added_and_deleted_binary_snapshots_preserve_one_sided_identity( + tmp_path, change_kind +): + root = repo(tmp_path) + binary = b"\x00binary-snapshot\xff\n" + path = root / "payload.py" + write(root, "anchor.txt", "committed\n") + if change_kind == "deleted": + path.write_bytes(binary) + commit(root) + + if change_kind == "added": + path.write_bytes(binary) + git(root, "add", "--", "payload.py") + else: + path.unlink() + + artifact = analyze_local_diff(str(root), staged=change_kind == "added") + + assert_valid(artifact) + assert artifact["symbols"] == [] + assert artifact["relationships"] == [] + assert artifact["metadata"]["files_analyzed"] == 0 + assert artifact["metadata"]["files_skipped"] == 1 + file_entry = artifact["files"][0] + assert file_entry["change_kind"] == change_kind + assert file_entry["language"] is None + assert file_entry["lines_added"] is None + assert file_entry["lines_removed"] is None + provenance = json.loads(file_entry["evidence"][0]["detail"]) + binary_oid = ( + git(root, "hash-object", "--path=payload.py", "payload.py") + if change_kind == "added" + else git(root, "rev-parse", "HEAD:payload.py") + ) + binary_side = "new" if change_kind == "added" else "old" + absent_side = "old" if change_kind == "added" else "new" + assert provenance[f"{binary_side}_oid"] == binary_oid + assert provenance[f"{binary_side}_mode"] == "100644" + assert provenance[f"{binary_side}_sha256"] == hashlib.sha256(binary).hexdigest() + assert provenance[f"{absent_side}_oid"] is None + assert provenance[f"{absent_side}_mode"] is None + assert provenance[f"{absent_side}_sha256"] is None + warning_side = "post-change" if change_kind == "added" else "pre-change" + assert artifact["metadata"]["warnings"] == [{ + "code": "PARTIAL_ANALYSIS", + "file": "payload.py", + "detail": ( + f"Binary content detected in {warning_side} snapshot; " + "structural parsing and line counts were skipped." + ), + }] + + def test_unstaged_binary_snapshot_uses_index_before_worktree(tmp_path): root = repo(tmp_path) path = root / "payload.py" From f29c7944854673328d721bd8a007740366ae2cf2 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Mon, 17 Aug 2026 21:05:05 +0530 Subject: [PATCH 5/5] test(structural): document binary evidence cases --- tests/test_structural.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_structural.py b/tests/test_structural.py index e61ab06..1dcb041 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -149,6 +149,7 @@ def test_unsupported_language_is_explicit_and_not_overclaimed(tmp_path): @pytest.mark.parametrize("staged", [False, True]) def test_binary_python_snapshot_preserves_identity_without_parsing(tmp_path, staged): + """Binary snapshots retain exact evidence without source-level claims.""" root = repo(tmp_path) write(root, "payload.py", "def previous():\n return 1\n") commit(root) @@ -195,6 +196,7 @@ def test_binary_python_snapshot_preserves_identity_without_parsing(tmp_path, sta def test_pre_change_binary_snapshots_skip_all_source_analysis( tmp_path, old_content, new_content, binary_sides ): + """Pre-change binary content suppresses analysis for either or both sides.""" root = repo(tmp_path) path = root / "payload.py" path.write_bytes(old_content) @@ -231,6 +233,7 @@ def test_pre_change_binary_snapshots_skip_all_source_analysis( def test_added_and_deleted_binary_snapshots_preserve_one_sided_identity( tmp_path, change_kind ): + """One-sided binary changes preserve evidence only for the present side.""" root = repo(tmp_path) binary = b"\x00binary-snapshot\xff\n" path = root / "payload.py" @@ -283,6 +286,7 @@ def test_added_and_deleted_binary_snapshots_preserve_one_sided_identity( def test_unstaged_binary_snapshot_uses_index_before_worktree(tmp_path): + """Unstaged evidence compares the index snapshot with the worktree.""" root = repo(tmp_path) path = root / "payload.py" committed = b"def committed():\n return 1\n"