mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
DiffCompressor: - Parse unified diff format and compress by reducing context lines - Preserve file headers and all +/- change lines - Score hunks by relevance (error keywords, query matches) - Add summary line: [N files, +X -Y lines] - Expected 30-50% savings on typical git diffs - Wire into content router for CompressionStrategy.DIFF - 30 tests covering parsing, compression, edge cases hnswlib SIGILL fix: - Move hnswlib import from module level to lazy loading - hnswlib crashes with SIGILL (Illegal Instruction) on CPUs without AVX support, before Python can catch the error - Now imports only when HNSWVectorIndex is actually used - HNSW_AVAILABLE is checked lazily via __getattr__ Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
673 lines
18 KiB
Python
673 lines
18 KiB
Python
"""Comprehensive tests for diff_compressor.py.
|
|
|
|
Tests cover:
|
|
1. Parsing of unified diff format
|
|
2. Context line reduction
|
|
3. Hunk selection and limiting
|
|
4. Compression ratios
|
|
5. Edge cases
|
|
"""
|
|
|
|
from headroom.transforms.diff_compressor import (
|
|
DiffCompressionResult,
|
|
DiffCompressor,
|
|
DiffCompressorConfig,
|
|
DiffFile,
|
|
DiffHunk,
|
|
)
|
|
|
|
|
|
class TestDiffParsing:
|
|
"""Tests for parsing unified diff format."""
|
|
|
|
def test_parse_simple_diff(self):
|
|
"""Simple single-file diff is parsed correctly."""
|
|
content = """diff --git a/src/main.py b/src/main.py
|
|
--- a/src/main.py
|
|
+++ b/src/main.py
|
|
@@ -10,6 +10,7 @@ def main():
|
|
print("hello")
|
|
+ print("world")
|
|
return 0
|
|
"""
|
|
compressor = DiffCompressor()
|
|
diff_files = compressor._parse_diff(content.split("\n"))
|
|
|
|
assert len(diff_files) == 1
|
|
assert diff_files[0].header == "diff --git a/src/main.py b/src/main.py"
|
|
assert diff_files[0].old_file == "--- a/src/main.py"
|
|
assert diff_files[0].new_file == "+++ b/src/main.py"
|
|
assert len(diff_files[0].hunks) == 1
|
|
assert diff_files[0].hunks[0].additions == 1
|
|
assert diff_files[0].hunks[0].deletions == 0
|
|
|
|
def test_parse_multi_file_diff(self):
|
|
"""Multi-file diff is parsed into separate DiffFile objects."""
|
|
content = """diff --git a/file1.py b/file1.py
|
|
--- a/file1.py
|
|
+++ b/file1.py
|
|
@@ -1,3 +1,4 @@
|
|
line1
|
|
+added line
|
|
line2
|
|
diff --git a/file2.py b/file2.py
|
|
--- a/file2.py
|
|
+++ b/file2.py
|
|
@@ -5,4 +5,3 @@
|
|
keep
|
|
-removed
|
|
keep2
|
|
"""
|
|
compressor = DiffCompressor()
|
|
diff_files = compressor._parse_diff(content.split("\n"))
|
|
|
|
assert len(diff_files) == 2
|
|
assert "file1.py" in diff_files[0].header
|
|
assert "file2.py" in diff_files[1].header
|
|
assert diff_files[0].hunks[0].additions == 1
|
|
assert diff_files[0].hunks[0].deletions == 0
|
|
assert diff_files[1].hunks[0].additions == 0
|
|
assert diff_files[1].hunks[0].deletions == 1
|
|
|
|
def test_parse_multi_hunk_file(self):
|
|
"""File with multiple hunks is parsed correctly."""
|
|
content = """diff --git a/src/utils.py b/src/utils.py
|
|
--- a/src/utils.py
|
|
+++ b/src/utils.py
|
|
@@ -10,4 +10,5 @@ def helper():
|
|
pass
|
|
+ # added comment
|
|
return True
|
|
@@ -50,3 +51,4 @@ def other():
|
|
x = 1
|
|
+ y = 2
|
|
return x
|
|
"""
|
|
compressor = DiffCompressor()
|
|
diff_files = compressor._parse_diff(content.split("\n"))
|
|
|
|
assert len(diff_files) == 1
|
|
assert len(diff_files[0].hunks) == 2
|
|
assert diff_files[0].total_additions == 2
|
|
|
|
def test_parse_new_file(self):
|
|
"""New file diff is detected."""
|
|
content = """diff --git a/newfile.py b/newfile.py
|
|
new file mode 100644
|
|
--- /dev/null
|
|
+++ b/newfile.py
|
|
@@ -0,0 +1,3 @@
|
|
+def new_func():
|
|
+ pass
|
|
+ return None
|
|
"""
|
|
compressor = DiffCompressor()
|
|
diff_files = compressor._parse_diff(content.split("\n"))
|
|
|
|
assert len(diff_files) == 1
|
|
assert diff_files[0].is_new_file is True
|
|
assert diff_files[0].hunks[0].additions == 3
|
|
|
|
def test_parse_deleted_file(self):
|
|
"""Deleted file diff is detected."""
|
|
content = """diff --git a/oldfile.py b/oldfile.py
|
|
deleted file mode 100644
|
|
--- a/oldfile.py
|
|
+++ /dev/null
|
|
@@ -1,2 +0,0 @@
|
|
-def old_func():
|
|
- pass
|
|
"""
|
|
compressor = DiffCompressor()
|
|
diff_files = compressor._parse_diff(content.split("\n"))
|
|
|
|
assert len(diff_files) == 1
|
|
assert diff_files[0].is_deleted_file is True
|
|
assert diff_files[0].hunks[0].deletions == 2
|
|
|
|
def test_parse_binary_file(self):
|
|
"""Binary file diff is detected."""
|
|
content = """diff --git a/image.png b/image.png
|
|
Binary files a/image.png and b/image.png differ
|
|
"""
|
|
compressor = DiffCompressor()
|
|
diff_files = compressor._parse_diff(content.split("\n"))
|
|
|
|
assert len(diff_files) == 1
|
|
assert diff_files[0].is_binary is True
|
|
|
|
|
|
class TestContextReduction:
|
|
"""Tests for context line reduction."""
|
|
|
|
def test_reduce_context_lines(self):
|
|
"""Context lines are reduced to configured maximum."""
|
|
content = """diff --git a/file.py b/file.py
|
|
--- a/file.py
|
|
+++ b/file.py
|
|
@@ -1,10 +1,11 @@
|
|
context1
|
|
context2
|
|
context3
|
|
context4
|
|
+added
|
|
context5
|
|
context6
|
|
context7
|
|
context8
|
|
"""
|
|
# Default max_context_lines is 2
|
|
compressor = DiffCompressor(
|
|
config=DiffCompressorConfig(
|
|
max_context_lines=2,
|
|
min_lines_for_ccr=5,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Should keep 2 context before and 2 after the +added line
|
|
# Plus the added line itself
|
|
lines = result.compressed.split("\n")
|
|
context_count = sum(1 for line in lines if line.startswith(" "))
|
|
|
|
# At most 4 context lines (2 before + 2 after)
|
|
assert context_count <= 4
|
|
|
|
def test_preserve_all_changes(self):
|
|
"""All addition and deletion lines are preserved."""
|
|
content = """diff --git a/file.py b/file.py
|
|
--- a/file.py
|
|
+++ b/file.py
|
|
@@ -1,10 +1,10 @@
|
|
ctx1
|
|
ctx2
|
|
-removed1
|
|
+added1
|
|
ctx3
|
|
ctx4
|
|
-removed2
|
|
+added2
|
|
ctx5
|
|
ctx6
|
|
"""
|
|
compressor = DiffCompressor(
|
|
config=DiffCompressorConfig(
|
|
min_lines_for_ccr=5,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
assert "-removed1" in result.compressed
|
|
assert "-removed2" in result.compressed
|
|
assert "+added1" in result.compressed
|
|
assert "+added2" in result.compressed
|
|
|
|
|
|
class TestHunkSelection:
|
|
"""Tests for hunk selection when limiting."""
|
|
|
|
def test_max_hunks_per_file(self):
|
|
"""Hunks are limited to max_hunks_per_file."""
|
|
# Create a diff with many hunks
|
|
hunks = []
|
|
for i in range(20):
|
|
hunks.append(f"""@@ -{i * 10},3 +{i * 10},4 @@
|
|
context
|
|
+added_{i}
|
|
more
|
|
""")
|
|
|
|
content = f"""diff --git a/bigfile.py b/bigfile.py
|
|
--- a/bigfile.py
|
|
+++ b/bigfile.py
|
|
{"".join(hunks)}"""
|
|
|
|
compressor = DiffCompressor(
|
|
config=DiffCompressorConfig(
|
|
max_hunks_per_file=5,
|
|
min_lines_for_ccr=10,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Should have at most 5 hunks
|
|
hunk_count = result.compressed.count("@@")
|
|
# Each hunk has one @@ header (we count full hunk headers)
|
|
assert hunk_count <= 10 # Each hunk header appears twice @@...@@
|
|
|
|
def test_keeps_first_and_last_hunk(self):
|
|
"""First and last hunks are preserved when limiting."""
|
|
hunks = []
|
|
for i in range(10):
|
|
hunks.append(f"""@@ -{i * 10},3 +{i * 10},4 @@
|
|
context
|
|
+added_{i}
|
|
more
|
|
""")
|
|
|
|
content = f"""diff --git a/file.py b/file.py
|
|
--- a/file.py
|
|
+++ b/file.py
|
|
{"".join(hunks)}"""
|
|
|
|
compressor = DiffCompressor(
|
|
config=DiffCompressorConfig(
|
|
max_hunks_per_file=3,
|
|
min_lines_for_ccr=10,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# First hunk (added_0) should be present
|
|
assert "+added_0" in result.compressed
|
|
# Last hunk (added_9) should be present
|
|
assert "+added_9" in result.compressed
|
|
|
|
|
|
class TestFileSelection:
|
|
"""Tests for file selection when limiting."""
|
|
|
|
def test_max_files(self):
|
|
"""Files are limited to max_files."""
|
|
# Create diff with many files
|
|
files = []
|
|
for i in range(30):
|
|
files.append(f"""diff --git a/file{i}.py b/file{i}.py
|
|
--- a/file{i}.py
|
|
+++ b/file{i}.py
|
|
@@ -1,2 +1,3 @@
|
|
ctx
|
|
+added
|
|
ctx2
|
|
""")
|
|
|
|
content = "\n".join(files)
|
|
|
|
compressor = DiffCompressor(
|
|
config=DiffCompressorConfig(
|
|
max_files=10,
|
|
min_lines_for_ccr=20,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Count diff --git headers
|
|
file_count = result.compressed.count("diff --git")
|
|
assert file_count <= 10
|
|
|
|
|
|
class TestCompressionResult:
|
|
"""Tests for DiffCompressionResult properties."""
|
|
|
|
def test_compression_ratio_calculation(self):
|
|
"""Compression ratio is calculated correctly."""
|
|
result = DiffCompressionResult(
|
|
compressed="a\nb\nc",
|
|
original_line_count=100,
|
|
compressed_line_count=10,
|
|
files_affected=2,
|
|
additions=5,
|
|
deletions=3,
|
|
hunks_kept=2,
|
|
hunks_removed=5,
|
|
)
|
|
|
|
assert result.compression_ratio == 0.1
|
|
|
|
def test_tokens_saved_estimate(self):
|
|
"""Token savings estimation works correctly."""
|
|
result = DiffCompressionResult(
|
|
compressed="short",
|
|
original_line_count=100,
|
|
compressed_line_count=10,
|
|
files_affected=1,
|
|
additions=10,
|
|
deletions=5,
|
|
hunks_kept=1,
|
|
hunks_removed=0,
|
|
)
|
|
|
|
# 90 lines saved * 40 chars/line / 4 chars/token = 900 tokens
|
|
assert result.tokens_saved_estimate == 900
|
|
|
|
|
|
class TestHunkScoring:
|
|
"""Tests for context-aware hunk scoring."""
|
|
|
|
def test_score_by_context_keywords(self):
|
|
"""Hunks containing context keywords get higher scores."""
|
|
content = """diff --git a/file.py b/file.py
|
|
--- a/file.py
|
|
+++ b/file.py
|
|
@@ -1,3 +1,4 @@
|
|
normal context
|
|
+normal change
|
|
more context
|
|
@@ -10,3 +11,4 @@
|
|
error handling
|
|
+fix the bug here
|
|
return result
|
|
"""
|
|
compressor = DiffCompressor()
|
|
diff_files = compressor._parse_diff(content.split("\n"))
|
|
compressor._score_hunks(diff_files, "fix error bug")
|
|
|
|
# Second hunk should have higher score (contains "fix" and "bug")
|
|
assert len(diff_files[0].hunks) == 2
|
|
assert diff_files[0].hunks[1].score > diff_files[0].hunks[0].score
|
|
|
|
def test_score_priority_patterns(self):
|
|
"""Hunks with priority patterns (error, security) score higher."""
|
|
compressor = DiffCompressor()
|
|
|
|
hunk_normal = DiffHunk(
|
|
header="@@ -1,1 +1,2 @@",
|
|
lines=["+normal change"],
|
|
additions=1,
|
|
)
|
|
hunk_error = DiffHunk(
|
|
header="@@ -10,1 +10,2 @@",
|
|
lines=["+fix critical error"],
|
|
additions=1,
|
|
)
|
|
|
|
diff_file = DiffFile(
|
|
header="diff --git a/f.py b/f.py",
|
|
old_file="--- a/f.py",
|
|
new_file="+++ b/f.py",
|
|
hunks=[hunk_normal, hunk_error],
|
|
)
|
|
|
|
compressor._score_hunks([diff_file], "")
|
|
|
|
assert hunk_error.score > hunk_normal.score
|
|
|
|
|
|
class TestSmallDiffPassthrough:
|
|
"""Tests for small diff passthrough behavior."""
|
|
|
|
def test_small_diff_unchanged(self):
|
|
"""Diffs smaller than threshold pass through unchanged."""
|
|
content = """diff --git a/small.py b/small.py
|
|
--- a/small.py
|
|
+++ b/small.py
|
|
@@ -1,2 +1,3 @@
|
|
line1
|
|
+added
|
|
line2
|
|
"""
|
|
compressor = DiffCompressor(
|
|
config=DiffCompressorConfig(
|
|
min_lines_for_ccr=100, # High threshold
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Should be unchanged
|
|
assert result.compressed == content
|
|
assert result.compression_ratio == 1.0
|
|
|
|
|
|
class TestOutputFormatting:
|
|
"""Tests for output formatting."""
|
|
|
|
def test_summary_line_added(self):
|
|
"""Summary line is added at end of compressed diff."""
|
|
# Large diff that will be compressed
|
|
hunks = []
|
|
for i in range(15):
|
|
hunks.append(f"""@@ -{i * 10},5 +{i * 10},6 @@
|
|
ctx1
|
|
ctx2
|
|
+added_{i}
|
|
ctx3
|
|
ctx4
|
|
""")
|
|
|
|
content = f"""diff --git a/file.py b/file.py
|
|
--- a/file.py
|
|
+++ b/file.py
|
|
{"".join(hunks)}"""
|
|
|
|
compressor = DiffCompressor(
|
|
config=DiffCompressorConfig(
|
|
max_hunks_per_file=5,
|
|
min_lines_for_ccr=10,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Should have summary at end
|
|
assert "files changed" in result.compressed
|
|
assert "hunks omitted" in result.compressed
|
|
|
|
def test_preserves_diff_format(self):
|
|
"""Output preserves valid unified diff format."""
|
|
content = """diff --git a/test.py b/test.py
|
|
--- a/test.py
|
|
+++ b/test.py
|
|
@@ -1,3 +1,4 @@
|
|
def test():
|
|
+ # new comment
|
|
pass
|
|
return True
|
|
"""
|
|
compressor = DiffCompressor(
|
|
config=DiffCompressorConfig(
|
|
min_lines_for_ccr=5,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Should have all standard diff markers
|
|
assert "diff --git" in result.compressed
|
|
assert "---" in result.compressed
|
|
assert "+++" in result.compressed
|
|
assert "@@" in result.compressed
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Tests for edge cases and boundary conditions."""
|
|
|
|
def test_empty_input(self):
|
|
"""Empty input is handled gracefully."""
|
|
compressor = DiffCompressor()
|
|
result = compressor.compress("")
|
|
|
|
assert result.compressed == ""
|
|
assert result.compression_ratio == 1.0
|
|
|
|
def test_non_diff_input(self):
|
|
"""Non-diff input passes through unchanged."""
|
|
content = "This is not a diff\nJust regular text"
|
|
compressor = DiffCompressor()
|
|
result = compressor.compress(content)
|
|
|
|
# Should pass through (no diff --git found)
|
|
assert result.compressed == content
|
|
|
|
def test_unicode_content(self):
|
|
"""Unicode characters in diff are handled."""
|
|
content = """diff --git a/i18n.py b/i18n.py
|
|
--- a/i18n.py
|
|
+++ b/i18n.py
|
|
@@ -1,2 +1,3 @@
|
|
msg = "hello"
|
|
+msg_ja = "こんにちは"
|
|
return msg
|
|
"""
|
|
compressor = DiffCompressor()
|
|
result = compressor.compress(content)
|
|
|
|
assert "こんにちは" in result.compressed
|
|
|
|
def test_no_newline_at_eof(self):
|
|
"""Handles 'No newline at end of file' indicator."""
|
|
content = """diff --git a/file.py b/file.py
|
|
--- a/file.py
|
|
+++ b/file.py
|
|
@@ -1,2 +1,2 @@
|
|
line1
|
|
-line2
|
|
\\ No newline at end of file
|
|
+line2_modified
|
|
\\ No newline at end of file
|
|
"""
|
|
compressor = DiffCompressor()
|
|
result = compressor.compress(content)
|
|
|
|
# Should not crash and preserve the indicator
|
|
assert "No newline" in result.compressed or "-line2" in result.compressed
|
|
|
|
def test_empty_hunks(self):
|
|
"""Files with no actual hunks are handled."""
|
|
content = """diff --git a/file.py b/file.py
|
|
--- a/file.py
|
|
+++ b/file.py
|
|
"""
|
|
compressor = DiffCompressor()
|
|
result = compressor.compress(content)
|
|
|
|
# Should not crash
|
|
assert result.compressed is not None
|
|
|
|
|
|
class TestDiffHunkDataclass:
|
|
"""Tests for DiffHunk dataclass."""
|
|
|
|
def test_change_count_property(self):
|
|
"""change_count returns sum of additions and deletions."""
|
|
hunk = DiffHunk(
|
|
header="@@ -1,5 +1,6 @@",
|
|
lines=["+a", "+b", "-c", " ctx"],
|
|
additions=2,
|
|
deletions=1,
|
|
)
|
|
assert hunk.change_count == 3
|
|
|
|
def test_default_values(self):
|
|
"""DiffHunk default values are correct."""
|
|
hunk = DiffHunk(header="@@", lines=[])
|
|
assert hunk.additions == 0
|
|
assert hunk.deletions == 0
|
|
assert hunk.context_lines == 0
|
|
assert hunk.score == 0.0
|
|
|
|
|
|
class TestDiffFileDataclass:
|
|
"""Tests for DiffFile dataclass."""
|
|
|
|
def test_total_additions_property(self):
|
|
"""total_additions sums across all hunks."""
|
|
hunk1 = DiffHunk(header="@@", lines=[], additions=3)
|
|
hunk2 = DiffHunk(header="@@", lines=[], additions=5)
|
|
diff_file = DiffFile(
|
|
header="diff --git",
|
|
old_file="---",
|
|
new_file="+++",
|
|
hunks=[hunk1, hunk2],
|
|
)
|
|
assert diff_file.total_additions == 8
|
|
|
|
def test_total_deletions_property(self):
|
|
"""total_deletions sums across all hunks."""
|
|
hunk1 = DiffHunk(header="@@", lines=[], deletions=2)
|
|
hunk2 = DiffHunk(header="@@", lines=[], deletions=4)
|
|
diff_file = DiffFile(
|
|
header="diff --git",
|
|
old_file="---",
|
|
new_file="+++",
|
|
hunks=[hunk1, hunk2],
|
|
)
|
|
assert diff_file.total_deletions == 6
|
|
|
|
|
|
class TestConfigOptions:
|
|
"""Tests for configuration options."""
|
|
|
|
def test_max_context_lines_config(self):
|
|
"""max_context_lines configuration controls context reduction."""
|
|
content = """diff --git a/file.py b/file.py
|
|
--- a/file.py
|
|
+++ b/file.py
|
|
@@ -1,10 +1,11 @@
|
|
c1
|
|
c2
|
|
c3
|
|
c4
|
|
c5
|
|
+added
|
|
c6
|
|
c7
|
|
c8
|
|
c9
|
|
c10
|
|
"""
|
|
# With max_context_lines=1
|
|
compressor = DiffCompressor(
|
|
config=DiffCompressorConfig(
|
|
max_context_lines=1,
|
|
min_lines_for_ccr=5,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
# Count context lines (lines starting with space)
|
|
context_count = sum(1 for line in result.compressed.split("\n") if line.startswith(" "))
|
|
|
|
# Should have at most 2 context lines (1 before + 1 after)
|
|
assert context_count <= 2
|
|
|
|
def test_always_keep_additions_default(self):
|
|
"""Additions are always kept by default."""
|
|
content = """diff --git a/file.py b/file.py
|
|
--- a/file.py
|
|
+++ b/file.py
|
|
@@ -1,3 +1,5 @@
|
|
ctx
|
|
+add1
|
|
+add2
|
|
ctx
|
|
"""
|
|
compressor = DiffCompressor(
|
|
config=DiffCompressorConfig(
|
|
always_keep_additions=True,
|
|
min_lines_for_ccr=2,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
assert "+add1" in result.compressed
|
|
assert "+add2" in result.compressed
|
|
|
|
def test_always_keep_deletions_default(self):
|
|
"""Deletions are always kept by default."""
|
|
content = """diff --git a/file.py b/file.py
|
|
--- a/file.py
|
|
+++ b/file.py
|
|
@@ -1,5 +1,3 @@
|
|
ctx
|
|
-del1
|
|
-del2
|
|
ctx
|
|
"""
|
|
compressor = DiffCompressor(
|
|
config=DiffCompressorConfig(
|
|
always_keep_deletions=True,
|
|
min_lines_for_ccr=2,
|
|
enable_ccr=False,
|
|
)
|
|
)
|
|
result = compressor.compress(content)
|
|
|
|
assert "-del1" in result.compressed
|
|
assert "-del2" in result.compressed
|