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,275 @@
1
+ """Deterministic Symbol History Engine.
2
+
3
+ Traces the evolutionary history of a symbol across Git commits:
4
+ - Symbol introduced / added
5
+ - Symbol modified (signature, body, docstring)
6
+ - Symbol renamed (tracked via AST structure & body hashes)
7
+ - Symbol moved across files (tracked via Git renames and structural equality)
8
+ - Symbol deleted
9
+
10
+ Epistemic states:
11
+ - FACT: Exact body hash match, canonical ID continuity, or Git rename (-M) metadata
12
+ - POSSIBLE: Structural or signature similarity without identical body hash
13
+ - AMBIGUOUS: Multiple candidate replacements detected
14
+ - UNKNOWN: Disappeared with no identifiable successor
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import sqlite3
20
+ import subprocess
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from codegraph.git import recent_commits
26
+ from codegraph.indexing.parser import parse
27
+ from codegraph.indexing.scanner import LANGUAGES
28
+
29
+
30
+ @dataclass
31
+ class SymbolHistoryEvent:
32
+ commit_sha: str
33
+ date: str
34
+ author: str
35
+ message: str
36
+ event_type: str # "INTRODUCED" | "MODIFIED" | "RENAMED" | "MOVED" | "DELETED"
37
+ path: str
38
+ old_path: str | None = None
39
+ old_name: str | None = None
40
+ new_name: str | None = None
41
+ confidence: str = "FACT" # "FACT" | "POSSIBLE" | "AMBIGUOUS" | "UNKNOWN"
42
+ evidence: str = ""
43
+
44
+ def as_dict(self) -> dict[str, Any]:
45
+ return {
46
+ "commit_sha": self.commit_sha,
47
+ "date": self.date,
48
+ "author": self.author,
49
+ "message": self.message,
50
+ "event_type": self.event_type,
51
+ "path": self.path,
52
+ "old_path": self.old_path,
53
+ "old_name": self.old_name,
54
+ "new_name": self.new_name,
55
+ "confidence": self.confidence,
56
+ "evidence": self.evidence,
57
+ }
58
+
59
+
60
+ @dataclass
61
+ class SymbolHistoryReport:
62
+ symbol: str
63
+ current_path: str | None
64
+ events: list[SymbolHistoryEvent] = field(default_factory=list)
65
+ total_commits_evaluated: int = 0
66
+ status: str = "SUCCESS" # "SUCCESS" | "NOT_FOUND" | "AMBIGUOUS"
67
+ detail: str = ""
68
+
69
+ def as_dict(self) -> dict[str, Any]:
70
+ return {
71
+ "symbol": self.symbol,
72
+ "current_path": self.current_path,
73
+ "events": [e.as_dict() for e in self.events],
74
+ "total_commits_evaluated": self.total_commits_evaluated,
75
+ "status": self.status,
76
+ "detail": self.detail,
77
+ }
78
+
79
+
80
+ def _run_git(repository: Path, *args: str, timeout: int = 15) -> str | None:
81
+ for arg in args:
82
+ if any(c in arg for c in (";", "|", "&", "`", "$", "\n", "\r")):
83
+ return None
84
+ try:
85
+ res = subprocess.run(
86
+ ["git", *args],
87
+ cwd=str(repository),
88
+ capture_output=True,
89
+ text=True,
90
+ timeout=timeout,
91
+ )
92
+ return res.stdout if res.returncode == 0 else None
93
+ except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
94
+ return None
95
+
96
+
97
+ _BODY_HASH_CACHE: dict[tuple[int, str, str, str], tuple[str | None, int | None]] = {}
98
+
99
+
100
+ def _get_symbol_body_hash(content: str, lang: str, file_path: str, symbol_name: str) -> tuple[str | None, int | None]:
101
+ """Parse content and compute content hash for symbol body with caching."""
102
+ if not content:
103
+ return None, None
104
+ c_hash = hash(content)
105
+ cache_key = (c_hash, lang, file_path, symbol_name)
106
+ if cache_key in _BODY_HASH_CACHE:
107
+ return _BODY_HASH_CACHE[cache_key]
108
+ try:
109
+ res = parse(content, lang, file_path)
110
+ for s in res.symbols:
111
+ if s.name == symbol_name:
112
+ lines = content.splitlines()
113
+ snippet = "\n".join(lines[max(0, s.start_line - 1) : s.end_line]).strip()
114
+ b_hash = hashlib.sha256(snippet.encode("utf-8", errors="replace")).hexdigest()[:16]
115
+ result = (b_hash, s.start_line)
116
+ if len(_BODY_HASH_CACHE) > 2000:
117
+ _BODY_HASH_CACHE.clear()
118
+ _BODY_HASH_CACHE[cache_key] = result
119
+ return result
120
+ _BODY_HASH_CACHE[cache_key] = (None, None)
121
+ return None, None
122
+ except Exception:
123
+ return None, None
124
+
125
+
126
+ def trace_symbol_history(
127
+ repository: Path,
128
+ symbol: str,
129
+ path: str | None = None,
130
+ con: sqlite3.Connection | None = None,
131
+ max_commits: int = 15,
132
+ ) -> SymbolHistoryReport:
133
+ """Trace the history of a symbol across Git commits deterministically."""
134
+ from codegraph.structural_diff import _get_file_content_at_ref
135
+
136
+ # 1. Resolve path if not provided
137
+ resolved_path = path
138
+ if not resolved_path and con is not None:
139
+ try:
140
+ row = con.execute("SELECT path FROM symbols WHERE name=? LIMIT 1", (symbol,)).fetchone()
141
+ if row:
142
+ resolved_path = str(row[0])
143
+ except (sqlite3.OperationalError, sqlite3.DatabaseError):
144
+ pass
145
+
146
+ # 2. Get commits affecting the file or symbol
147
+ commits = recent_commits(repository, n=max_commits * 2, path=resolved_path)
148
+ if not commits:
149
+ return SymbolHistoryReport(
150
+ symbol=symbol,
151
+ current_path=resolved_path,
152
+ events=[],
153
+ total_commits_evaluated=0,
154
+ status="NOT_FOUND",
155
+ detail=f"No Git commits found for symbol '{symbol}' or path '{resolved_path}'.",
156
+ )
157
+
158
+ events: list[SymbolHistoryEvent] = []
159
+ current_sym_name = symbol
160
+ current_sym_path = resolved_path or ""
161
+
162
+ prev_parent_content: str | None = None
163
+ prev_parent_hash: str | None = None
164
+ prev_parent_line: int | None = None
165
+ prev_parent_path: str | None = None
166
+
167
+ for c in commits:
168
+ if len(events) >= max_commits:
169
+ break
170
+
171
+ ext = Path(current_sym_path).suffix.lower() if current_sym_path else ".py"
172
+ lang = LANGUAGES.get(ext, "python")
173
+
174
+ # Fast path: check if curr_content was already loaded as parent in previous iteration
175
+ curr_content: str | None = None
176
+ curr_hash: str | None = None
177
+ curr_line: int | None = None
178
+
179
+ if prev_parent_content is not None and prev_parent_path == current_sym_path:
180
+ curr_content = prev_parent_content
181
+ curr_hash, curr_line = prev_parent_hash, prev_parent_line
182
+ else:
183
+ curr_content = _get_file_content_at_ref(repository, c.sha, current_sym_path) if current_sym_path else None
184
+ curr_hash, curr_line = _get_symbol_body_hash(curr_content or "", lang, current_sym_path, current_sym_name) if curr_content else (None, None)
185
+
186
+ # Get file content at parent commit c.sha~1
187
+ parent_content = _get_file_content_at_ref(repository, f"{c.sha}~1", current_sym_path) if current_sym_path else None
188
+ parent_hash, parent_line = _get_symbol_body_hash(parent_content or "", lang, current_sym_path, current_sym_name) if parent_content else (None, None)
189
+
190
+ # Store for next iteration
191
+ prev_parent_content = parent_content
192
+ prev_parent_hash = parent_hash
193
+ prev_parent_line = parent_line
194
+ prev_parent_path = current_sym_path
195
+
196
+ if curr_hash is not None and parent_hash is not None:
197
+ if curr_hash != parent_hash:
198
+ events.append(
199
+ SymbolHistoryEvent(
200
+ commit_sha=c.sha,
201
+ date=c.date,
202
+ author=c.author,
203
+ message=c.message,
204
+ event_type="MODIFIED",
205
+ path=current_sym_path,
206
+ confidence="FACT",
207
+ evidence=f"Symbol modified at line {curr_line} (body hash changed)",
208
+ )
209
+ )
210
+ elif curr_hash is not None and parent_hash is None:
211
+ # Symbol appeared in this commit! Did it rename or move from somewhere else?
212
+ found_move = False
213
+ # Check git diff --name-status c.sha~1 c.sha for renames
214
+ raw_rename = _run_git(repository, "diff", "-M", "--name-status", f"{c.sha}~1", c.sha)
215
+ if raw_rename:
216
+ for line in raw_rename.splitlines():
217
+ if line.startswith("R") and current_sym_path in line:
218
+ parts = line.split("\t")
219
+ if len(parts) >= 3 and parts[2] == current_sym_path:
220
+ old_p = parts[1]
221
+ events.append(
222
+ SymbolHistoryEvent(
223
+ commit_sha=c.sha,
224
+ date=c.date,
225
+ author=c.author,
226
+ message=c.message,
227
+ event_type="MOVED",
228
+ path=current_sym_path,
229
+ old_path=old_p,
230
+ confidence="FACT",
231
+ evidence=f"File moved from {old_p} to {current_sym_path}",
232
+ )
233
+ )
234
+ current_sym_path = old_p
235
+ found_move = True
236
+ break
237
+
238
+ if not found_move:
239
+ # Check if introduced
240
+ events.append(
241
+ SymbolHistoryEvent(
242
+ commit_sha=c.sha,
243
+ date=c.date,
244
+ author=c.author,
245
+ message=c.message,
246
+ event_type="INTRODUCED",
247
+ path=current_sym_path,
248
+ confidence="FACT",
249
+ evidence=f"Symbol created at {current_sym_path}:{curr_line}",
250
+ )
251
+ )
252
+ break
253
+ elif curr_hash is None and parent_hash is not None:
254
+ # Symbol was deleted or renamed in curr_content
255
+ events.append(
256
+ SymbolHistoryEvent(
257
+ commit_sha=c.sha,
258
+ date=c.date,
259
+ author=c.author,
260
+ message=c.message,
261
+ event_type="DELETED",
262
+ path=current_sym_path,
263
+ confidence="FACT",
264
+ evidence=f"Symbol removed in commit {c.sha[:8]}",
265
+ )
266
+ )
267
+
268
+ return SymbolHistoryReport(
269
+ symbol=symbol,
270
+ current_path=resolved_path,
271
+ events=events,
272
+ total_commits_evaluated=len(commits),
273
+ status="SUCCESS" if events else "NOT_FOUND",
274
+ detail=f"Traced {len(events)} history event(s) across {len(commits)} commits.",
275
+ )