codegraph-engine 2.2.0__py3-none-any.whl → 2.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,540 @@
1
+ """Structural Git Diff Engine.
2
+
3
+ Computes semantic and AST-level differences between Git revisions, branches,
4
+ or between working tree and indexed state.
5
+
6
+ Identifies:
7
+ - File-level additions, deletions, modifications, renames/moves
8
+ - Symbol-level additions, removals, signature and body modifications
9
+ - Relationship-level changes: calls, imports, framework routes, test links, DB models/queries
10
+ - Evidence-backed classifications: FACT, POSSIBLE, UNKNOWN
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import sqlite3
16
+ import subprocess
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from codegraph.indexing.parser import parse
22
+ from codegraph.indexing.scanner import LANGUAGES
23
+
24
+
25
+ @dataclass
26
+ class RenamedFile:
27
+ old_path: str
28
+ new_path: str
29
+ similarity_score: float = 1.0
30
+
31
+ def as_dict(self) -> dict[str, Any]:
32
+ return {
33
+ "old_path": self.old_path,
34
+ "new_path": self.new_path,
35
+ "similarity_score": self.similarity_score,
36
+ }
37
+
38
+
39
+ @dataclass
40
+ class ChangedSymbol:
41
+ name: str
42
+ qualified_name: str
43
+ path: str
44
+ kind: str
45
+ change_type: str # "ADDED" | "REMOVED" | "MODIFIED" | "MOVED" | "RENAMED"
46
+ old_path: str | None = None
47
+ old_name: str | None = None
48
+ old_line: int | None = None
49
+ new_line: int | None = None
50
+ confidence: str = "FACT" # FACT | POSSIBLE | UNKNOWN
51
+ evidence: str = ""
52
+
53
+ def as_dict(self) -> dict[str, Any]:
54
+ return {
55
+ "name": self.name,
56
+ "qualified_name": self.qualified_name,
57
+ "path": self.path,
58
+ "kind": self.kind,
59
+ "change_type": self.change_type,
60
+ "old_path": self.old_path,
61
+ "old_name": self.old_name,
62
+ "old_line": self.old_line,
63
+ "new_line": self.new_line,
64
+ "confidence": self.confidence,
65
+ "evidence": self.evidence,
66
+ }
67
+
68
+
69
+ @dataclass
70
+ class ChangedRelationship:
71
+ relationship_type: str # "CALLS" | "IMPORTS" | "ROUTES_TO" | "TESTS" | "DB_MUTATION"
72
+ source: str
73
+ target: str
74
+ change_type: str # "ADDED" | "REMOVED"
75
+ file: str
76
+ confidence: str = "FACT"
77
+
78
+ def as_dict(self) -> dict[str, Any]:
79
+ return {
80
+ "relationship_type": self.relationship_type,
81
+ "source": self.source,
82
+ "target": self.target,
83
+ "change_type": self.change_type,
84
+ "file": self.file,
85
+ "confidence": self.confidence,
86
+ }
87
+
88
+
89
+ @dataclass
90
+ class ChangedRoute:
91
+ path: str
92
+ method: str
93
+ handler: str
94
+ framework: str
95
+ change_type: str # "ADDED" | "REMOVED" | "MODIFIED"
96
+
97
+ def as_dict(self) -> dict[str, Any]:
98
+ return {
99
+ "path": self.path,
100
+ "method": self.method,
101
+ "handler": self.handler,
102
+ "framework": self.framework,
103
+ "change_type": self.change_type,
104
+ }
105
+
106
+
107
+ @dataclass
108
+ class StructuralDiffReport:
109
+ base_ref: str
110
+ head_ref: str
111
+ added_files: list[str] = field(default_factory=list)
112
+ deleted_files: list[str] = field(default_factory=list)
113
+ modified_files: list[str] = field(default_factory=list)
114
+ renamed_files: list[RenamedFile] = field(default_factory=list)
115
+ added_symbols: list[ChangedSymbol] = field(default_factory=list)
116
+ removed_symbols: list[ChangedSymbol] = field(default_factory=list)
117
+ changed_symbols: list[ChangedSymbol] = field(default_factory=list)
118
+ changed_relationships: list[ChangedRelationship] = field(default_factory=list)
119
+ changed_routes: list[ChangedRoute] = field(default_factory=list)
120
+ affected_tests: list[str] = field(default_factory=list)
121
+ db_impact: list[dict[str, Any]] = field(default_factory=list)
122
+ total_file_changes: int = 0
123
+ total_symbol_changes: int = 0
124
+
125
+ def as_dict(self) -> dict[str, Any]:
126
+ return {
127
+ "base_ref": self.base_ref,
128
+ "head_ref": self.head_ref,
129
+ "added_files": self.added_files,
130
+ "deleted_files": self.deleted_files,
131
+ "modified_files": self.modified_files,
132
+ "renamed_files": [r.as_dict() for r in self.renamed_files],
133
+ "added_symbols": [s.as_dict() for s in self.added_symbols],
134
+ "removed_symbols": [s.as_dict() for s in self.removed_symbols],
135
+ "changed_symbols": [s.as_dict() for s in self.changed_symbols],
136
+ "changed_relationships": [r.as_dict() for r in self.changed_relationships],
137
+ "changed_routes": [r.as_dict() for r in self.changed_routes],
138
+ "affected_tests": self.affected_tests,
139
+ "db_impact": self.db_impact,
140
+ "total_file_changes": self.total_file_changes,
141
+ "total_symbol_changes": self.total_symbol_changes,
142
+ }
143
+
144
+
145
+ def _run_git(repository: Path, *args: str, timeout: int = 15) -> str | None:
146
+ for arg in args:
147
+ if any(c in arg for c in (";", "|", "&", "`", "$", "\n", "\r")):
148
+ return None
149
+ try:
150
+ res = subprocess.run(
151
+ ["git", *args],
152
+ cwd=str(repository),
153
+ capture_output=True,
154
+ text=True,
155
+ timeout=timeout,
156
+ )
157
+ return res.stdout if res.returncode == 0 else None
158
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
159
+ return None
160
+
161
+
162
+ _FILE_CONTENT_AT_REF_CACHE: dict[tuple[str, str, str], str | None] = {}
163
+
164
+
165
+ def _get_file_content_at_ref(repository: Path, ref: str, file_path: str) -> str | None:
166
+ """Read historical or current content of a file at a specific git ref or working tree."""
167
+ if ref == "WORKING_TREE":
168
+ abs_p = repository / file_path
169
+ if abs_p.exists() and abs_p.is_file():
170
+ try:
171
+ return abs_p.read_text(encoding="utf-8", errors="replace")
172
+ except OSError:
173
+ return None
174
+ return None
175
+
176
+ cache_key = (str(repository), ref, file_path)
177
+ if cache_key in _FILE_CONTENT_AT_REF_CACHE:
178
+ return _FILE_CONTENT_AT_REF_CACHE[cache_key]
179
+
180
+ # Use git show <ref>:<path>
181
+ raw = _run_git(repository, "show", f"{ref}:{file_path}")
182
+ if len(_FILE_CONTENT_AT_REF_CACHE) > 1000:
183
+ _FILE_CONTENT_AT_REF_CACHE.clear()
184
+ _FILE_CONTENT_AT_REF_CACHE[cache_key] = raw
185
+ return raw
186
+
187
+
188
+ def _get_language(file_path: str) -> str | None:
189
+ ext = Path(file_path).suffix.lower()
190
+ return LANGUAGES.get(ext)
191
+
192
+
193
+ _REF_PATTERN = re.compile(r"^[A-Za-z0-9_.~^/-]{1,100}$")
194
+
195
+
196
+ def compare_revisions(
197
+ repository: Path,
198
+ base: str = "HEAD~1",
199
+ head: str = "HEAD",
200
+ con: sqlite3.Connection | None = None,
201
+ max_symbols_per_file: int = 100,
202
+ ) -> StructuralDiffReport:
203
+ """Compute structural, AST-level difference between two revisions or working tree."""
204
+ if head != "WORKING_TREE" and not _REF_PATTERN.match(head):
205
+ return StructuralDiffReport(base_ref=base, head_ref=head)
206
+ if not _REF_PATTERN.match(base):
207
+ return StructuralDiffReport(base_ref=base, head_ref=head)
208
+
209
+ # Identical ref short-circuit (0ms latency)
210
+ if base == head and head != "WORKING_TREE":
211
+ return StructuralDiffReport(base_ref=base, head_ref=head)
212
+
213
+ # 1. Get raw diff stats with rename detection
214
+ if head == "WORKING_TREE":
215
+ raw_diff = _run_git(repository, "diff", "-M", "--name-status", base)
216
+ else:
217
+ raw_diff = _run_git(repository, "diff", "-M", "--name-status", base, head)
218
+
219
+ if raw_diff is None:
220
+ return StructuralDiffReport(base_ref=base, head_ref=head)
221
+
222
+ added_files: list[str] = []
223
+ deleted_files: list[str] = []
224
+ modified_files: list[str] = []
225
+ renamed_files: list[RenamedFile] = []
226
+
227
+ for line in raw_diff.splitlines():
228
+ if not line.strip():
229
+ continue
230
+ parts = line.split("\t")
231
+ if not parts:
232
+ continue
233
+ status_code = parts[0].strip()
234
+ if status_code.startswith("R") and len(parts) >= 3:
235
+ # Rename: R100 old_path new_path
236
+ score = 1.0
237
+ if len(status_code) > 1 and status_code[1:].isdigit():
238
+ score = float(status_code[1:]) / 100.0
239
+ renamed_files.append(RenamedFile(old_path=parts[1], new_path=parts[2], similarity_score=score))
240
+ elif status_code.startswith("A") and len(parts) >= 2:
241
+ added_files.append(parts[1])
242
+ elif status_code.startswith("D") and len(parts) >= 2:
243
+ deleted_files.append(parts[1])
244
+ elif status_code.startswith("M") and len(parts) >= 2:
245
+ modified_files.append(parts[1])
246
+
247
+ added_symbols: list[ChangedSymbol] = []
248
+ removed_symbols: list[ChangedSymbol] = []
249
+ changed_symbols: list[ChangedSymbol] = []
250
+ changed_relationships: list[ChangedRelationship] = []
251
+ changed_routes: list[ChangedRoute] = []
252
+ affected_tests: set[str] = set()
253
+ db_impact: list[dict[str, Any]] = []
254
+
255
+ # 2. Inspect renamed files
256
+ for r in renamed_files:
257
+ lang = _get_language(r.new_path)
258
+ if not lang:
259
+ continue
260
+ old_content = _get_file_content_at_ref(repository, base, r.old_path)
261
+ new_content = _get_file_content_at_ref(repository, head, r.new_path)
262
+ if old_content and new_content:
263
+ old_res = parse(old_content, lang, r.old_path)
264
+ new_res = parse(new_content, lang, r.new_path)
265
+ for s in new_res.symbols:
266
+ changed_symbols.append(
267
+ ChangedSymbol(
268
+ name=s.name,
269
+ qualified_name=s.qualified_name or s.name,
270
+ path=r.new_path,
271
+ kind=s.kind,
272
+ change_type="MOVED",
273
+ old_path=r.old_path,
274
+ old_name=s.name,
275
+ confidence="FACT",
276
+ evidence=f"File moved from {r.old_path} to {r.new_path} (similarity {r.similarity_score:.0%})",
277
+ )
278
+ )
279
+
280
+ # 3. Inspect modified files at AST level
281
+ for m_path in modified_files:
282
+ lang = _get_language(m_path)
283
+ if not lang:
284
+ continue
285
+
286
+ old_content = _get_file_content_at_ref(repository, base, m_path) or ""
287
+ new_content = _get_file_content_at_ref(repository, head, m_path) or ""
288
+
289
+ old_res = parse(old_content, lang, m_path)
290
+ new_res = parse(new_content, lang, m_path)
291
+
292
+ old_sym_map = {s.name: s for s in old_res.symbols}
293
+ new_sym_map = {s.name: s for s in new_res.symbols}
294
+
295
+ # Check added symbols in this file
296
+ for name, new_s in new_sym_map.items():
297
+ if name not in old_sym_map:
298
+ added_symbols.append(
299
+ ChangedSymbol(
300
+ name=name,
301
+ qualified_name=new_s.qualified_name or name,
302
+ path=m_path,
303
+ kind=new_s.kind,
304
+ change_type="ADDED",
305
+ new_line=new_s.start_line,
306
+ confidence="FACT",
307
+ evidence=f"Defined at {m_path}:{new_s.start_line}",
308
+ )
309
+ )
310
+
311
+ # Check removed symbols in this file
312
+ for name, old_s in old_sym_map.items():
313
+ if name not in new_sym_map:
314
+ removed_symbols.append(
315
+ ChangedSymbol(
316
+ name=name,
317
+ qualified_name=old_s.qualified_name or name,
318
+ path=m_path,
319
+ kind=old_s.kind,
320
+ change_type="REMOVED",
321
+ old_line=old_s.start_line,
322
+ confidence="FACT",
323
+ evidence=f"Removed from {m_path} (was at line {old_s.start_line})",
324
+ )
325
+ )
326
+
327
+ # Check modified symbols
328
+ for name, new_s in new_sym_map.items():
329
+ if name in old_sym_map:
330
+ old_s = old_sym_map[name]
331
+ # Compare body content hashes or line ranges
332
+ old_lines = old_content.splitlines()
333
+ new_lines = new_content.splitlines()
334
+ old_snippet = "\n".join(old_lines[max(0, old_s.start_line - 1) : old_s.end_line]).strip()
335
+ new_snippet = "\n".join(new_lines[max(0, new_s.start_line - 1) : new_s.end_line]).strip()
336
+
337
+ if old_snippet != new_snippet:
338
+ changed_symbols.append(
339
+ ChangedSymbol(
340
+ name=name,
341
+ qualified_name=new_s.qualified_name or name,
342
+ path=m_path,
343
+ kind=new_s.kind,
344
+ change_type="MODIFIED",
345
+ old_line=old_s.start_line,
346
+ new_line=new_s.start_line,
347
+ confidence="FACT",
348
+ evidence=f"Body modified at {m_path}:{new_s.start_line}-{new_s.end_line}",
349
+ )
350
+ )
351
+
352
+ # Check framework routes diff
353
+ old_routes = {(r.route_path, r.http_method): r for r in old_res.routes}
354
+ new_routes = {(r.route_path, r.http_method): r for r in new_res.routes}
355
+ for (r_path, r_method), r_obj in new_routes.items():
356
+ if (r_path, r_method) not in old_routes:
357
+ changed_routes.append(
358
+ ChangedRoute(
359
+ path=r_path,
360
+ method=r_method,
361
+ handler=r_obj.handler_name or "",
362
+ framework=r_obj.framework or "",
363
+ change_type="ADDED",
364
+ )
365
+ )
366
+ elif old_routes[(r_path, r_method)].handler_name != r_obj.handler_name:
367
+ changed_routes.append(
368
+ ChangedRoute(
369
+ path=r_path,
370
+ method=r_method,
371
+ handler=r_obj.handler_name or "",
372
+ framework=r_obj.framework or "",
373
+ change_type="MODIFIED",
374
+ )
375
+ )
376
+ for (r_path, r_method), r_obj in old_routes.items():
377
+ if (r_path, r_method) not in new_routes:
378
+ changed_routes.append(
379
+ ChangedRoute(
380
+ path=r_path,
381
+ method=r_method,
382
+ handler=r_obj.handler_name or "",
383
+ framework=r_obj.framework or "",
384
+ change_type="REMOVED",
385
+ )
386
+ )
387
+
388
+ # Check call graph relationships added / removed
389
+ old_calls = {c.callee for c in old_res.calls}
390
+ new_calls = {c.callee for c in new_res.calls}
391
+ for c in new_calls - old_calls:
392
+ changed_relationships.append(
393
+ ChangedRelationship(
394
+ relationship_type="CALLS",
395
+ source=m_path,
396
+ target=c,
397
+ change_type="ADDED",
398
+ file=m_path,
399
+ )
400
+ )
401
+ for c in old_calls - new_calls:
402
+ changed_relationships.append(
403
+ ChangedRelationship(
404
+ relationship_type="CALLS",
405
+ source=m_path,
406
+ target=c,
407
+ change_type="REMOVED",
408
+ file=m_path,
409
+ )
410
+ )
411
+
412
+ # Detect test coverage links if test file
413
+ if "test" in m_path.lower():
414
+ affected_tests.add(m_path)
415
+
416
+ # 4. Check added files at AST level
417
+ for a_path in added_files:
418
+ lang = _get_language(a_path)
419
+ if not lang:
420
+ continue
421
+ content = _get_file_content_at_ref(repository, head, a_path) or ""
422
+ res = parse(content, lang, a_path)
423
+ for s in res.symbols:
424
+ added_symbols.append(
425
+ ChangedSymbol(
426
+ name=s.name,
427
+ qualified_name=s.qualified_name or s.name,
428
+ path=a_path,
429
+ kind=s.kind,
430
+ change_type="ADDED",
431
+ new_line=s.start_line,
432
+ confidence="FACT",
433
+ evidence=f"Defined in newly added file {a_path}:{s.start_line}",
434
+ )
435
+ )
436
+ for r_obj in res.routes:
437
+ changed_routes.append(
438
+ ChangedRoute(
439
+ path=r_obj.route_path,
440
+ method=r_obj.http_method,
441
+ handler=r_obj.handler_name or "",
442
+ framework=r_obj.framework or "",
443
+ change_type="ADDED",
444
+ )
445
+ )
446
+ if "test" in a_path.lower():
447
+ affected_tests.add(a_path)
448
+
449
+ # 5. Check deleted files
450
+ for d_path in deleted_files:
451
+ lang = _get_language(d_path)
452
+ if not lang:
453
+ continue
454
+ old_content = _get_file_content_at_ref(repository, base, d_path) or ""
455
+ res = parse(old_content, lang, d_path)
456
+ for s in res.symbols:
457
+ removed_symbols.append(
458
+ ChangedSymbol(
459
+ name=s.name,
460
+ qualified_name=s.qualified_name or s.name,
461
+ path=d_path,
462
+ kind=s.kind,
463
+ change_type="REMOVED",
464
+ old_line=s.start_line,
465
+ confidence="FACT",
466
+ evidence=f"Deleted with file {d_path}",
467
+ )
468
+ )
469
+ for r_obj in res.routes:
470
+ changed_routes.append(
471
+ ChangedRoute(
472
+ path=r_obj.route_path,
473
+ method=r_obj.http_method,
474
+ handler=r_obj.handler_name or "",
475
+ framework=r_obj.framework or "",
476
+ change_type="REMOVED",
477
+ )
478
+ )
479
+
480
+ # 6. Database impact (if con is available)
481
+ if con is not None:
482
+ try:
483
+ for cs in changed_symbols + added_symbols + removed_symbols:
484
+ rows = con.execute(
485
+ "SELECT table_name, operation FROM database_queries WHERE symbol=? OR caller_symbol=?",
486
+ (cs.name, cs.name),
487
+ ).fetchall()
488
+ for r in rows:
489
+ db_impact.append({
490
+ "symbol": cs.name,
491
+ "table": r[0],
492
+ "operation": r[1],
493
+ "change_type": cs.change_type,
494
+ })
495
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
496
+ pass
497
+
498
+ total_files = len(added_files) + len(deleted_files) + len(modified_files) + len(renamed_files)
499
+ total_symbols = len(added_symbols) + len(removed_symbols) + len(changed_symbols)
500
+
501
+ return StructuralDiffReport(
502
+ base_ref=base,
503
+ head_ref=head,
504
+ added_files=sorted(added_files),
505
+ deleted_files=sorted(deleted_files),
506
+ modified_files=sorted(modified_files),
507
+ renamed_files=renamed_files,
508
+ added_symbols=added_symbols,
509
+ removed_symbols=removed_symbols,
510
+ changed_symbols=changed_symbols,
511
+ changed_relationships=changed_relationships,
512
+ changed_routes=changed_routes,
513
+ affected_tests=sorted(affected_tests),
514
+ db_impact=db_impact,
515
+ total_file_changes=total_files,
516
+ total_symbol_changes=total_symbols,
517
+ )
518
+
519
+
520
+ def compare_branches(
521
+ repository: Path,
522
+ base_branch: str = "main",
523
+ head_branch: str = "HEAD",
524
+ con: sqlite3.Connection | None = None,
525
+ ) -> StructuralDiffReport:
526
+ """Compare two branches structurally using their merge-base or direct revision diff."""
527
+ if base_branch == head_branch:
528
+ return StructuralDiffReport(base_ref=base_branch, head_ref=head_branch)
529
+ # Find merge base if available
530
+ merge_base = _run_git(repository, "merge-base", base_branch, head_branch)
531
+ base = merge_base.strip() if merge_base else base_branch
532
+ return compare_revisions(repository, base=base, head=head_branch, con=con)
533
+
534
+
535
+ def compare_head_with_index(
536
+ repository: Path,
537
+ con: sqlite3.Connection | None = None,
538
+ ) -> StructuralDiffReport:
539
+ """Compare uncommitted working tree changes against current HEAD."""
540
+ return compare_revisions(repository, base="HEAD", head="WORKING_TREE", con=con)