Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 27 additions & 2 deletions diffgraph/structural.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -333,20 +343,35 @@ 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,
"analysis_source": "structural",
"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."))
Expand Down
181 changes: 181 additions & 0 deletions tests/test_structural.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import hashlib
import json
import os
import subprocess
Expand Down Expand Up @@ -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
Expand Down
Loading