codegraph-engine 3.0.0__2-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.
Files changed (130) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/agent_brain.py +2212 -0
  4. codegraph/agent_capabilities.py +4058 -0
  5. codegraph/agent_rules.py +531 -0
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture.py +371 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/audit.py +106 -0
  22. codegraph/binding_resolver.py +616 -0
  23. codegraph/branch_comparison.py +197 -0
  24. codegraph/cache.py +95 -0
  25. codegraph/change_impact.py +547 -0
  26. codegraph/cli.py +2436 -0
  27. codegraph/cli_output.py +205 -0
  28. codegraph/config.py +45 -0
  29. codegraph/constraints.py +238 -0
  30. codegraph/context.py +2182 -0
  31. codegraph/context_freshness.py +351 -0
  32. codegraph/database/__init__.py +72 -0
  33. codegraph/database/extractor.py +3651 -0
  34. codegraph/database/interrogation.py +1494 -0
  35. codegraph/database/models.py +310 -0
  36. codegraph/database/schema_drift.py +862 -0
  37. codegraph/epistemic.py +200 -0
  38. codegraph/errors.py +282 -0
  39. codegraph/evidence/__init__.py +15 -0
  40. codegraph/evidence/citations.py +418 -0
  41. codegraph/evidence_contract.py +486 -0
  42. codegraph/frameworks.py +960 -0
  43. codegraph/frameworks_nestjs.py +502 -0
  44. codegraph/frameworks_nextjs.py +337 -0
  45. codegraph/frameworks_react.py +457 -0
  46. codegraph/freshness.py +335 -0
  47. codegraph/frontend_html.py +109 -0
  48. codegraph/git.py +362 -0
  49. codegraph/git_state.py +545 -0
  50. codegraph/graph/__init__.py +46 -0
  51. codegraph/graph/models.py +56 -0
  52. codegraph/graph/traversal.py +1993 -0
  53. codegraph/indexing/__init__.py +24 -0
  54. codegraph/indexing/classifier.py +564 -0
  55. codegraph/indexing/indexer.py +2578 -0
  56. codegraph/indexing/models.py +427 -0
  57. codegraph/indexing/parser.py +3404 -0
  58. codegraph/indexing/pool.py +136 -0
  59. codegraph/indexing/scanner.py +211 -0
  60. codegraph/indexing/telemetry.py +326 -0
  61. codegraph/indexing/templates.py +418 -0
  62. codegraph/indexing/test_framework.py +116 -0
  63. codegraph/installer.py +1702 -0
  64. codegraph/interrogation.py +2355 -0
  65. codegraph/llm/__init__.py +3 -0
  66. codegraph/llm/base.py +15 -0
  67. codegraph/llm/context.py +20 -0
  68. codegraph/mcp/__init__.py +3 -0
  69. codegraph/mcp/server.py +1476 -0
  70. codegraph/mcp_diagnostics.py +882 -0
  71. codegraph/memory/__init__.py +3 -0
  72. codegraph/memory/store.py +46 -0
  73. codegraph/models.py +310 -0
  74. codegraph/monorepo.py +925 -0
  75. codegraph/observability.py +262 -0
  76. codegraph/optimizer.py +1014 -0
  77. codegraph/planner.py +417 -0
  78. codegraph/process_lifecycle.py +1153 -0
  79. codegraph/py.typed +1 -0
  80. codegraph/query_expansion.py +199 -0
  81. codegraph/ranking.py +408 -0
  82. codegraph/refactor/__init__.py +36 -0
  83. codegraph/refactor/models.py +114 -0
  84. codegraph/refactor/renamer.py +844 -0
  85. codegraph/refactor/transactions.py +414 -0
  86. codegraph/resolver.py +3968 -0
  87. codegraph/resources/__init__.py +46 -0
  88. codegraph/resources/cache.py +117 -0
  89. codegraph/resources/coalescer.py +83 -0
  90. codegraph/resources/debouncer.py +98 -0
  91. codegraph/resources/governor.py +219 -0
  92. codegraph/resources/monitor.py +102 -0
  93. codegraph/resources/policy.py +123 -0
  94. codegraph/retrieval_policy.py +320 -0
  95. codegraph/roblox.py +2174 -0
  96. codegraph/route_composer.py +363 -0
  97. codegraph/route_topology.py +172 -0
  98. codegraph/runtime/__init__.py +51 -0
  99. codegraph/runtime/collector.py +155 -0
  100. codegraph/runtime/db_watcher.py +191 -0
  101. codegraph/runtime/ingestor.py +692 -0
  102. codegraph/runtime/models.py +130 -0
  103. codegraph/runtime/port_inspector.py +304 -0
  104. codegraph/runtime/reconciliation.py +492 -0
  105. codegraph/runtime/runner.py +353 -0
  106. codegraph/runtime/trace_linker.py +175 -0
  107. codegraph/search/__init__.py +25 -0
  108. codegraph/search/hybrid.py +846 -0
  109. codegraph/search/semantic.py +28 -0
  110. codegraph/security/__init__.py +51 -0
  111. codegraph/security/paths.py +189 -0
  112. codegraph/security/redaction.py +633 -0
  113. codegraph/semantic_conflicts.py +270 -0
  114. codegraph/semantic_decorators.py +372 -0
  115. codegraph/structural_diff.py +542 -0
  116. codegraph/styles.py +144 -0
  117. codegraph/symbol_history.py +277 -0
  118. codegraph/target_resolver.py +348 -0
  119. codegraph/task.py +637 -0
  120. codegraph/tool_selection_eval.py +1158 -0
  121. codegraph/tsconfig.py +236 -0
  122. codegraph/ui/__init__.py +6 -0
  123. codegraph/ui/server.py +755 -0
  124. codegraph/watcher.py +323 -0
  125. codegraph_engine-3.0.0.dist-info/METADATA +868 -0
  126. codegraph_engine-3.0.0.dist-info/RECORD +130 -0
  127. codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
  128. codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
  129. codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
  130. 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}