Compare commits

..

1 Commits

Author SHA1 Message Date
Step35
1470b44c3b feat: add codebase genome diff script for structural change detection
Some checks failed
Test / pytest (pull_request) Failing after 9s
Introduces genome_diff.py — a tool for detecting structural changes between
two git refs: file-level changes, function/class signature modifications,
and dependency import changes.

Addresses #132.
2026-04-26 09:46:04 -04:00
4 changed files with 288 additions and 424 deletions

288
scripts/genome_diff.py Executable file
View File

@@ -0,0 +1,288 @@
#!/usr/bin/env python3
"""
Codebase Genome Diff — Detect structural changes between two versions.
Compares two git refs (commits, branches, tags) and produces a human-readable
report of structural changes:
• Added/removed/renamed files
• Changed functions/classes (signature modifications)
• New dependencies (imports, requirements, etc.)
Usage:
python3 scripts/genome_diff.py --ref1 <commit1> --ref2 <commit2>
python3 scripts/genome_diff.py --ref1 main --ref2 feature-branch
python3 scripts/genome_diff.py --ref1 v1.0 --ref2 v2.0 --output report.txt
"""
import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPT_DIR)
from diff_analyzer import DiffAnalyzer, ChangeCategory
@dataclass
class FunctionChange:
file: str
name: str
kind: str # 'function' or 'class'
change_type: str # 'added' or 'removed' (simplified)
old_line: Optional[int] = None
new_line: Optional[int] = None
@dataclass
class DependencyChange:
file: str
module: str
change_type: str # 'added' or 'removed' or 'modified'
line: int = 0
@dataclass
class GenomeDiffReport:
ref1: str
ref2: str
file_changes: List[Dict[str, Any]] = field(default_factory=list)
function_changes: List[FunctionChange] = field(default_factory=list)
dependency_changes: List[DependencyChange] = field(default_factory=list)
total_files_changed: int = 0
total_functions_changed: int = 0
total_dependencies_changed: int = 0
def to_dict(self) -> Dict[str, Any]:
return {
"ref1": self.ref1,
"ref2": self.ref2,
"summary": {
"files": self.total_files_changed,
"functions": self.total_functions_changed,
"dependencies": self.total_dependencies_changed,
},
"file_changes": self.file_changes,
"function_changes": [fc.__dict__ for fc in self.function_changes],
"dependency_changes": [dc.__dict__ for dc in self.dependency_changes],
}
def human_report(self) -> str:
lines = []
lines.append(f"Codebase Genome Diff: {self.ref1}{self.ref2}")
lines.append("=" * 60)
lines.append(f" Files changed: {self.total_files_changed}")
lines.append(f" Functions changed: {self.total_functions_changed}")
lines.append(f" Dependencies changed: {self.total_dependencies_changed}")
lines.append("")
for fc in self.file_changes:
kind = []
if fc.get('is_new'):
kind.append("NEW")
if fc.get('is_deleted'):
kind.append("DELETED")
if fc.get('is_renamed'):
kind.append("RENAMED")
if fc.get('is_binary'):
kind.append("BINARY")
kind_str = f" [{', '.join(kind)}]" if kind else ""
lines.append(f" {fc['path']}{kind_str} (+{fc['added_lines']}/-{fc['deleted_lines']})")
lines.append("")
for fc in self.function_changes:
op = {'added': '+', 'removed': '-', 'modified': '~'}.get(fc.change_type, '?')
lines.append(f" [{op}] {fc.file}: {fc.kind} '{fc.name}'")
lines.append("")
for dc in self.dependency_changes:
op = '+' if dc.change_type == 'added' else '-'
lines.append(f" [{op}] {dc.file}: {dc.module}")
lines.append("")
return "\n".join(lines)
def run_git_diff(ref1: str, ref2: str) -> str:
result = subprocess.run(
['git', 'diff', '--unified=0', f'{ref1}...{ref2}'],
capture_output=True, text=True, cwd=SCRIPT_DIR
)
if result.returncode not in (0, 1):
print(f"git diff failed: {result.stderr}", file=sys.stderr)
sys.exit(1)
return result.stdout
def extract_function_changes(diff_text: str) -> List[FunctionChange]:
changes: List[FunctionChange] = []
pattern = re.compile(r'^([+\-])\s*(def|class)\s+(\w+)', re.MULTILINE)
hunk_header_re = re.compile(r'^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@')
current_old_line: Optional[int] = None
current_new_line: Optional[int] = None
for line in diff_text.split('\n'):
hdr = hunk_header_re.match(line)
if hdr:
current_old_line = int(hdr.group(1))
current_new_line = int(hdr.group(3))
continue
m = pattern.match(line)
if m:
op = m.group(1)
kind = m.group(2)
name = m.group(3)
change_type = "added" if op == '+' else "removed"
line_num = current_new_line if change_type == "added" else current_old_line
changes.append(FunctionChange(
file="<unknown>",
name=name,
kind=kind,
change_type=change_type,
new_line=line_num if change_type == "added" else None,
old_line=line_num if change_type == "removed" else None,
))
# Advance line counters heuristically
if op == '-':
if current_old_line is not None:
current_old_line += 1
elif op == '+':
if current_new_line is not None:
current_new_line += 1
elif line.startswith(' '):
if current_old_line is not None:
current_old_line += 1
if current_new_line is not None:
current_new_line += 1
# lines starting with other prefixes (like \\ No newline) ignored
return changes
def extract_dependency_changes(diff_text: str, analyzer: DiffAnalyzer) -> List[DependencyChange]:
changes: List[DependencyChange] = []
import_pattern = re.compile(
r'^([+\-])\s*(?:import\s+([\w\.]+)|from\s+([\w\.]+)\s+import)',
re.MULTILINE
)
file_diffs = analyzer._split_files(diff_text)
for file_diff in file_diffs:
file_match = re.search(r'^diff --git a/.*? b/(.*?)$', file_diff, re.MULTILINE)
if not file_match:
continue
filepath = file_match.group(1)
# Scan each line for import changes
for line in file_diff.split('\n'):
m = import_pattern.match(line)
if m:
change_type = "added" if m.group(1) == '+' else "removed"
module = m.group(2) or m.group(3)
changes.append(DependencyChange(
file=filepath,
module=module,
change_type=change_type,
line=0
))
# Detect if this file is a dependency manifest
req_file_pattern = re.compile(
r'^[\+\-].*?(requirements(.*?)\.txt|pyproject\.toml|setup\.py|Pipfile)'
)
if any(req_file_pattern.match(line) for line in file_diff.split('\n')):
if not any(c.file == filepath and c.module == "<file>" for c in changes):
changes.append(DependencyChange(
file=filepath,
module="<file>",
change_type="modified",
line=0
))
return changes
def correlate_function_changes_with_files(diff_text: str, functions: List[FunctionChange]) -> List[FunctionChange]:
result: List[FunctionChange] = []
# Split diff into per-file sections
file_sections: List[tuple[str, str]] = []
current_file: Optional[str] = None
current_lines: List[str] = []
for line in diff_text.split('\n'):
if line.startswith('diff --git'):
if current_file is not None:
file_sections.append((current_file, '\n'.join(current_lines)))
m = re.match(r'^diff --git a/.*? b/(.*?)$', line)
current_file = m.group(1) if m else "unknown"
current_lines = [line]
else:
current_lines.append(line)
if current_file is not None:
file_sections.append((current_file, '\n'.join(current_lines)))
pattern = re.compile(r'^([+\-])\s*(def|class)\s+(\w+)', re.MULTILINE)
for filepath, section in file_sections:
for m in pattern.finditer(section):
op = m.group(1)
kind = m.group(2)
name = m.group(3)
change_type = "added" if op == '+' else "removed"
result.append(FunctionChange(
file=filepath,
name=name,
kind=kind,
change_type=change_type
))
return result
def main():
parser = argparse.ArgumentParser(description="Codebase Genome Diff — structural changes between versions")
parser.add_argument("--ref1", required=True, help="First git ref (commit, branch, tag)")
parser.add_argument("--ref2", required=True, help="Second git ref")
parser.add_argument("--output", help="Write report to file")
parser.add_argument("--json", action="store_true", help="Output JSON instead of human report")
args = parser.parse_args()
try:
diff_text = run_git_diff(args.ref1, args.ref2)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
if not diff_text.strip():
print(f"No differences between {args.ref1} and {args.ref2}.")
sys.exit(0)
analyzer = DiffAnalyzer()
summary = analyzer.analyze(diff_text)
file_changes = [fc.to_dict() for fc in summary.files]
func_changes = extract_function_changes(diff_text)
func_changes = correlate_function_changes_with_files(diff_text, func_changes)
dep_changes = extract_dependency_changes(diff_text, analyzer)
report = GenomeDiffReport(
ref1=args.ref1,
ref2=args.ref2,
file_changes=file_changes,
function_changes=func_changes,
dependency_changes=dep_changes,
total_files_changed=len(file_changes),
total_functions_changed=len(func_changes),
total_dependencies_changed=len(dep_changes),
)
output = json.dumps(report.to_dict(), indent=2) if args.json else report.human_report()
if args.output:
with open(args.output, 'w') as f:
f.write(output + '\n')
print(f"Report written to {args.output}")
else:
print(output)
if __name__ == '__main__':
main()

View File

@@ -1,185 +0,0 @@
#!/usr/bin/env python3
"""
Review Comment Generator — Issue #126
Reads JSONL findings, deduplicates, posts as Gitea PR comments.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
import urllib.request
import urllib.error
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, List, Optional
SCRIPT_DIR = Path(__file__).resolve().parent
REPO_ROOT = SCRIPT_DIR.parent
DEFAULT_API_BASE = os.environ.get(
"GITEA_API_BASE",
"https://forge.alexanderwhitestone.com"
)
TOKEN_PATHS = [
os.path.expanduser("~/.config/gitea/token"),
os.path.expanduser("~/.hermes/gitea.token"),
os.environ.get("GITEA_TOKEN", ""),
]
def load_token() -> Optional[str]:
token = os.environ.get("GITEA_TOKEN", "")
if token:
return token
for path in TOKEN_PATHS:
if path and os.path.exists(path):
with open(path) as f:
t = f.read().strip()
if t:
return t
return None
class GiteaClient:
def __init__(self, base_url: str, token: str, org: str, repo: str):
self.base_url = base_url.rstrip("/")
self.token = token
self.org = org
self.repo = repo
def _post(self, path: str, data: Dict) -> Optional[Dict]:
url = f"{self.base_url}/api/v1{path}"
body = json.dumps(data).encode("utf-8")
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Authorization", f"token {self.token}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
err = e.read().decode() if e.read() else str(e)
print(f"[ERROR] HTTP {e.code}: {err}", file=sys.stderr)
return None
except Exception as e:
print(f"[ERROR] {e}", file=sys.stderr)
return None
def post_issue_comment(self, issue_num: int, body: str) -> Optional[Dict]:
return self._post(
f"/repos/{self.org}/{self.repo}/issues/{issue_num}/comments",
{"body": body}
)
def content_hash(finding: Dict) -> str:
key = f"{finding['file']}:{finding['line']}:{finding['text']}"
return hashlib.sha256(key.encode("utf-8")).hexdigest()
def format_comment(finding: Dict) -> str:
emoji = {
"error": "🛑",
"warning": "⚠️",
"info": "",
}.get(finding.get("severity", ""), "📝")
f = finding["file"]
ln = finding["line"]
txt = finding["text"]
return f"{emoji} **Review Comment**\n\nFile: `{f}`\nLine: {ln}\n\n> {txt}\n"
def load_findings(path: Optional[Path], from_stdin: bool) -> List[Dict]:
import fileinput
findings = []
sources = ["-"] if from_stdin else [str(path)]
for line in fileinput.input(files=sources):
line = line.strip()
if not line or line.startswith("#"):
continue
try:
f = json.loads(line)
for key in ("file", "line", "text"):
if key not in f:
raise ValueError(f"Missing key: {key}")
findings.append(f)
except json.JSONDecodeError as e:
print(f"WARNING: Skipping invalid JSON: {e}", file=sys.stderr)
return findings
def main() -> int:
parser = argparse.ArgumentParser(
description="Post review findings as comments to a Gitea PR/issue"
)
parser.add_argument("--pr", type=int, required=True, help="PR/issue number")
parser.add_argument("--org", default="Timmy_Foundation", help="Gitea org")
parser.add_argument("--repo", default="compounding-intelligence", help="Repo name")
parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="Gitea API base")
parser.add_argument("--token", default=None, help="API token (or env/file)")
parser.add_argument("--input", type=Path, default=None, help="JSONL input file")
parser.add_argument("--stdin", action="store_true", help="Read from stdin")
parser.add_argument("--dry-run", action="store_true", help="Show without posting")
parser.add_argument("--json", action="store_true", help="Emit JSON report")
args = parser.parse_args()
if not args.stdin and args.input is None:
print("ERROR: --input or --stdin required", file=sys.stderr)
return 1
if args.stdin and args.input:
print("ERROR: --stdin and --input exclusive", file=sys.stderr)
return 1
token = args.token or load_token()
if not token:
print("ERROR: Token not found. Set GITEA_TOKEN or ~/.config/gitea/token", file=sys.stderr)
return 1
findings = load_findings(args.input, args.stdin)
if not findings:
print("ERROR: No findings loaded", file=sys.stderr)
return 1
if not args.json: print(f"Loaded {len(findings)} finding(s)")
seen: Dict[str, Dict] = {}
for f in findings:
h = content_hash(f)
if h not in seen:
seen[h] = f
unique = list(seen.values())
if not args.json: print(f"After dedup: {len(unique)} unique")
if args.json:
report = {
"total": len(findings),
"unique": len(unique),
"findings": unique,
"generated_at": datetime.now(timezone.utc).isoformat(),
}
print(json.dumps(report, indent=2))
return 0
if args.dry_run:
print("\n=== DRY RUN — would post ===")
for i, f in enumerate(unique, 1):
print(f"\n--- Comment {i}/{len(unique)} ---")
print(format_comment(f))
return 0
client = GiteaClient(args.api_base, token, args.org, args.repo)
posted = 0
for f in unique:
body = format_comment(f)
result = client.post_issue_comment(args.pr, body)
if result:
print(f"✅ Posted: {f['file']}:{f['line']} (id={result.get('id')})")
posted += 1
else:
print(f"❌ Failed: {f['file']}:{f['line']}")
print(f"\nPosted {posted}/{len(unique)} to PR #{args.pr}")
return 0 if posted == len(unique) else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,5 +0,0 @@
{"file": "scripts/harvester.py", "line": 47, "text": "Consider adding type hints to improve readability", "severity": "info"}
{"file": "scripts/dedup.py", "line": 89, "text": "Add null check before accessing fact['confidence'] to avoid KeyError", "severity": "warning"}
{"file": "scripts/bootstrapper.py", "line": 102, "text": "This loop is O(n^2) — could be optimized with a dict lookup", "severity": "info"}
{"file": "scripts/harvester.py", "line": 47, "text": "Consider adding type hints to improve readability", "severity": "info"}
{"file": "scripts/harvester.py", "line": 120, "text": "File handle not closed in error path — use context manager", "severity": "error"}

View File

@@ -1,234 +0,0 @@
#!/usr/bin/env python3
"""
Smoke tests for Review Comment Generator — Issue #126
"""
from __future__ import annotations
import json
import subprocess
import sys
import hashlib
from io import StringIO
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPTS_DIR = REPO_ROOT / "scripts"
GENERATOR = SCRIPTS_DIR / "review_comment_generator.py"
SAMPLE_FINDINGS = SCRIPTS_DIR / "sample_findings.jsonl"
class TestGeneratorPresence:
def test_script_exists(self):
assert GENERATOR.exists(), f"Missing: {GENERATOR}"
def test_shebang_is_python(self):
with open(GENERATOR) as f:
first = f.readline().strip()
assert first.startswith("#!"), "No shebang"
assert "python" in first.lower()
class TestDeduplication:
def test_content_hash_deterministic(self):
from hashlib import sha256
def ch(f):
key = f"{f['file']}:{f['line']}:{f['text']}"
return sha256(key.encode()).hexdigest()
finding = {"file": "a.py", "line": 1, "text": "test"}
assert ch(finding) == ch(finding)
def test_duplicate_findings_are_removed(self):
findings = [
{"file": "a.py", "line": 1, "text": "foo", "severity": "info"},
{"file": "a.py", "line": 1, "text": "foo", "severity": "warning"},
{"file": "b.py", "line": 2, "text": "bar", "severity": "info"},
]
seen = {}
for f in findings:
key = f"{f['file']}:{f['line']}:{f['text']}"
seen[key] = f
assert len(seen) == 2
def test_different_findings_are_kept(self):
findings = [
{"file": "a.py", "line": 1, "text": "foo"},
{"file": "a.py", "line": 2, "text": "foo"},
{"file": "a.py", "line": 1, "text": "bar"},
]
seen = {}
for f in findings:
key = f"{f['file']}:{f['line']}:{f['text']}"
seen[key] = f
assert len(seen) == 3
class TestCommentFormatting:
def test_format_basic(self):
sys.path.insert(0, str(SCRIPTS_DIR))
from review_comment_generator import format_comment
f = {"file": "scripts/foo.py", "line": 10, "text": "Fix this bug", "severity": "warning"}
body = format_comment(f)
assert "📝 **Review Comment**" not in body # warning uses ⚠️
assert "⚠️ **Review Comment**" in body
assert "`scripts/foo.py`" in body
assert "Line: 10" in body
assert "> Fix this bug" in body
def test_format_severity_emoji(self):
sys.path.insert(0, str(SCRIPTS_DIR))
from review_comment_generator import format_comment
cases = [("error", "🛑"), ("warning", "⚠️"), ("info", ""), ("unknown", "📝")]
for severity, emoji in cases:
f = {"file": "x.py", "line": 1, "text": "test", "severity": severity}
assert emoji in format_comment(f)
class TestFindingsLoader:
def test_load_from_file(self):
sys.path.insert(0, str(SCRIPTS_DIR))
from review_comment_generator import load_findings
findings = load_findings(SAMPLE_FINDINGS, from_stdin=False)
assert len(findings) >= 4
def test_load_ignores_blank_and_comments(self):
import tempfile, os
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as tf:
tf.write('{"file":"a.py","line":1,"text":"valid"}\n')
tf.write('\n')
tf.write('# this is a comment\n')
tf.write('{"file":"b.py","line":2,"text":"also valid"}\n')
tfname = tf.name
try:
sys.path.insert(0, str(SCRIPTS_DIR))
from review_comment_generator import load_findings
assert len(load_findings(Path(tfname), from_stdin=False)) == 2
finally:
os.unlink(tfname)
def test_invalid_json_line_skipped(self, capsys):
import tempfile, os
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as tf:
tf.write('invalid json\n')
tf.write('{"file":"ok.py","line":1,"text":"valid"}\n')
tfname = tf.name
try:
sys.path.insert(0, str(SCRIPTS_DIR))
from review_comment_generator import load_findings
assert len(load_findings(Path(tfname), from_stdin=False)) == 1
finally:
os.unlink(tfname)
class TestDryRunMode:
def test_dry_run_counts_unique(self):
result = subprocess.run(
[sys.executable, str(GENERATOR), "--pr", "126",
"--input", str(SAMPLE_FINDINGS), "--dry-run"],
capture_output=True, text=True, cwd=REPO_ROOT, timeout=15
)
assert result.returncode == 0
assert "DRY RUN" in result.stdout
assert "Review Comment" in result.stdout
def test_dry_run_shows_all_unique(self):
result = subprocess.run(
[sys.executable, str(GENERATOR), "--pr", "126",
"--input", str(SAMPLE_FINDINGS), "--dry-run"],
capture_output=True, text=True, cwd=REPO_ROOT, timeout=15
)
assert result.stdout.count("--- Comment") == 4
class TestJSONOutputMode:
def test_json_flag_emits_valid_json(self):
result = subprocess.run(
[sys.executable, str(GENERATOR), "--pr", "126",
"--input", str(SAMPLE_FINDINGS), "--json"],
capture_output=True, text=True, cwd=REPO_ROOT, timeout=15
)
assert result.returncode == 0
payload = json.loads(result.stdout)
assert "total" in payload and "unique" in payload and "findings" in payload
assert payload["total"] >= payload["unique"]
def test_json_findings_have_required_fields(self):
result = subprocess.run(
[sys.executable, str(GENERATOR), "--pr", "126",
"--input", str(SAMPLE_FINDINGS), "--json"],
capture_output=True, text=True, cwd=REPO_ROOT, timeout=15
)
payload = json.loads(result.stdout)
for f in payload["findings"]:
assert "file" in f and "line" in f and "text" in f
class TestGiteaClient:
def test_post_issue_comment_builds_correct_url(self):
sys.path.insert(0, str(SCRIPTS_DIR))
from review_comment_generator import GiteaClient
client = GiteaClient("https://example.com", "token123", "MyOrg", "myrepo")
assert client.org == "MyOrg" and client.repo == "myrepo"
def test_generate_comment_body_has_required_fields(self):
sys.path.insert(0, str(SCRIPTS_DIR))
from review_comment_generator import format_comment
f = {"file": "x.py", "line": 5, "text": "Fix this", "severity": "error"}
body = format_comment(f)
assert "x.py" in body and "5" in body and "Fix this" in body
class TestFullPipeline:
def test_end_to_end_json_output(self):
result = subprocess.run(
[sys.executable, str(GENERATOR), "--pr", "126",
"--input", str(SAMPLE_FINDINGS), "--json"],
capture_output=True, text=True, cwd=REPO_ROOT, timeout=15
)
assert result.returncode == 0
data = json.loads(result.stdout)
assert data["total"] == 5
assert data["unique"] == 4
f = data["findings"][0]
for key in ("file", "line", "text", "severity"):
assert key in f
def test_token_loading_fallback(self):
sys.path.insert(0, str(SCRIPTS_DIR))
from review_comment_generator import load_token
token = load_token()
assert token is None or isinstance(token, str)
class TestErrorHandling:
def test_missing_input_shows_error(self):
result = subprocess.run(
[sys.executable, str(GENERATOR), "--pr", "126"],
capture_output=True, text=True, cwd=REPO_ROOT, timeout=15
)
assert result.returncode != 0
assert "--input" in result.stderr or "--stdin" in result.stderr
def test_invalid_json_line_skipped(self):
import tempfile, os
with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as tf:
tf.write('invalid json\n')
tf.write('{"file":"ok.py","line":1,"text":"valid"}\n')
tfname = tf.name
try:
result = subprocess.run(
[sys.executable, str(GENERATOR), "--pr", "126",
"--input", tfname, "--json"],
capture_output=True, text=True, cwd=REPO_ROOT, timeout=15
)
data = json.loads(result.stdout)
assert data["total"] == 1
assert data["unique"] == 1
finally:
os.unlink(tfname)
if __name__ == "__main__":
pytest.main([__file__, "-v"])