codegraph-engine 3.0.0__1-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.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +859 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,277 @@
|
|
|
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
|
+
encoding="utf-8",
|
|
91
|
+
errors="replace",
|
|
92
|
+
timeout=timeout,
|
|
93
|
+
)
|
|
94
|
+
return res.stdout if res.returncode == 0 else None
|
|
95
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_BODY_HASH_CACHE: dict[tuple[int, str, str, str], tuple[str | None, int | None]] = {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _get_symbol_body_hash(content: str, lang: str, file_path: str, symbol_name: str) -> tuple[str | None, int | None]:
|
|
103
|
+
"""Parse content and compute content hash for symbol body with caching."""
|
|
104
|
+
if not content:
|
|
105
|
+
return None, None
|
|
106
|
+
c_hash = hash(content)
|
|
107
|
+
cache_key = (c_hash, lang, file_path, symbol_name)
|
|
108
|
+
if cache_key in _BODY_HASH_CACHE:
|
|
109
|
+
return _BODY_HASH_CACHE[cache_key]
|
|
110
|
+
try:
|
|
111
|
+
res = parse(content, lang, file_path)
|
|
112
|
+
for s in res.symbols:
|
|
113
|
+
if s.name == symbol_name:
|
|
114
|
+
lines = content.splitlines()
|
|
115
|
+
snippet = "\n".join(lines[max(0, s.start_line - 1) : s.end_line]).strip()
|
|
116
|
+
b_hash = hashlib.sha256(snippet.encode("utf-8", errors="replace")).hexdigest()[:16]
|
|
117
|
+
result = (b_hash, s.start_line)
|
|
118
|
+
if len(_BODY_HASH_CACHE) > 2000:
|
|
119
|
+
_BODY_HASH_CACHE.clear()
|
|
120
|
+
_BODY_HASH_CACHE[cache_key] = result
|
|
121
|
+
return result
|
|
122
|
+
_BODY_HASH_CACHE[cache_key] = (None, None)
|
|
123
|
+
return None, None
|
|
124
|
+
except Exception:
|
|
125
|
+
return None, None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def trace_symbol_history(
|
|
129
|
+
repository: Path,
|
|
130
|
+
symbol: str,
|
|
131
|
+
path: str | None = None,
|
|
132
|
+
con: sqlite3.Connection | None = None,
|
|
133
|
+
max_commits: int = 15,
|
|
134
|
+
) -> SymbolHistoryReport:
|
|
135
|
+
"""Trace the history of a symbol across Git commits deterministically."""
|
|
136
|
+
from codegraph.structural_diff import _get_file_content_at_ref
|
|
137
|
+
|
|
138
|
+
# 1. Resolve path if not provided
|
|
139
|
+
resolved_path = path
|
|
140
|
+
if not resolved_path and con is not None:
|
|
141
|
+
try:
|
|
142
|
+
row = con.execute("SELECT path FROM symbols WHERE name=? LIMIT 1", (symbol,)).fetchone()
|
|
143
|
+
if row:
|
|
144
|
+
resolved_path = str(row[0])
|
|
145
|
+
except (sqlite3.OperationalError, sqlite3.DatabaseError):
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
# 2. Get commits affecting the file or symbol
|
|
149
|
+
commits = recent_commits(repository, n=max_commits * 2, path=resolved_path)
|
|
150
|
+
if not commits:
|
|
151
|
+
return SymbolHistoryReport(
|
|
152
|
+
symbol=symbol,
|
|
153
|
+
current_path=resolved_path,
|
|
154
|
+
events=[],
|
|
155
|
+
total_commits_evaluated=0,
|
|
156
|
+
status="NOT_FOUND",
|
|
157
|
+
detail=f"No Git commits found for symbol '{symbol}' or path '{resolved_path}'.",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
events: list[SymbolHistoryEvent] = []
|
|
161
|
+
current_sym_name = symbol
|
|
162
|
+
current_sym_path = resolved_path or ""
|
|
163
|
+
|
|
164
|
+
prev_parent_content: str | None = None
|
|
165
|
+
prev_parent_hash: str | None = None
|
|
166
|
+
prev_parent_line: int | None = None
|
|
167
|
+
prev_parent_path: str | None = None
|
|
168
|
+
|
|
169
|
+
for c in commits:
|
|
170
|
+
if len(events) >= max_commits:
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
ext = Path(current_sym_path).suffix.lower() if current_sym_path else ".py"
|
|
174
|
+
lang = LANGUAGES.get(ext, "python")
|
|
175
|
+
|
|
176
|
+
# Fast path: check if curr_content was already loaded as parent in previous iteration
|
|
177
|
+
curr_content: str | None = None
|
|
178
|
+
curr_hash: str | None = None
|
|
179
|
+
curr_line: int | None = None
|
|
180
|
+
|
|
181
|
+
if prev_parent_content is not None and prev_parent_path == current_sym_path:
|
|
182
|
+
curr_content = prev_parent_content
|
|
183
|
+
curr_hash, curr_line = prev_parent_hash, prev_parent_line
|
|
184
|
+
else:
|
|
185
|
+
curr_content = _get_file_content_at_ref(repository, c.sha, current_sym_path) if current_sym_path else None
|
|
186
|
+
curr_hash, curr_line = _get_symbol_body_hash(curr_content or "", lang, current_sym_path, current_sym_name) if curr_content else (None, None)
|
|
187
|
+
|
|
188
|
+
# Get file content at parent commit c.sha~1
|
|
189
|
+
parent_content = _get_file_content_at_ref(repository, f"{c.sha}~1", current_sym_path) if current_sym_path else None
|
|
190
|
+
parent_hash, parent_line = _get_symbol_body_hash(parent_content or "", lang, current_sym_path, current_sym_name) if parent_content else (None, None)
|
|
191
|
+
|
|
192
|
+
# Store for next iteration
|
|
193
|
+
prev_parent_content = parent_content
|
|
194
|
+
prev_parent_hash = parent_hash
|
|
195
|
+
prev_parent_line = parent_line
|
|
196
|
+
prev_parent_path = current_sym_path
|
|
197
|
+
|
|
198
|
+
if curr_hash is not None and parent_hash is not None:
|
|
199
|
+
if curr_hash != parent_hash:
|
|
200
|
+
events.append(
|
|
201
|
+
SymbolHistoryEvent(
|
|
202
|
+
commit_sha=c.sha,
|
|
203
|
+
date=c.date,
|
|
204
|
+
author=c.author,
|
|
205
|
+
message=c.message,
|
|
206
|
+
event_type="MODIFIED",
|
|
207
|
+
path=current_sym_path,
|
|
208
|
+
confidence="FACT",
|
|
209
|
+
evidence=f"Symbol modified at line {curr_line} (body hash changed)",
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
elif curr_hash is not None and parent_hash is None:
|
|
213
|
+
# Symbol appeared in this commit! Did it rename or move from somewhere else?
|
|
214
|
+
found_move = False
|
|
215
|
+
# Check git diff --name-status c.sha~1 c.sha for renames
|
|
216
|
+
raw_rename = _run_git(repository, "diff", "-M", "--name-status", f"{c.sha}~1", c.sha)
|
|
217
|
+
if raw_rename:
|
|
218
|
+
for line in raw_rename.splitlines():
|
|
219
|
+
if line.startswith("R") and current_sym_path in line:
|
|
220
|
+
parts = line.split("\t")
|
|
221
|
+
if len(parts) >= 3 and parts[2] == current_sym_path:
|
|
222
|
+
old_p = parts[1]
|
|
223
|
+
events.append(
|
|
224
|
+
SymbolHistoryEvent(
|
|
225
|
+
commit_sha=c.sha,
|
|
226
|
+
date=c.date,
|
|
227
|
+
author=c.author,
|
|
228
|
+
message=c.message,
|
|
229
|
+
event_type="MOVED",
|
|
230
|
+
path=current_sym_path,
|
|
231
|
+
old_path=old_p,
|
|
232
|
+
confidence="FACT",
|
|
233
|
+
evidence=f"File moved from {old_p} to {current_sym_path}",
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
current_sym_path = old_p
|
|
237
|
+
found_move = True
|
|
238
|
+
break
|
|
239
|
+
|
|
240
|
+
if not found_move:
|
|
241
|
+
# Check if introduced
|
|
242
|
+
events.append(
|
|
243
|
+
SymbolHistoryEvent(
|
|
244
|
+
commit_sha=c.sha,
|
|
245
|
+
date=c.date,
|
|
246
|
+
author=c.author,
|
|
247
|
+
message=c.message,
|
|
248
|
+
event_type="INTRODUCED",
|
|
249
|
+
path=current_sym_path,
|
|
250
|
+
confidence="FACT",
|
|
251
|
+
evidence=f"Symbol created at {current_sym_path}:{curr_line}",
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
break
|
|
255
|
+
elif curr_hash is None and parent_hash is not None:
|
|
256
|
+
# Symbol was deleted or renamed in curr_content
|
|
257
|
+
events.append(
|
|
258
|
+
SymbolHistoryEvent(
|
|
259
|
+
commit_sha=c.sha,
|
|
260
|
+
date=c.date,
|
|
261
|
+
author=c.author,
|
|
262
|
+
message=c.message,
|
|
263
|
+
event_type="DELETED",
|
|
264
|
+
path=current_sym_path,
|
|
265
|
+
confidence="FACT",
|
|
266
|
+
evidence=f"Symbol removed in commit {c.sha[:8]}",
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return SymbolHistoryReport(
|
|
271
|
+
symbol=symbol,
|
|
272
|
+
current_path=resolved_path,
|
|
273
|
+
events=events,
|
|
274
|
+
total_commits_evaluated=len(commits),
|
|
275
|
+
status="SUCCESS" if events else "NOT_FOUND",
|
|
276
|
+
detail=f"Traced {len(events)} history event(s) across {len(commits)} commits.",
|
|
277
|
+
)
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""First-class Target Resolver for CodeGraph MCP v2.1.
|
|
2
|
+
|
|
3
|
+
Resolves raw user-supplied target strings (symbol names, routes, file paths,
|
|
4
|
+
class.method forms) against the indexed repository with an explicit,
|
|
5
|
+
ordered resolution strategy.
|
|
6
|
+
|
|
7
|
+
Resolution order:
|
|
8
|
+
1. Exact canonical_id match
|
|
9
|
+
2. Exact qualified_name match
|
|
10
|
+
3. Exact endpoint / route match (route_path or endpoint_id)
|
|
11
|
+
4. Module + symbol form (mod.sym → resolve module, then symbol)
|
|
12
|
+
5. Class.method form (explicit dot-split + join lookup)
|
|
13
|
+
6. Exact filename match
|
|
14
|
+
7. Short name (name = raw) — MEDIUM if unique, AMBIGUOUS if multiple
|
|
15
|
+
8. No match → UNKNOWN
|
|
16
|
+
|
|
17
|
+
Every resolution is source-backed: it only returns what is actually in the
|
|
18
|
+
index. It never fabricates relationships.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import sqlite3
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from enum import StrEnum
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Enumerations
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TargetType(StrEnum):
|
|
32
|
+
FILE = "FILE"
|
|
33
|
+
MODULE = "MODULE"
|
|
34
|
+
CLASS = "CLASS"
|
|
35
|
+
FUNCTION = "FUNCTION"
|
|
36
|
+
METHOD = "METHOD"
|
|
37
|
+
API = "API"
|
|
38
|
+
API_ENDPOINT = "API_ENDPOINT"
|
|
39
|
+
TEST = "TEST"
|
|
40
|
+
TYPE = "TYPE"
|
|
41
|
+
CONFIG = "CONFIG"
|
|
42
|
+
EXTERNAL_SERVICE = "EXTERNAL_SERVICE"
|
|
43
|
+
UNKNOWN = "UNKNOWN"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ResolutionMethod(StrEnum):
|
|
47
|
+
CANONICAL_ID = "CANONICAL_ID"
|
|
48
|
+
EXACT_QUALIFIED = "EXACT_QUALIFIED"
|
|
49
|
+
ROUTE_MATCH = "ROUTE_MATCH"
|
|
50
|
+
MODULE_SYMBOL = "MODULE_SYMBOL"
|
|
51
|
+
CLASS_METHOD = "CLASS_METHOD"
|
|
52
|
+
FILENAME = "FILENAME"
|
|
53
|
+
LEXICAL = "LEXICAL"
|
|
54
|
+
AMBIGUOUS = "AMBIGUOUS"
|
|
55
|
+
UNKNOWN = "UNKNOWN"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Result types
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
_KIND_TO_TARGET_TYPE: dict[str, TargetType] = {
|
|
63
|
+
"class": TargetType.CLASS,
|
|
64
|
+
"function": TargetType.FUNCTION,
|
|
65
|
+
"method": TargetType.METHOD,
|
|
66
|
+
"module": TargetType.MODULE,
|
|
67
|
+
"type": TargetType.TYPE,
|
|
68
|
+
"test": TargetType.TEST,
|
|
69
|
+
"config": TargetType.CONFIG,
|
|
70
|
+
"file": TargetType.FILE,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _kind_to_type(kind: str | None) -> TargetType:
|
|
75
|
+
if not kind:
|
|
76
|
+
return TargetType.UNKNOWN
|
|
77
|
+
return _KIND_TO_TARGET_TYPE.get(kind.lower().strip(), TargetType.UNKNOWN)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class TargetResolution:
|
|
82
|
+
schema_version: str
|
|
83
|
+
raw_target: str
|
|
84
|
+
target_type: TargetType
|
|
85
|
+
canonical_id: str | None
|
|
86
|
+
qualified_name: str | None
|
|
87
|
+
file_path: str | None
|
|
88
|
+
line: int | None
|
|
89
|
+
confidence: str # HIGH | MEDIUM | LOW | UNKNOWN
|
|
90
|
+
resolution_method: ResolutionMethod
|
|
91
|
+
alternatives: tuple[dict[str, object], ...] # other possible resolutions
|
|
92
|
+
ambiguity_state: str # CLEAR | ASSUMED | AMBIGUOUS | CONFLICT | UNKNOWN
|
|
93
|
+
evidence_ids: tuple[str, ...] # canonical_ids of evidence sources
|
|
94
|
+
|
|
95
|
+
def is_resolved(self) -> bool:
|
|
96
|
+
return self.confidence in ("HIGH", "MEDIUM") and self.canonical_id is not None
|
|
97
|
+
|
|
98
|
+
def as_dict(self) -> dict[str, object]:
|
|
99
|
+
return {
|
|
100
|
+
"schema_version": self.schema_version,
|
|
101
|
+
"raw_target": self.raw_target,
|
|
102
|
+
"target_type": self.target_type.value,
|
|
103
|
+
"canonical_id": self.canonical_id,
|
|
104
|
+
"qualified_name": self.qualified_name,
|
|
105
|
+
"file_path": self.file_path,
|
|
106
|
+
"line": self.line,
|
|
107
|
+
"confidence": self.confidence,
|
|
108
|
+
"resolution_method": self.resolution_method.value,
|
|
109
|
+
"alternatives": list(self.alternatives),
|
|
110
|
+
"ambiguity_state": self.ambiguity_state,
|
|
111
|
+
"evidence_ids": list(self.evidence_ids),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
_SCHEMA_VERSION = "2.1"
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Core resolution logic
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
_SYMBOL_SELECT = (
|
|
122
|
+
"SELECT canonical_id, qualified_name, name, kind, path, start_line "
|
|
123
|
+
"FROM symbols"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _row_to_resolution(
|
|
128
|
+
row: sqlite3.Row,
|
|
129
|
+
raw: str,
|
|
130
|
+
method: ResolutionMethod,
|
|
131
|
+
ambiguity_state: str = "CLEAR",
|
|
132
|
+
confidence: str = "HIGH",
|
|
133
|
+
alternatives: tuple[dict[str, object], ...] = (),
|
|
134
|
+
) -> TargetResolution:
|
|
135
|
+
return TargetResolution(
|
|
136
|
+
schema_version=_SCHEMA_VERSION,
|
|
137
|
+
raw_target=raw,
|
|
138
|
+
target_type=_kind_to_type(row["kind"]),
|
|
139
|
+
canonical_id=row["canonical_id"],
|
|
140
|
+
qualified_name=row["qualified_name"],
|
|
141
|
+
file_path=row["path"],
|
|
142
|
+
line=row["start_line"],
|
|
143
|
+
confidence=confidence,
|
|
144
|
+
resolution_method=method,
|
|
145
|
+
alternatives=alternatives,
|
|
146
|
+
ambiguity_state=ambiguity_state,
|
|
147
|
+
evidence_ids=(row["canonical_id"],),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def resolve_target(raw: str, con: sqlite3.Connection) -> TargetResolution:
|
|
152
|
+
"""Resolve a single raw target string against the indexed repository.
|
|
153
|
+
|
|
154
|
+
Resolution order:
|
|
155
|
+
1. Exact canonical_id
|
|
156
|
+
2. Exact qualified_name
|
|
157
|
+
3. Route/endpoint exact match
|
|
158
|
+
4. module.symbol resolution
|
|
159
|
+
5. Class.method resolution
|
|
160
|
+
6. Exact filename
|
|
161
|
+
7. Short name (name=raw)
|
|
162
|
+
8. UNKNOWN fallback
|
|
163
|
+
"""
|
|
164
|
+
clean = raw.strip()
|
|
165
|
+
if not clean:
|
|
166
|
+
return TargetResolution(
|
|
167
|
+
schema_version=_SCHEMA_VERSION,
|
|
168
|
+
raw_target=raw,
|
|
169
|
+
target_type=TargetType.UNKNOWN,
|
|
170
|
+
canonical_id=None,
|
|
171
|
+
qualified_name=None,
|
|
172
|
+
file_path=None,
|
|
173
|
+
line=None,
|
|
174
|
+
confidence="UNKNOWN",
|
|
175
|
+
resolution_method=ResolutionMethod.UNKNOWN,
|
|
176
|
+
alternatives=(),
|
|
177
|
+
ambiguity_state="UNKNOWN",
|
|
178
|
+
evidence_ids=(),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# ── 1. Exact canonical_id ────────────────────────────────────────────────
|
|
182
|
+
row = con.execute(
|
|
183
|
+
_SYMBOL_SELECT + " WHERE canonical_id=? LIMIT 1", (clean,)
|
|
184
|
+
).fetchone()
|
|
185
|
+
if row:
|
|
186
|
+
return _row_to_resolution(row, clean, ResolutionMethod.CANONICAL_ID)
|
|
187
|
+
|
|
188
|
+
# ── 2. Exact qualified_name (only when unique across repository) ─────────
|
|
189
|
+
q_rows = con.execute(
|
|
190
|
+
_SYMBOL_SELECT + " WHERE qualified_name=? ORDER BY path, start_line LIMIT 10", (clean,)
|
|
191
|
+
).fetchall()
|
|
192
|
+
if len(q_rows) == 1:
|
|
193
|
+
return _row_to_resolution(q_rows[0], clean, ResolutionMethod.EXACT_QUALIFIED)
|
|
194
|
+
|
|
195
|
+
# ── 3. Endpoint / route ──────────────────────────────────────────────────
|
|
196
|
+
try:
|
|
197
|
+
route_row = con.execute(
|
|
198
|
+
"SELECT route_path, http_method, handler_name, file_path, line, endpoint_id "
|
|
199
|
+
"FROM framework_routes "
|
|
200
|
+
"WHERE route_path=? OR endpoint_id=? LIMIT 1",
|
|
201
|
+
(clean, clean),
|
|
202
|
+
).fetchone()
|
|
203
|
+
if route_row:
|
|
204
|
+
return TargetResolution(
|
|
205
|
+
schema_version=_SCHEMA_VERSION,
|
|
206
|
+
raw_target=raw,
|
|
207
|
+
target_type=TargetType.API_ENDPOINT,
|
|
208
|
+
canonical_id=route_row["endpoint_id"] or f"{route_row['http_method']} {route_row['route_path']}",
|
|
209
|
+
qualified_name=route_row["route_path"],
|
|
210
|
+
file_path=route_row["file_path"],
|
|
211
|
+
line=route_row["line"],
|
|
212
|
+
confidence="HIGH",
|
|
213
|
+
resolution_method=ResolutionMethod.ROUTE_MATCH,
|
|
214
|
+
alternatives=(),
|
|
215
|
+
ambiguity_state="CLEAR",
|
|
216
|
+
evidence_ids=(route_row["endpoint_id"] or clean,),
|
|
217
|
+
)
|
|
218
|
+
except sqlite3.OperationalError:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
# ── 4. Module.symbol (e.g. "src.services.auth_service.AuthService") ─────
|
|
222
|
+
if "." in clean:
|
|
223
|
+
parts = clean.rsplit(".", 1)
|
|
224
|
+
module_part, sym_part = parts[0], parts[1]
|
|
225
|
+
row = con.execute(
|
|
226
|
+
_SYMBOL_SELECT
|
|
227
|
+
+ " WHERE (module=? OR module LIKE ?) AND (name=? OR qualified_name=?) "
|
|
228
|
+
"ORDER BY path, start_line LIMIT 1",
|
|
229
|
+
(module_part, f"%.{module_part}", sym_part, clean),
|
|
230
|
+
).fetchone()
|
|
231
|
+
if row:
|
|
232
|
+
return _row_to_resolution(row, clean, ResolutionMethod.MODULE_SYMBOL)
|
|
233
|
+
|
|
234
|
+
# ── 5. Class.method (e.g. "AdminDashboardView.get") ─────────────────────
|
|
235
|
+
if "." in clean:
|
|
236
|
+
class_part, method_part = clean.rsplit(".", 1)
|
|
237
|
+
# Look for parent class, then find method inside it
|
|
238
|
+
class_row = con.execute(
|
|
239
|
+
_SYMBOL_SELECT + " WHERE (name=? OR qualified_name LIKE ?) AND kind='class' LIMIT 1",
|
|
240
|
+
(class_part, f"%.{class_part}"),
|
|
241
|
+
).fetchone()
|
|
242
|
+
if class_row:
|
|
243
|
+
method_row = con.execute(
|
|
244
|
+
_SYMBOL_SELECT
|
|
245
|
+
+ " WHERE name=? AND parent_symbol_id=? LIMIT 1",
|
|
246
|
+
(method_part, class_row["canonical_id"]),
|
|
247
|
+
).fetchone()
|
|
248
|
+
if method_row:
|
|
249
|
+
return _row_to_resolution(method_row, clean, ResolutionMethod.CLASS_METHOD)
|
|
250
|
+
# fallback: qualified_name contains the class.method pattern
|
|
251
|
+
qrow = con.execute(
|
|
252
|
+
_SYMBOL_SELECT + " WHERE qualified_name LIKE ? LIMIT 1",
|
|
253
|
+
(f"%.{clean}",),
|
|
254
|
+
).fetchone()
|
|
255
|
+
if qrow:
|
|
256
|
+
return _row_to_resolution(qrow, clean, ResolutionMethod.CLASS_METHOD)
|
|
257
|
+
|
|
258
|
+
# ── 6. Exact filename ────────────────────────────────────────────────────
|
|
259
|
+
try:
|
|
260
|
+
file_row = con.execute(
|
|
261
|
+
"SELECT path FROM files WHERE path=? OR path LIKE ? LIMIT 1",
|
|
262
|
+
(clean, f"%/{clean}"),
|
|
263
|
+
).fetchone()
|
|
264
|
+
if file_row:
|
|
265
|
+
return TargetResolution(
|
|
266
|
+
schema_version=_SCHEMA_VERSION,
|
|
267
|
+
raw_target=raw,
|
|
268
|
+
target_type=TargetType.FILE,
|
|
269
|
+
canonical_id=file_row["path"],
|
|
270
|
+
qualified_name=file_row["path"],
|
|
271
|
+
file_path=file_row["path"],
|
|
272
|
+
line=None,
|
|
273
|
+
confidence="HIGH",
|
|
274
|
+
resolution_method=ResolutionMethod.FILENAME,
|
|
275
|
+
alternatives=(),
|
|
276
|
+
ambiguity_state="CLEAR",
|
|
277
|
+
evidence_ids=(file_row["path"],),
|
|
278
|
+
)
|
|
279
|
+
except sqlite3.OperationalError:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
# ── 7. Short name (name = clean) — ambiguity-aware ──────────────────────
|
|
283
|
+
short_rows = con.execute(
|
|
284
|
+
_SYMBOL_SELECT + " WHERE name=? ORDER BY kind, path, start_line LIMIT 10",
|
|
285
|
+
(clean,),
|
|
286
|
+
).fetchall()
|
|
287
|
+
if len(short_rows) == 1:
|
|
288
|
+
return _row_to_resolution(
|
|
289
|
+
short_rows[0], clean, ResolutionMethod.LEXICAL,
|
|
290
|
+
ambiguity_state="CLEAR", confidence="MEDIUM",
|
|
291
|
+
)
|
|
292
|
+
if len(short_rows) > 1:
|
|
293
|
+
alternatives = tuple(
|
|
294
|
+
{
|
|
295
|
+
"canonical_id": r["canonical_id"],
|
|
296
|
+
"qualified_name": r["qualified_name"],
|
|
297
|
+
"kind": r["kind"],
|
|
298
|
+
"path": r["path"],
|
|
299
|
+
"line": r["start_line"],
|
|
300
|
+
}
|
|
301
|
+
for r in short_rows
|
|
302
|
+
)
|
|
303
|
+
# Pick the non-test one if unique
|
|
304
|
+
non_test = [r for r in short_rows if "test" not in (r["path"] or "").lower()]
|
|
305
|
+
if len(non_test) == 1:
|
|
306
|
+
return _row_to_resolution(
|
|
307
|
+
non_test[0], clean, ResolutionMethod.LEXICAL,
|
|
308
|
+
ambiguity_state="ASSUMED", confidence="MEDIUM",
|
|
309
|
+
alternatives=alternatives,
|
|
310
|
+
)
|
|
311
|
+
return TargetResolution(
|
|
312
|
+
schema_version=_SCHEMA_VERSION,
|
|
313
|
+
raw_target=raw,
|
|
314
|
+
target_type=_kind_to_type(short_rows[0]["kind"]),
|
|
315
|
+
canonical_id=short_rows[0]["canonical_id"],
|
|
316
|
+
qualified_name=short_rows[0]["qualified_name"],
|
|
317
|
+
file_path=short_rows[0]["path"],
|
|
318
|
+
line=short_rows[0]["start_line"],
|
|
319
|
+
confidence="LOW",
|
|
320
|
+
resolution_method=ResolutionMethod.AMBIGUOUS,
|
|
321
|
+
alternatives=alternatives,
|
|
322
|
+
ambiguity_state="AMBIGUOUS",
|
|
323
|
+
evidence_ids=tuple(r["canonical_id"] for r in short_rows),
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# ── 8. UNKNOWN ───────────────────────────────────────────────────────────
|
|
327
|
+
return TargetResolution(
|
|
328
|
+
schema_version=_SCHEMA_VERSION,
|
|
329
|
+
raw_target=raw,
|
|
330
|
+
target_type=TargetType.UNKNOWN,
|
|
331
|
+
canonical_id=None,
|
|
332
|
+
qualified_name=None,
|
|
333
|
+
file_path=None,
|
|
334
|
+
line=None,
|
|
335
|
+
confidence="UNKNOWN",
|
|
336
|
+
resolution_method=ResolutionMethod.UNKNOWN,
|
|
337
|
+
alternatives=(),
|
|
338
|
+
ambiguity_state="UNKNOWN",
|
|
339
|
+
evidence_ids=(),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def resolve_targets(
|
|
344
|
+
targets: list[str],
|
|
345
|
+
con: sqlite3.Connection,
|
|
346
|
+
) -> dict[str, TargetResolution]:
|
|
347
|
+
"""Resolve a list of target strings. Returns mapping raw_target -> TargetResolution."""
|
|
348
|
+
return {t: resolve_target(t, con) for t in targets}
|