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..1dcb041 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,186 @@ 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): + """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) + 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.", + }] + + +@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 +): + """Pre-change binary content suppresses analysis for either or both 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." + ), + }] + + +@pytest.mark.parametrize("change_kind", ["added", "deleted"]) +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" + 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): + """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" + 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