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,2355 @@
|
|
|
1
|
+
"""Core deterministic repository interrogation engine for CodeGraph MCP.
|
|
2
|
+
|
|
3
|
+
Principle:
|
|
4
|
+
"The AI understands the developer. CodeGraph interrogates the repository."
|
|
5
|
+
"The claim comes from the AI; the proof comes from CodeGraph."
|
|
6
|
+
|
|
7
|
+
Invariant:
|
|
8
|
+
same repository + same index generation + same MCP request = same deterministic result.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sqlite3
|
|
13
|
+
from collections import deque
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from codegraph.architecture import get_architecture as arch_get_architecture
|
|
18
|
+
from codegraph.errors import ErrorCode, SecurityError
|
|
19
|
+
from codegraph.freshness import check_freshness, index_generation
|
|
20
|
+
from codegraph.git import (
|
|
21
|
+
changed_files,
|
|
22
|
+
changed_symbols_since,
|
|
23
|
+
current_commit,
|
|
24
|
+
)
|
|
25
|
+
from codegraph.security import contains_private_key, is_sensitive, redact_secrets, safe_path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_evidence(
|
|
29
|
+
file: str,
|
|
30
|
+
start_line: int,
|
|
31
|
+
end_line: int,
|
|
32
|
+
evidence_type: str,
|
|
33
|
+
canonical_id: str | None = None,
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
"""Create a standardized, evidence-backed citation record."""
|
|
36
|
+
return {
|
|
37
|
+
"file": file,
|
|
38
|
+
"start_line": start_line,
|
|
39
|
+
"end_line": end_line,
|
|
40
|
+
"type": evidence_type,
|
|
41
|
+
"canonical_id": canonical_id or "",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_index_metadata(con: sqlite3.Connection, repository: Path) -> dict[str, Any]:
|
|
46
|
+
"""Return index generation, freshness, and repository commit identity."""
|
|
47
|
+
gen = index_generation(con)
|
|
48
|
+
freshness_rep = check_freshness(repository, con)
|
|
49
|
+
commit = current_commit(repository)
|
|
50
|
+
|
|
51
|
+
created_at = ""
|
|
52
|
+
try:
|
|
53
|
+
row = con.execute("SELECT value FROM metadata WHERE key='indexed_at'").fetchone()
|
|
54
|
+
if row:
|
|
55
|
+
created_at = str(row[0])
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
"index": {
|
|
61
|
+
"generation": gen,
|
|
62
|
+
"created_at": created_at,
|
|
63
|
+
"freshness": freshness_rep.status.value,
|
|
64
|
+
},
|
|
65
|
+
"repository": {
|
|
66
|
+
"commit": commit,
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_index_available(con: sqlite3.Connection, repository: Path) -> dict[str, Any] | None:
|
|
72
|
+
"""Return an INDEX_NOT_FOUND error envelope if the repository has not been indexed."""
|
|
73
|
+
try:
|
|
74
|
+
count = con.execute("SELECT count(*) FROM files").fetchone()[0]
|
|
75
|
+
except sqlite3.OperationalError:
|
|
76
|
+
count = 0
|
|
77
|
+
if count == 0:
|
|
78
|
+
return {
|
|
79
|
+
"status": "error",
|
|
80
|
+
"error": {
|
|
81
|
+
"code": ErrorCode.INDEX_NOT_FOUND.value,
|
|
82
|
+
"message": "No CodeGraph index exists for this repository.",
|
|
83
|
+
"next_action": {
|
|
84
|
+
"command": "codegraph init",
|
|
85
|
+
"reason": "Initialize the repository before querying it.",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# 1. resolve_symbol
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
def resolve_symbol(
|
|
96
|
+
con: sqlite3.Connection,
|
|
97
|
+
repository: Path,
|
|
98
|
+
name: str = "",
|
|
99
|
+
symbol: str = "",
|
|
100
|
+
canonical_id: str = "",
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
"""Determine whether an exact/canonical symbol exists and return its location and identity.
|
|
103
|
+
|
|
104
|
+
CodeGraph MUST NOT choose an intended candidate when ambiguous.
|
|
105
|
+
"""
|
|
106
|
+
unindexed = check_index_available(con, repository)
|
|
107
|
+
if unindexed:
|
|
108
|
+
return unindexed
|
|
109
|
+
|
|
110
|
+
meta = get_index_metadata(con, repository)
|
|
111
|
+
provided = [v.strip() for v in (symbol, name, canonical_id) if v and v.strip()]
|
|
112
|
+
if len(set(provided)) > 1:
|
|
113
|
+
return {
|
|
114
|
+
"status": "invalid_request",
|
|
115
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
116
|
+
"error": {
|
|
117
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
118
|
+
"message": "Conflicting symbol arguments provided to resolve_symbol; pass one or identical values.",
|
|
119
|
+
},
|
|
120
|
+
"query": symbol or name or canonical_id,
|
|
121
|
+
**meta,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
raw_target = symbol if symbol.strip() else (name if name.strip() else canonical_id)
|
|
125
|
+
clean_name = raw_target.strip()
|
|
126
|
+
if not clean_name:
|
|
127
|
+
return {
|
|
128
|
+
"status": "invalid_request",
|
|
129
|
+
"error_code": ErrorCode.EMPTY_SYMBOL_NAME.value,
|
|
130
|
+
"error": {
|
|
131
|
+
"code": ErrorCode.EMPTY_SYMBOL_NAME.value,
|
|
132
|
+
"message": "Symbol name must be non-empty.",
|
|
133
|
+
"next_action": {
|
|
134
|
+
"command": "codegraph resolve <symbol>",
|
|
135
|
+
"reason": "Provide a non-empty symbol name to resolve.",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
"query": raw_target,
|
|
139
|
+
**meta,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# Query matching symbols
|
|
143
|
+
rows = con.execute(
|
|
144
|
+
"SELECT canonical_id, name, qualified_name, kind, path, start_line, end_line "
|
|
145
|
+
"FROM symbols "
|
|
146
|
+
"WHERE canonical_id=? OR qualified_name=? OR name=? "
|
|
147
|
+
"ORDER BY canonical_id ASC",
|
|
148
|
+
(clean_name, clean_name, clean_name),
|
|
149
|
+
).fetchall()
|
|
150
|
+
|
|
151
|
+
if not rows:
|
|
152
|
+
return {
|
|
153
|
+
"status": "not_found",
|
|
154
|
+
"query": clean_name,
|
|
155
|
+
"error": {
|
|
156
|
+
"code": ErrorCode.SYMBOL_NOT_FOUND.value,
|
|
157
|
+
"message": f"No matching symbol was found for '{clean_name}'.",
|
|
158
|
+
"next_action": {
|
|
159
|
+
"command": f"codegraph search {clean_name}",
|
|
160
|
+
"reason": "Search with a broader query term.",
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
**meta,
|
|
164
|
+
"matches": [],
|
|
165
|
+
"candidates": [],
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# Check for exact canonical_id match
|
|
169
|
+
exact_canonical = [r for r in rows if r["canonical_id"] == clean_name]
|
|
170
|
+
if len(exact_canonical) == 1:
|
|
171
|
+
match = exact_canonical[0]
|
|
172
|
+
ev = [
|
|
173
|
+
make_evidence(
|
|
174
|
+
file=match["path"],
|
|
175
|
+
start_line=match["start_line"],
|
|
176
|
+
end_line=match["end_line"],
|
|
177
|
+
evidence_type="definition",
|
|
178
|
+
canonical_id=match["canonical_id"],
|
|
179
|
+
)
|
|
180
|
+
]
|
|
181
|
+
return {
|
|
182
|
+
"status": "ok",
|
|
183
|
+
**meta,
|
|
184
|
+
"symbol": {
|
|
185
|
+
"name": match["name"],
|
|
186
|
+
"canonical_id": match["canonical_id"],
|
|
187
|
+
"kind": match["kind"],
|
|
188
|
+
},
|
|
189
|
+
"location": {
|
|
190
|
+
"file": match["path"],
|
|
191
|
+
"start_line": match["start_line"],
|
|
192
|
+
"end_line": match["end_line"],
|
|
193
|
+
},
|
|
194
|
+
"evidence": ev,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
# If only one match overall
|
|
198
|
+
if len(rows) == 1:
|
|
199
|
+
match = rows[0]
|
|
200
|
+
ev = [
|
|
201
|
+
make_evidence(
|
|
202
|
+
file=match["path"],
|
|
203
|
+
start_line=match["start_line"],
|
|
204
|
+
end_line=match["end_line"],
|
|
205
|
+
evidence_type="definition",
|
|
206
|
+
canonical_id=match["canonical_id"],
|
|
207
|
+
)
|
|
208
|
+
]
|
|
209
|
+
return {
|
|
210
|
+
"status": "ok",
|
|
211
|
+
**meta,
|
|
212
|
+
"symbol": {
|
|
213
|
+
"name": match["name"],
|
|
214
|
+
"canonical_id": match["canonical_id"],
|
|
215
|
+
"kind": match["kind"],
|
|
216
|
+
},
|
|
217
|
+
"location": {
|
|
218
|
+
"file": match["path"],
|
|
219
|
+
"start_line": match["start_line"],
|
|
220
|
+
"end_line": match["end_line"],
|
|
221
|
+
},
|
|
222
|
+
"evidence": ev,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# Multiple candidates exist -> ambiguous! CodeGraph MUST NOT choose!
|
|
226
|
+
matches = [
|
|
227
|
+
{
|
|
228
|
+
"canonical_id": r["canonical_id"],
|
|
229
|
+
"symbol": r["name"],
|
|
230
|
+
"name": r["name"],
|
|
231
|
+
"file": r["path"],
|
|
232
|
+
"kind": r["kind"],
|
|
233
|
+
"line": r["start_line"],
|
|
234
|
+
"start_line": r["start_line"],
|
|
235
|
+
"end_line": r["end_line"],
|
|
236
|
+
}
|
|
237
|
+
for r in rows
|
|
238
|
+
]
|
|
239
|
+
matches.sort(key=lambda m: str(m["canonical_id"]))
|
|
240
|
+
return {
|
|
241
|
+
"status": "ambiguous",
|
|
242
|
+
"query": clean_name,
|
|
243
|
+
"error": {
|
|
244
|
+
"code": ErrorCode.SYMBOL_AMBIGUOUS.value,
|
|
245
|
+
"message": f"Multiple symbols match '{clean_name}'. Provide a qualified name or canonical ID.",
|
|
246
|
+
"next_action": {
|
|
247
|
+
"command": "codegraph resolve <canonical_id>",
|
|
248
|
+
"reason": "Select an explicit canonical ID from candidates.",
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
**meta,
|
|
252
|
+
"matches": matches,
|
|
253
|
+
"candidates": matches,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# 2. search_symbols
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
def search_symbols(
|
|
261
|
+
con: sqlite3.Connection,
|
|
262
|
+
repository: Path,
|
|
263
|
+
query: str,
|
|
264
|
+
top_k: int = 20,
|
|
265
|
+
) -> dict[str, Any]:
|
|
266
|
+
"""Search indexed repository symbols using explicit search terms with deterministic ranking."""
|
|
267
|
+
unindexed = check_index_available(con, repository)
|
|
268
|
+
if unindexed:
|
|
269
|
+
return unindexed
|
|
270
|
+
|
|
271
|
+
clean_query = query.strip()
|
|
272
|
+
meta = get_index_metadata(con, repository)
|
|
273
|
+
if not clean_query:
|
|
274
|
+
return {
|
|
275
|
+
"status": "error",
|
|
276
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
277
|
+
"error": {
|
|
278
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
279
|
+
"message": "Search query must be non-empty.",
|
|
280
|
+
"next_action": {
|
|
281
|
+
"command": "codegraph search <query>",
|
|
282
|
+
"reason": "Provide a non-empty query term.",
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
"query": query,
|
|
286
|
+
**meta,
|
|
287
|
+
"count": 0,
|
|
288
|
+
"symbols": [],
|
|
289
|
+
"evidence": [],
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
bounded_k = max(1, min(top_k, 100))
|
|
293
|
+
|
|
294
|
+
# Search symbols joined with files to exclude GENERATED files
|
|
295
|
+
rows = con.execute(
|
|
296
|
+
"SELECT s.canonical_id, s.name, s.qualified_name, s.kind, s.path, s.start_line, s.end_line, f.category "
|
|
297
|
+
"FROM symbols s "
|
|
298
|
+
"JOIN files f ON f.path = s.path "
|
|
299
|
+
"WHERE (f.category != 'GENERATED' OR f.category IS NULL) "
|
|
300
|
+
"AND (s.name LIKE ? OR s.qualified_name LIKE ? OR s.canonical_id LIKE ?) "
|
|
301
|
+
"ORDER BY s.canonical_id ASC",
|
|
302
|
+
(f"%{clean_query}%", f"%{clean_query}%", f"%{clean_query}%"),
|
|
303
|
+
).fetchall()
|
|
304
|
+
|
|
305
|
+
# Deterministic scoring:
|
|
306
|
+
# 1.0 = exact match on name or qualified_name
|
|
307
|
+
# 0.85 = prefix match on name
|
|
308
|
+
# 0.70 = substring match on name
|
|
309
|
+
# 0.60 = match elsewhere
|
|
310
|
+
scored_results: list[dict[str, Any]] = []
|
|
311
|
+
q_lower = clean_query.lower()
|
|
312
|
+
|
|
313
|
+
for r in rows:
|
|
314
|
+
name = r["name"]
|
|
315
|
+
qname = r["qualified_name"] or ""
|
|
316
|
+
cid = r["canonical_id"] or ""
|
|
317
|
+
name_lower = name.lower()
|
|
318
|
+
|
|
319
|
+
if name_lower == q_lower or qname.lower() == q_lower:
|
|
320
|
+
score = 1.00
|
|
321
|
+
elif name_lower.startswith(q_lower):
|
|
322
|
+
score = 0.85
|
|
323
|
+
elif q_lower in name_lower:
|
|
324
|
+
score = 0.70
|
|
325
|
+
else:
|
|
326
|
+
score = 0.60
|
|
327
|
+
|
|
328
|
+
scored_results.append(
|
|
329
|
+
{
|
|
330
|
+
"name": name,
|
|
331
|
+
"canonical_id": cid,
|
|
332
|
+
"kind": r["kind"],
|
|
333
|
+
"file": r["path"],
|
|
334
|
+
"start_line": r["start_line"],
|
|
335
|
+
"end_line": r["end_line"],
|
|
336
|
+
"score": round(score, 2),
|
|
337
|
+
"evidence": make_evidence(
|
|
338
|
+
file=r["path"],
|
|
339
|
+
start_line=r["start_line"],
|
|
340
|
+
end_line=r["end_line"],
|
|
341
|
+
evidence_type="symbol_match",
|
|
342
|
+
canonical_id=cid,
|
|
343
|
+
),
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Sort deterministically: highest score first, ties broken by canonical_id ASC
|
|
348
|
+
scored_results.sort(key=lambda s: (-float(s["score"]), str(s["canonical_id"])))
|
|
349
|
+
selected = scored_results[:bounded_k]
|
|
350
|
+
|
|
351
|
+
evidence_list = [s["evidence"] for s in selected]
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
"status": "ok",
|
|
355
|
+
"query": clean_query,
|
|
356
|
+
**meta,
|
|
357
|
+
"count": len(selected),
|
|
358
|
+
"symbols": selected,
|
|
359
|
+
"evidence": evidence_list,
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
# 3. get_symbol
|
|
365
|
+
# ---------------------------------------------------------------------------
|
|
366
|
+
def get_symbol(
|
|
367
|
+
con: sqlite3.Connection,
|
|
368
|
+
repository: Path,
|
|
369
|
+
canonical_id: str = "",
|
|
370
|
+
symbol: str = "",
|
|
371
|
+
) -> dict[str, Any]:
|
|
372
|
+
"""Return complete structured information for a known canonical symbol."""
|
|
373
|
+
unindexed = check_index_available(con, repository)
|
|
374
|
+
if unindexed:
|
|
375
|
+
return unindexed
|
|
376
|
+
|
|
377
|
+
meta = get_index_metadata(con, repository)
|
|
378
|
+
if symbol.strip() and canonical_id.strip() and symbol.strip() != canonical_id.strip():
|
|
379
|
+
return {
|
|
380
|
+
"status": "error",
|
|
381
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
382
|
+
"error": {
|
|
383
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
384
|
+
"message": f"Conflicting arguments: symbol='{symbol}' and canonical_id='{canonical_id}'.",
|
|
385
|
+
},
|
|
386
|
+
"canonical_id": canonical_id or symbol,
|
|
387
|
+
**meta,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
raw_input = symbol if symbol.strip() else canonical_id
|
|
391
|
+
clean_id = raw_input.strip()
|
|
392
|
+
if not clean_id:
|
|
393
|
+
return {
|
|
394
|
+
"status": "error",
|
|
395
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
396
|
+
"error": {
|
|
397
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
398
|
+
"message": "Canonical ID or symbol name must be non-empty.",
|
|
399
|
+
"next_action": {
|
|
400
|
+
"command": "codegraph get-symbol <symbol>",
|
|
401
|
+
"reason": "Provide a non-empty canonical ID or symbol name.",
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
"canonical_id": raw_input,
|
|
405
|
+
**meta,
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
row = con.execute(
|
|
409
|
+
"SELECT id, canonical_id, qualified_name, name, kind, path, start_line, end_line, "
|
|
410
|
+
"parent_symbol_id, module, scope, language, signature, content_hash, visibility, "
|
|
411
|
+
"return_type, parameter_count, documentation, decorators "
|
|
412
|
+
"FROM symbols "
|
|
413
|
+
"WHERE canonical_id=? OR qualified_name=? OR name=? "
|
|
414
|
+
"ORDER BY canonical_id=? DESC "
|
|
415
|
+
"LIMIT 1",
|
|
416
|
+
(clean_id, clean_id, clean_id, clean_id),
|
|
417
|
+
).fetchone()
|
|
418
|
+
|
|
419
|
+
if not row:
|
|
420
|
+
return {
|
|
421
|
+
"status": "not_found",
|
|
422
|
+
"canonical_id": clean_id,
|
|
423
|
+
"error": {
|
|
424
|
+
"code": ErrorCode.SYMBOL_NOT_FOUND.value,
|
|
425
|
+
"message": f"No symbol found with canonical ID or name: '{clean_id}'",
|
|
426
|
+
"next_action": {
|
|
427
|
+
"command": f"codegraph search {clean_id}",
|
|
428
|
+
"reason": "Search for symbol candidates.",
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
**meta,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
# Query parent symbol
|
|
435
|
+
parent_info: str | None = None
|
|
436
|
+
if row["parent_symbol_id"]:
|
|
437
|
+
p_row = con.execute(
|
|
438
|
+
"SELECT canonical_id, qualified_name FROM symbols WHERE id=?",
|
|
439
|
+
(row["parent_symbol_id"],),
|
|
440
|
+
).fetchone()
|
|
441
|
+
if p_row:
|
|
442
|
+
parent_info = p_row["canonical_id"] or p_row["qualified_name"]
|
|
443
|
+
|
|
444
|
+
# Query children
|
|
445
|
+
children_rows = con.execute(
|
|
446
|
+
"SELECT canonical_id, name, kind, start_line, end_line FROM symbols "
|
|
447
|
+
"WHERE parent_symbol_id=? ORDER BY start_line ASC, canonical_id ASC",
|
|
448
|
+
(row["id"],),
|
|
449
|
+
).fetchall()
|
|
450
|
+
children = [
|
|
451
|
+
{
|
|
452
|
+
"canonical_id": c["canonical_id"],
|
|
453
|
+
"name": c["name"],
|
|
454
|
+
"kind": c["kind"],
|
|
455
|
+
"start_line": c["start_line"],
|
|
456
|
+
"end_line": c["end_line"],
|
|
457
|
+
}
|
|
458
|
+
for c in children_rows
|
|
459
|
+
]
|
|
460
|
+
|
|
461
|
+
# Parse decorators
|
|
462
|
+
decors_raw = row["decorators"] or ""
|
|
463
|
+
decorators: list[str] = []
|
|
464
|
+
if decors_raw:
|
|
465
|
+
decorators = [d.strip() for d in decors_raw.split(",") if d.strip()]
|
|
466
|
+
|
|
467
|
+
# Query relationships from graph_edges
|
|
468
|
+
cid = row["canonical_id"]
|
|
469
|
+
rel_rows = con.execute(
|
|
470
|
+
"SELECT target, relationship, confidence, file, start_line, end_line, evidence_class, reason "
|
|
471
|
+
"FROM graph_edges WHERE source=? ORDER BY relationship ASC, target ASC",
|
|
472
|
+
(cid,),
|
|
473
|
+
).fetchall()
|
|
474
|
+
relationships = [
|
|
475
|
+
{
|
|
476
|
+
"target": r["target"],
|
|
477
|
+
"relationship": r["relationship"],
|
|
478
|
+
"confidence": r["confidence"],
|
|
479
|
+
"file": r["file"],
|
|
480
|
+
"start_line": r["start_line"],
|
|
481
|
+
"end_line": r["end_line"],
|
|
482
|
+
"evidence_class": r["evidence_class"] if "evidence_class" in r.keys() else "AST_VERIFIED",
|
|
483
|
+
"reason": r["reason"] if "reason" in r.keys() else None,
|
|
484
|
+
}
|
|
485
|
+
for r in rel_rows
|
|
486
|
+
]
|
|
487
|
+
|
|
488
|
+
ev = [
|
|
489
|
+
make_evidence(
|
|
490
|
+
file=row["path"],
|
|
491
|
+
start_line=row["start_line"],
|
|
492
|
+
end_line=row["end_line"],
|
|
493
|
+
evidence_type="definition",
|
|
494
|
+
canonical_id=row["canonical_id"],
|
|
495
|
+
)
|
|
496
|
+
]
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
"status": "ok",
|
|
500
|
+
**meta,
|
|
501
|
+
"evidence": ev,
|
|
502
|
+
"symbol": {
|
|
503
|
+
"canonical_id": row["canonical_id"],
|
|
504
|
+
"name": row["name"],
|
|
505
|
+
"qualified_name": row["qualified_name"],
|
|
506
|
+
"kind": row["kind"],
|
|
507
|
+
"file": row["path"],
|
|
508
|
+
"start_line": row["start_line"],
|
|
509
|
+
"end_line": row["end_line"],
|
|
510
|
+
"module": row["module"],
|
|
511
|
+
"scope": row["scope"],
|
|
512
|
+
"language": row["language"],
|
|
513
|
+
"signature": redact_secrets(str(row["signature"] or "")),
|
|
514
|
+
"parent": parent_info,
|
|
515
|
+
"children": children,
|
|
516
|
+
"decorators": decorators,
|
|
517
|
+
"docstring": redact_secrets(str(row["documentation"] or "")),
|
|
518
|
+
"visibility": row["visibility"] or "public",
|
|
519
|
+
"relationships": relationships,
|
|
520
|
+
},
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# ---------------------------------------------------------------------------
|
|
525
|
+
# 4. get_file
|
|
526
|
+
# ---------------------------------------------------------------------------
|
|
527
|
+
_TEXT_LANGUAGE_MAP: dict[str, str] = {
|
|
528
|
+
".py": "python",
|
|
529
|
+
".pyi": "python",
|
|
530
|
+
".js": "javascript",
|
|
531
|
+
".jsx": "javascript",
|
|
532
|
+
".mjs": "javascript",
|
|
533
|
+
".cjs": "javascript",
|
|
534
|
+
".ts": "typescript",
|
|
535
|
+
".tsx": "typescript",
|
|
536
|
+
".html": "html",
|
|
537
|
+
".htm": "html",
|
|
538
|
+
".jinja": "jinja",
|
|
539
|
+
".jinja2": "jinja",
|
|
540
|
+
".j2": "jinja",
|
|
541
|
+
".css": "css",
|
|
542
|
+
".scss": "css",
|
|
543
|
+
".json": "json",
|
|
544
|
+
".yaml": "yaml",
|
|
545
|
+
".yml": "yaml",
|
|
546
|
+
".toml": "toml",
|
|
547
|
+
".md": "markdown",
|
|
548
|
+
".sql": "sql",
|
|
549
|
+
".prisma": "prisma",
|
|
550
|
+
".sh": "shell",
|
|
551
|
+
".lua": "luau",
|
|
552
|
+
".luau": "luau",
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def get_file(
|
|
557
|
+
con: sqlite3.Connection,
|
|
558
|
+
repository: Path,
|
|
559
|
+
path: str,
|
|
560
|
+
include_content: bool = False,
|
|
561
|
+
max_read_bytes: int = 256_000,
|
|
562
|
+
start_line: int | None = None,
|
|
563
|
+
end_line: int | None = None,
|
|
564
|
+
max_lines: int = 200,
|
|
565
|
+
) -> dict[str, Any]:
|
|
566
|
+
"""Return bounded file source content and structural AST outline for a repository file."""
|
|
567
|
+
unindexed = check_index_available(con, repository)
|
|
568
|
+
if unindexed:
|
|
569
|
+
return unindexed
|
|
570
|
+
|
|
571
|
+
meta = get_index_metadata(con, repository)
|
|
572
|
+
try:
|
|
573
|
+
resolved = safe_path(repository, path)
|
|
574
|
+
relative = resolved.relative_to(repository.resolve(strict=True)).as_posix()
|
|
575
|
+
except SecurityError:
|
|
576
|
+
return {
|
|
577
|
+
"status": "error",
|
|
578
|
+
"error_code": ErrorCode.PATH_OUTSIDE_REPOSITORY.value,
|
|
579
|
+
"error": {
|
|
580
|
+
"code": ErrorCode.PATH_OUTSIDE_REPOSITORY.value,
|
|
581
|
+
"message": f"Path traversal blocked: '{path}' resides outside repository boundary.",
|
|
582
|
+
"next_action": {
|
|
583
|
+
"command": "codegraph status",
|
|
584
|
+
"reason": "Ensure all queried paths reside within the repository root.",
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
"path": path,
|
|
588
|
+
"file": path,
|
|
589
|
+
**meta,
|
|
590
|
+
}
|
|
591
|
+
except Exception:
|
|
592
|
+
return {
|
|
593
|
+
"status": "error",
|
|
594
|
+
"error_code": ErrorCode.INVALID_PATH.value,
|
|
595
|
+
"error": {
|
|
596
|
+
"code": ErrorCode.INVALID_PATH.value,
|
|
597
|
+
"message": f"Invalid path: '{path}'",
|
|
598
|
+
"next_action": {
|
|
599
|
+
"command": "codegraph status",
|
|
600
|
+
"reason": "Check repository files and paths.",
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
"path": path,
|
|
604
|
+
"file": path,
|
|
605
|
+
**meta,
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if is_sensitive(Path(relative)) or is_sensitive(Path(str(path))):
|
|
609
|
+
if include_content:
|
|
610
|
+
raise SecurityError(
|
|
611
|
+
f"Access to sensitive file '{relative}' is blocked. "
|
|
612
|
+
"Sensitive files (e.g. .env, private keys, secrets) may not be read."
|
|
613
|
+
)
|
|
614
|
+
return {
|
|
615
|
+
"status": "invalid_request",
|
|
616
|
+
"error_code": ErrorCode.SENSITIVE_FILE_ACCESS_DENIED.value,
|
|
617
|
+
"error": {
|
|
618
|
+
"code": ErrorCode.SENSITIVE_FILE_ACCESS_DENIED.value,
|
|
619
|
+
"message": f"Access to sensitive file '{relative}' is blocked.",
|
|
620
|
+
"next_action": {
|
|
621
|
+
"command": "codegraph privacy",
|
|
622
|
+
"reason": "Review protected file boundaries.",
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
"path": relative,
|
|
626
|
+
"file": relative,
|
|
627
|
+
**meta,
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if start_line is not None and start_line < 1:
|
|
631
|
+
return {
|
|
632
|
+
"status": "invalid_request",
|
|
633
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
634
|
+
"error": {
|
|
635
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
636
|
+
"message": f"start_line must be >= 1 (got {start_line}).",
|
|
637
|
+
},
|
|
638
|
+
"path": relative,
|
|
639
|
+
"file": relative,
|
|
640
|
+
**meta,
|
|
641
|
+
}
|
|
642
|
+
if end_line is not None and end_line < 1:
|
|
643
|
+
return {
|
|
644
|
+
"status": "invalid_request",
|
|
645
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
646
|
+
"error": {
|
|
647
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
648
|
+
"message": f"end_line must be >= 1 (got {end_line}).",
|
|
649
|
+
},
|
|
650
|
+
"path": relative,
|
|
651
|
+
"file": relative,
|
|
652
|
+
**meta,
|
|
653
|
+
}
|
|
654
|
+
if start_line is not None and end_line is not None and end_line < start_line:
|
|
655
|
+
return {
|
|
656
|
+
"status": "invalid_request",
|
|
657
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
658
|
+
"error": {
|
|
659
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
660
|
+
"message": f"end_line ({end_line}) must be >= start_line ({start_line}).",
|
|
661
|
+
},
|
|
662
|
+
"path": relative,
|
|
663
|
+
"file": relative,
|
|
664
|
+
**meta,
|
|
665
|
+
}
|
|
666
|
+
if max_lines < 1:
|
|
667
|
+
return {
|
|
668
|
+
"status": "invalid_request",
|
|
669
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
670
|
+
"error": {
|
|
671
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
672
|
+
"message": f"max_lines must be >= 1 (got {max_lines}).",
|
|
673
|
+
},
|
|
674
|
+
"path": relative,
|
|
675
|
+
"file": relative,
|
|
676
|
+
**meta,
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
file_row = con.execute(
|
|
680
|
+
"SELECT hash, language, category FROM files WHERE path=?",
|
|
681
|
+
(relative,),
|
|
682
|
+
).fetchone()
|
|
683
|
+
|
|
684
|
+
if not file_row and not resolved.is_file():
|
|
685
|
+
return {
|
|
686
|
+
"status": "not_found",
|
|
687
|
+
"error_code": ErrorCode.INVALID_PATH.value,
|
|
688
|
+
"error": {
|
|
689
|
+
"code": ErrorCode.INVALID_PATH.value,
|
|
690
|
+
"message": f"File does not exist: '{relative}'",
|
|
691
|
+
"next_action": {
|
|
692
|
+
"command": "codegraph status",
|
|
693
|
+
"reason": "Check repository files and paths.",
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
"path": relative,
|
|
697
|
+
"file": relative,
|
|
698
|
+
**meta,
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
# Query symbols in file
|
|
702
|
+
symbols_rows = con.execute(
|
|
703
|
+
"SELECT canonical_id, name, qualified_name, kind, start_line, end_line "
|
|
704
|
+
"FROM symbols WHERE path=? ORDER BY start_line ASC, canonical_id ASC",
|
|
705
|
+
(relative,),
|
|
706
|
+
).fetchall()
|
|
707
|
+
|
|
708
|
+
symbols = [
|
|
709
|
+
{
|
|
710
|
+
"canonical_id": s["canonical_id"],
|
|
711
|
+
"name": s["name"],
|
|
712
|
+
"qualified_name": s["qualified_name"],
|
|
713
|
+
"kind": s["kind"],
|
|
714
|
+
"start_line": s["start_line"],
|
|
715
|
+
"end_line": s["end_line"],
|
|
716
|
+
}
|
|
717
|
+
for s in symbols_rows
|
|
718
|
+
]
|
|
719
|
+
classes = [s for s in symbols if s["kind"] in ("class", "interface")]
|
|
720
|
+
functions = [s for s in symbols if s["kind"] in ("function", "method")]
|
|
721
|
+
|
|
722
|
+
# Query imports
|
|
723
|
+
import_rows = con.execute(
|
|
724
|
+
"SELECT module, name, alias, line, imported_module, imported_name, resolved_path "
|
|
725
|
+
"FROM imports WHERE source_path=? ORDER BY line ASC, module ASC",
|
|
726
|
+
(relative,),
|
|
727
|
+
).fetchall()
|
|
728
|
+
imports = [
|
|
729
|
+
{
|
|
730
|
+
"module": i["module"],
|
|
731
|
+
"name": i["name"],
|
|
732
|
+
"alias": i["alias"],
|
|
733
|
+
"line": i["line"],
|
|
734
|
+
"imported_module": i["imported_module"],
|
|
735
|
+
"imported_name": i["imported_name"],
|
|
736
|
+
"resolved_path": i["resolved_path"],
|
|
737
|
+
}
|
|
738
|
+
for i in import_rows
|
|
739
|
+
]
|
|
740
|
+
|
|
741
|
+
# Query routes in file
|
|
742
|
+
route_rows = con.execute(
|
|
743
|
+
"SELECT endpoint_id, framework, http_method, route_path, handler_name, line "
|
|
744
|
+
"FROM framework_routes WHERE file_path=? ORDER BY line ASC, route_path ASC",
|
|
745
|
+
(relative,),
|
|
746
|
+
).fetchall()
|
|
747
|
+
routes = [
|
|
748
|
+
{
|
|
749
|
+
"endpoint_id": r["endpoint_id"],
|
|
750
|
+
"framework": r["framework"],
|
|
751
|
+
"method": r["http_method"],
|
|
752
|
+
"path": r["route_path"],
|
|
753
|
+
"handler": r["handler_name"],
|
|
754
|
+
"line": r["line"],
|
|
755
|
+
}
|
|
756
|
+
for r in route_rows
|
|
757
|
+
]
|
|
758
|
+
|
|
759
|
+
from codegraph.indexing.classifier import classify_file
|
|
760
|
+
from codegraph.indexing.scanner import is_binary
|
|
761
|
+
|
|
762
|
+
file_size = resolved.stat().st_size if resolved.exists() else 0
|
|
763
|
+
file_hash = str(file_row["hash"]) if file_row else ""
|
|
764
|
+
inferred_lang = _TEXT_LANGUAGE_MAP.get(Path(relative).suffix.lower(), "text")
|
|
765
|
+
language = str(file_row["language"]) if file_row else inferred_lang
|
|
766
|
+
category = (
|
|
767
|
+
str(file_row["category"])
|
|
768
|
+
if file_row and "category" in file_row.keys() and file_row["category"]
|
|
769
|
+
else classify_file(relative).value
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
is_bin = category == "BINARY" or (resolved.exists() and resolved.is_file() and is_binary(resolved))
|
|
773
|
+
if is_bin:
|
|
774
|
+
return {
|
|
775
|
+
"status": "error",
|
|
776
|
+
"error_code": ErrorCode.BINARY_FILE_NOT_READABLE.value,
|
|
777
|
+
"error": {
|
|
778
|
+
"code": ErrorCode.BINARY_FILE_NOT_READABLE.value,
|
|
779
|
+
"message": f"Binary file '{relative}' cannot be read as text.",
|
|
780
|
+
},
|
|
781
|
+
"path": relative,
|
|
782
|
+
"file": relative,
|
|
783
|
+
"size_bytes": file_size,
|
|
784
|
+
**meta,
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
# If full-file content was explicitly requested without a targeted line range on an oversized file, block
|
|
788
|
+
if include_content and start_line is None and end_line is None and file_size > max_read_bytes:
|
|
789
|
+
return {
|
|
790
|
+
"status": "error",
|
|
791
|
+
"error_code": ErrorCode.FILE_TOO_LARGE.value,
|
|
792
|
+
"error": {
|
|
793
|
+
"code": ErrorCode.FILE_TOO_LARGE.value,
|
|
794
|
+
"message": f"File '{relative}' size ({file_size} bytes) exceeds max_read_bytes limit ({max_read_bytes} bytes).",
|
|
795
|
+
},
|
|
796
|
+
"path": relative,
|
|
797
|
+
"file": relative,
|
|
798
|
+
"size_bytes": file_size,
|
|
799
|
+
"max_read_bytes": max_read_bytes,
|
|
800
|
+
**meta,
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
effective_max_lines = max(1, min(int(max_lines), 500))
|
|
804
|
+
eff_start = start_line if start_line is not None else 1
|
|
805
|
+
if end_line is not None:
|
|
806
|
+
eff_end = min(end_line, eff_start + effective_max_lines - 1)
|
|
807
|
+
capped_by_max = end_line > eff_end
|
|
808
|
+
else:
|
|
809
|
+
eff_end = eff_start + effective_max_lines - 1
|
|
810
|
+
capped_by_max = False
|
|
811
|
+
|
|
812
|
+
selected_lines: list[str] = []
|
|
813
|
+
has_more_after = False
|
|
814
|
+
bytes_accum = 0
|
|
815
|
+
bytes_capped = False
|
|
816
|
+
actual_start = eff_start
|
|
817
|
+
actual_end = eff_start
|
|
818
|
+
|
|
819
|
+
if resolved.exists() and resolved.is_file():
|
|
820
|
+
try:
|
|
821
|
+
with resolved.open("r", encoding="utf-8", errors="replace") as fh:
|
|
822
|
+
for line_idx, raw_line in enumerate(fh, start=1):
|
|
823
|
+
if line_idx < eff_start:
|
|
824
|
+
continue
|
|
825
|
+
if line_idx > eff_end:
|
|
826
|
+
has_more_after = True
|
|
827
|
+
break
|
|
828
|
+
clean_line = raw_line.rstrip("\r\n")
|
|
829
|
+
line_bytes = len(clean_line.encode("utf-8", errors="replace"))
|
|
830
|
+
if bytes_accum + line_bytes > max_read_bytes and selected_lines:
|
|
831
|
+
bytes_capped = True
|
|
832
|
+
has_more_after = True
|
|
833
|
+
break
|
|
834
|
+
selected_lines.append(clean_line)
|
|
835
|
+
bytes_accum += line_bytes + 1
|
|
836
|
+
except OSError:
|
|
837
|
+
pass
|
|
838
|
+
|
|
839
|
+
if selected_lines:
|
|
840
|
+
actual_end = eff_start + len(selected_lines) - 1
|
|
841
|
+
else:
|
|
842
|
+
actual_end = eff_start
|
|
843
|
+
|
|
844
|
+
truncated = bool(
|
|
845
|
+
capped_by_max
|
|
846
|
+
or bytes_capped
|
|
847
|
+
or (end_line is None and (has_more_after or eff_start > 1))
|
|
848
|
+
)
|
|
849
|
+
raw_joined = "\n".join(selected_lines)
|
|
850
|
+
if contains_private_key(raw_joined):
|
|
851
|
+
raise SecurityError(
|
|
852
|
+
f"Access to sensitive file '{relative}' containing private key material is blocked."
|
|
853
|
+
)
|
|
854
|
+
content_str = redact_secrets(raw_joined)
|
|
855
|
+
|
|
856
|
+
ev = [
|
|
857
|
+
make_evidence(
|
|
858
|
+
file=relative,
|
|
859
|
+
start_line=actual_start,
|
|
860
|
+
end_line=actual_end,
|
|
861
|
+
evidence_type="file_ast" if file_row else "source",
|
|
862
|
+
canonical_id=relative,
|
|
863
|
+
)
|
|
864
|
+
]
|
|
865
|
+
|
|
866
|
+
result: dict[str, Any] = {
|
|
867
|
+
"status": "ok",
|
|
868
|
+
"path": relative,
|
|
869
|
+
"file": relative,
|
|
870
|
+
"start_line": actual_start,
|
|
871
|
+
"end_line": actual_end,
|
|
872
|
+
"content": content_str,
|
|
873
|
+
"truncated": truncated,
|
|
874
|
+
"category": category,
|
|
875
|
+
"file_category": category,
|
|
876
|
+
**meta,
|
|
877
|
+
"hash": file_hash,
|
|
878
|
+
"language": language,
|
|
879
|
+
"size_bytes": file_size,
|
|
880
|
+
"classes": classes,
|
|
881
|
+
"functions": functions,
|
|
882
|
+
"imports": imports,
|
|
883
|
+
"routes": routes,
|
|
884
|
+
"evidence": ev,
|
|
885
|
+
}
|
|
886
|
+
return result
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
# ---------------------------------------------------------------------------
|
|
890
|
+
# 5. get_references
|
|
891
|
+
# ---------------------------------------------------------------------------
|
|
892
|
+
def _resolve_symbol_canonical_ids(con: sqlite3.Connection, raw_symbol: str) -> list[str]:
|
|
893
|
+
clean = raw_symbol.strip()
|
|
894
|
+
if not clean:
|
|
895
|
+
return []
|
|
896
|
+
try:
|
|
897
|
+
rows = con.execute(
|
|
898
|
+
"SELECT canonical_id, qualified_name FROM symbols "
|
|
899
|
+
"WHERE canonical_id=? OR qualified_name=? OR name=? "
|
|
900
|
+
"ORDER BY (canonical_id=?) DESC, (qualified_name=?) DESC, path ASC, start_line ASC",
|
|
901
|
+
(clean, clean, clean, clean, clean),
|
|
902
|
+
).fetchall()
|
|
903
|
+
exact_c = [str(r["canonical_id"]) for r in rows if r["canonical_id"] == clean]
|
|
904
|
+
if exact_c:
|
|
905
|
+
return exact_c
|
|
906
|
+
exact_q = [str(r["canonical_id"]) for r in rows if r["qualified_name"] == clean]
|
|
907
|
+
if exact_q:
|
|
908
|
+
return exact_q
|
|
909
|
+
if rows and "." not in clean:
|
|
910
|
+
return [str(r["canonical_id"]) for r in rows]
|
|
911
|
+
except Exception:
|
|
912
|
+
pass
|
|
913
|
+
return []
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def get_references(
|
|
917
|
+
con: sqlite3.Connection,
|
|
918
|
+
repository: Path,
|
|
919
|
+
canonical_id: str = "",
|
|
920
|
+
symbol: str = "",
|
|
921
|
+
) -> dict[str, Any]:
|
|
922
|
+
"""Return all known references and call sites to a canonical symbol with evidence."""
|
|
923
|
+
unindexed = check_index_available(con, repository)
|
|
924
|
+
if unindexed:
|
|
925
|
+
return unindexed
|
|
926
|
+
|
|
927
|
+
meta = get_index_metadata(con, repository)
|
|
928
|
+
if symbol.strip() and canonical_id.strip() and symbol.strip() != canonical_id.strip():
|
|
929
|
+
return {
|
|
930
|
+
"status": "error",
|
|
931
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
932
|
+
"error": {
|
|
933
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
934
|
+
"message": f"Conflicting arguments: symbol='{symbol}' and canonical_id='{canonical_id}'.",
|
|
935
|
+
},
|
|
936
|
+
"canonical_id": canonical_id or symbol,
|
|
937
|
+
**meta,
|
|
938
|
+
"count": 0,
|
|
939
|
+
"references": [],
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
raw_input = symbol if symbol.strip() else canonical_id
|
|
943
|
+
clean_id = raw_input.strip()
|
|
944
|
+
if not clean_id:
|
|
945
|
+
return {
|
|
946
|
+
"status": "error",
|
|
947
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
948
|
+
"error": {
|
|
949
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
950
|
+
"message": "Symbol or canonical_id must be non-empty.",
|
|
951
|
+
"next_action": {
|
|
952
|
+
"command": "codegraph resolve <symbol>",
|
|
953
|
+
"reason": "Resolve canonical ID before querying references.",
|
|
954
|
+
},
|
|
955
|
+
},
|
|
956
|
+
"canonical_id": raw_input,
|
|
957
|
+
**meta,
|
|
958
|
+
"count": 0,
|
|
959
|
+
"references": [],
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
short_name = clean_id.split(":")[-1].split(".")[-1]
|
|
963
|
+
target_canons = _resolve_symbol_canonical_ids(con, clean_id)
|
|
964
|
+
|
|
965
|
+
# 1. Search 'references' table
|
|
966
|
+
if target_canons:
|
|
967
|
+
ph = ",".join("?" for _ in target_canons)
|
|
968
|
+
ref_rows = con.execute(
|
|
969
|
+
"SELECT source_symbol_id, relationship, confidence, path, start_line, end_line, evidence "
|
|
970
|
+
"FROM 'references' "
|
|
971
|
+
f"WHERE target_symbol_id IN ({ph}) OR target_symbol_id=? OR target_symbol_id LIKE ? "
|
|
972
|
+
"ORDER BY path ASC, start_line ASC",
|
|
973
|
+
[*target_canons, clean_id, f"%.{clean_id}"],
|
|
974
|
+
).fetchall()
|
|
975
|
+
else:
|
|
976
|
+
ref_rows = con.execute(
|
|
977
|
+
"SELECT source_symbol_id, relationship, confidence, path, start_line, end_line, evidence "
|
|
978
|
+
"FROM 'references' "
|
|
979
|
+
"WHERE target_symbol_id=? OR target_symbol_id LIKE ? "
|
|
980
|
+
"ORDER BY path ASC, start_line ASC",
|
|
981
|
+
(clean_id, f"%.{clean_id}"),
|
|
982
|
+
).fetchall()
|
|
983
|
+
|
|
984
|
+
# 2. Search 'calls' table
|
|
985
|
+
if target_canons:
|
|
986
|
+
ph = ",".join("?" for _ in target_canons)
|
|
987
|
+
call_rows = con.execute(
|
|
988
|
+
"SELECT source_path, callee, line, confidence, source_symbol_id, resolved_symbol_id "
|
|
989
|
+
"FROM calls "
|
|
990
|
+
f"WHERE resolved_symbol_id IN ({ph}) OR qualified_callee=? OR (resolved_symbol_id IS NULL AND callee=?) "
|
|
991
|
+
"ORDER BY source_path ASC, line ASC",
|
|
992
|
+
[*target_canons, clean_id, short_name],
|
|
993
|
+
).fetchall()
|
|
994
|
+
else:
|
|
995
|
+
call_rows = con.execute(
|
|
996
|
+
"SELECT source_path, callee, line, confidence, source_symbol_id, resolved_symbol_id "
|
|
997
|
+
"FROM calls "
|
|
998
|
+
"WHERE resolved_symbol_id=? OR qualified_callee=? OR callee=? "
|
|
999
|
+
"ORDER BY source_path ASC, line ASC",
|
|
1000
|
+
(clean_id, clean_id, short_name),
|
|
1001
|
+
).fetchall()
|
|
1002
|
+
|
|
1003
|
+
references: list[dict[str, Any]] = []
|
|
1004
|
+
seen: set[tuple[str, int, str]] = set()
|
|
1005
|
+
resolved_lines: set[tuple[str, int]] = set()
|
|
1006
|
+
|
|
1007
|
+
for r in ref_rows:
|
|
1008
|
+
key = (r["path"], r["start_line"], r["source_symbol_id"] or "")
|
|
1009
|
+
if key not in seen:
|
|
1010
|
+
seen.add(key)
|
|
1011
|
+
resolved_lines.add((r["path"], int(r["start_line"])))
|
|
1012
|
+
references.append(
|
|
1013
|
+
{
|
|
1014
|
+
"canonical_id": r["source_symbol_id"] or "",
|
|
1015
|
+
"file": r["path"],
|
|
1016
|
+
"start_line": r["start_line"],
|
|
1017
|
+
"end_line": r["end_line"],
|
|
1018
|
+
"reference_type": r["relationship"].lower(),
|
|
1019
|
+
"confidence": r["confidence"],
|
|
1020
|
+
"evidence": make_evidence(
|
|
1021
|
+
file=r["path"],
|
|
1022
|
+
start_line=r["start_line"],
|
|
1023
|
+
end_line=r["end_line"],
|
|
1024
|
+
evidence_type="reference",
|
|
1025
|
+
canonical_id=r["source_symbol_id"],
|
|
1026
|
+
),
|
|
1027
|
+
}
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
target_canon_set = set(target_canons)
|
|
1031
|
+
for c in call_rows:
|
|
1032
|
+
if (c["source_path"], int(c["line"])) in resolved_lines:
|
|
1033
|
+
continue
|
|
1034
|
+
if c["resolved_symbol_id"] and target_canon_set and c["resolved_symbol_id"] not in target_canon_set:
|
|
1035
|
+
continue
|
|
1036
|
+
key = (c["source_path"], c["line"], c["source_symbol_id"] or "")
|
|
1037
|
+
if key not in seen:
|
|
1038
|
+
seen.add(key)
|
|
1039
|
+
references.append(
|
|
1040
|
+
{
|
|
1041
|
+
"canonical_id": c["source_symbol_id"] or "",
|
|
1042
|
+
"file": c["source_path"],
|
|
1043
|
+
"start_line": c["line"],
|
|
1044
|
+
"end_line": c["line"],
|
|
1045
|
+
"reference_type": "call",
|
|
1046
|
+
"confidence": c["confidence"],
|
|
1047
|
+
"evidence": make_evidence(
|
|
1048
|
+
file=c["source_path"],
|
|
1049
|
+
start_line=c["line"],
|
|
1050
|
+
end_line=c["line"],
|
|
1051
|
+
evidence_type="call",
|
|
1052
|
+
canonical_id=c["source_symbol_id"],
|
|
1053
|
+
),
|
|
1054
|
+
}
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
references.sort(key=lambda r: (str(r["file"]), int(r["start_line"]), str(r["canonical_id"])))
|
|
1058
|
+
|
|
1059
|
+
return {
|
|
1060
|
+
"status": "ok",
|
|
1061
|
+
"symbol": clean_id,
|
|
1062
|
+
"canonical_id": target_canons[0] if len(target_canons) == 1 else clean_id,
|
|
1063
|
+
**meta,
|
|
1064
|
+
"count": len(references),
|
|
1065
|
+
"references": references,
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
# ---------------------------------------------------------------------------
|
|
1070
|
+
# 6. get_callers
|
|
1071
|
+
# ---------------------------------------------------------------------------
|
|
1072
|
+
def get_callers(
|
|
1073
|
+
con: sqlite3.Connection,
|
|
1074
|
+
repository: Path,
|
|
1075
|
+
canonical_id: str = "",
|
|
1076
|
+
symbol: str = "",
|
|
1077
|
+
) -> dict[str, Any]:
|
|
1078
|
+
"""Return functions and methods that call the specified symbol or canonical_id."""
|
|
1079
|
+
unindexed = check_index_available(con, repository)
|
|
1080
|
+
if unindexed:
|
|
1081
|
+
return unindexed
|
|
1082
|
+
|
|
1083
|
+
meta = get_index_metadata(con, repository)
|
|
1084
|
+
if symbol.strip() and canonical_id.strip() and symbol.strip() != canonical_id.strip():
|
|
1085
|
+
return {
|
|
1086
|
+
"status": "error",
|
|
1087
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1088
|
+
"error": {
|
|
1089
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1090
|
+
"message": f"Conflicting arguments: symbol='{symbol}' and canonical_id='{canonical_id}'.",
|
|
1091
|
+
},
|
|
1092
|
+
"canonical_id": canonical_id or symbol,
|
|
1093
|
+
**meta,
|
|
1094
|
+
"count": 0,
|
|
1095
|
+
"callers": [],
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
raw_input = symbol if symbol.strip() else canonical_id
|
|
1099
|
+
clean_id = raw_input.strip()
|
|
1100
|
+
if not clean_id:
|
|
1101
|
+
return {
|
|
1102
|
+
"status": "error",
|
|
1103
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1104
|
+
"error": {
|
|
1105
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1106
|
+
"message": "Symbol or canonical_id must be non-empty.",
|
|
1107
|
+
"next_action": {
|
|
1108
|
+
"command": "codegraph resolve <symbol>",
|
|
1109
|
+
"reason": "Resolve canonical ID before querying callers.",
|
|
1110
|
+
},
|
|
1111
|
+
},
|
|
1112
|
+
"canonical_id": raw_input,
|
|
1113
|
+
**meta,
|
|
1114
|
+
"count": 0,
|
|
1115
|
+
"callers": [],
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
short_name = clean_id.split(":")[-1].split(".")[-1]
|
|
1119
|
+
target_canons = _resolve_symbol_canonical_ids(con, clean_id)
|
|
1120
|
+
target_canon_set = set(target_canons)
|
|
1121
|
+
|
|
1122
|
+
callers: list[dict[str, Any]] = []
|
|
1123
|
+
seen: set[tuple[str, int, str]] = set()
|
|
1124
|
+
resolved_lines: set[tuple[str, int]] = set()
|
|
1125
|
+
|
|
1126
|
+
# 1. Query verified CALLS from 'references' table (shares exact resolution with find_callees / find_callers)
|
|
1127
|
+
if target_canons:
|
|
1128
|
+
ph = ",".join("?" for _ in target_canons)
|
|
1129
|
+
ref_rows = con.execute(
|
|
1130
|
+
"SELECT r.source_symbol_id, r.target_symbol_id, r.relationship, r.confidence, "
|
|
1131
|
+
"r.path, r.start_line, r.end_line, r.evidence, "
|
|
1132
|
+
"s.canonical_id AS caller_canon, s.name AS caller_name "
|
|
1133
|
+
"FROM 'references' r "
|
|
1134
|
+
"LEFT JOIN symbols s ON s.canonical_id = r.source_symbol_id "
|
|
1135
|
+
f"WHERE r.relationship='CALLS' AND (r.target_symbol_id IN ({ph}) OR r.target_symbol_id=? OR r.target_symbol_id LIKE ?) "
|
|
1136
|
+
"ORDER BY r.confidence = 'HIGH' DESC, r.path ASC, r.start_line ASC",
|
|
1137
|
+
[*target_canons, clean_id, f"%.{clean_id}"],
|
|
1138
|
+
).fetchall()
|
|
1139
|
+
elif "." in clean_id:
|
|
1140
|
+
ref_rows = con.execute(
|
|
1141
|
+
"SELECT r.source_symbol_id, r.target_symbol_id, r.relationship, r.confidence, "
|
|
1142
|
+
"r.path, r.start_line, r.end_line, r.evidence, "
|
|
1143
|
+
"s.canonical_id AS caller_canon, s.name AS caller_name "
|
|
1144
|
+
"FROM 'references' r "
|
|
1145
|
+
"LEFT JOIN symbols s ON s.canonical_id = r.source_symbol_id "
|
|
1146
|
+
"WHERE r.relationship='CALLS' AND (r.target_symbol_id=? OR r.target_symbol_id LIKE ?) "
|
|
1147
|
+
"ORDER BY r.confidence = 'HIGH' DESC, r.path ASC, r.start_line ASC",
|
|
1148
|
+
(clean_id, f"%.{clean_id}"),
|
|
1149
|
+
).fetchall()
|
|
1150
|
+
else:
|
|
1151
|
+
ref_rows = con.execute(
|
|
1152
|
+
"SELECT r.source_symbol_id, r.target_symbol_id, r.relationship, r.confidence, "
|
|
1153
|
+
"r.path, r.start_line, r.end_line, r.evidence, "
|
|
1154
|
+
"s.canonical_id AS caller_canon, s.name AS caller_name "
|
|
1155
|
+
"FROM 'references' r "
|
|
1156
|
+
"LEFT JOIN symbols s ON s.canonical_id = r.source_symbol_id "
|
|
1157
|
+
"WHERE r.relationship='CALLS' AND (r.target_symbol_id=? OR r.target_symbol_id LIKE ? OR r.target_symbol_id LIKE ?) "
|
|
1158
|
+
"ORDER BY r.confidence = 'HIGH' DESC, r.path ASC, r.start_line ASC",
|
|
1159
|
+
(clean_id, f"%.{clean_id}", f"%.{short_name}"),
|
|
1160
|
+
).fetchall()
|
|
1161
|
+
|
|
1162
|
+
for r in ref_rows:
|
|
1163
|
+
caller_id = r["caller_canon"] or r["source_symbol_id"] or r["caller_name"] or r["path"]
|
|
1164
|
+
key = (r["path"], int(r["start_line"]), str(caller_id))
|
|
1165
|
+
if key not in seen:
|
|
1166
|
+
seen.add(key)
|
|
1167
|
+
resolved_lines.add((r["path"], int(r["start_line"])))
|
|
1168
|
+
ev_text = str(r["evidence"] or "")
|
|
1169
|
+
ev_cls = "DATAFLOW_VERIFIED" if ("resolved via" in ev_text.lower() or "factory" in ev_text.lower()) else "AST_VERIFIED"
|
|
1170
|
+
callers.append(
|
|
1171
|
+
{
|
|
1172
|
+
"caller": caller_id,
|
|
1173
|
+
"symbol": caller_id,
|
|
1174
|
+
"canonical_id": r["caller_canon"] or r["source_symbol_id"] or "",
|
|
1175
|
+
"target_symbol_id": r["target_symbol_id"],
|
|
1176
|
+
"relationship": "CALLS",
|
|
1177
|
+
"evidence_class": ev_cls,
|
|
1178
|
+
"file": r["path"],
|
|
1179
|
+
"line": r["start_line"],
|
|
1180
|
+
"confidence": r["confidence"],
|
|
1181
|
+
"call_site": {
|
|
1182
|
+
"file": r["path"],
|
|
1183
|
+
"line": r["start_line"],
|
|
1184
|
+
"callee": short_name,
|
|
1185
|
+
},
|
|
1186
|
+
"evidence": make_evidence(
|
|
1187
|
+
file=r["path"],
|
|
1188
|
+
start_line=r["start_line"],
|
|
1189
|
+
end_line=r["end_line"] or r["start_line"],
|
|
1190
|
+
evidence_type="call",
|
|
1191
|
+
canonical_id=caller_id,
|
|
1192
|
+
),
|
|
1193
|
+
}
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
# 2. Query calls joined with symbols for any remaining unresolved/static calls
|
|
1197
|
+
if target_canons:
|
|
1198
|
+
ph = ",".join("?" for _ in target_canons)
|
|
1199
|
+
rows = con.execute(
|
|
1200
|
+
"SELECT c.source_path, c.callee, c.qualified_callee, c.line, c.confidence, "
|
|
1201
|
+
"c.source_symbol_id, c.resolved_symbol_id, s.canonical_id AS caller_canon, s.name AS caller_name "
|
|
1202
|
+
"FROM calls c "
|
|
1203
|
+
"LEFT JOIN symbols s ON s.canonical_id = c.source_symbol_id "
|
|
1204
|
+
f"WHERE c.resolved_symbol_id IN ({ph}) OR c.qualified_callee=? OR (c.resolved_symbol_id IS NULL AND c.callee=?) "
|
|
1205
|
+
"ORDER BY c.source_path ASC, c.line ASC",
|
|
1206
|
+
[*target_canons, clean_id, short_name],
|
|
1207
|
+
).fetchall()
|
|
1208
|
+
else:
|
|
1209
|
+
rows = con.execute(
|
|
1210
|
+
"SELECT c.source_path, c.callee, c.qualified_callee, c.line, c.confidence, "
|
|
1211
|
+
"c.source_symbol_id, c.resolved_symbol_id, s.canonical_id AS caller_canon, s.name AS caller_name "
|
|
1212
|
+
"FROM calls c "
|
|
1213
|
+
"LEFT JOIN symbols s ON s.canonical_id = c.source_symbol_id "
|
|
1214
|
+
"WHERE c.resolved_symbol_id=? OR c.qualified_callee=? OR c.callee=? "
|
|
1215
|
+
"ORDER BY c.source_path ASC, c.line ASC",
|
|
1216
|
+
(clean_id, clean_id, short_name),
|
|
1217
|
+
).fetchall()
|
|
1218
|
+
|
|
1219
|
+
for r in rows:
|
|
1220
|
+
if (r["source_path"], int(r["line"])) in resolved_lines:
|
|
1221
|
+
continue
|
|
1222
|
+
if r["resolved_symbol_id"] and target_canon_set and r["resolved_symbol_id"] not in target_canon_set:
|
|
1223
|
+
continue
|
|
1224
|
+
caller_id = r["caller_canon"] or r["source_symbol_id"] or r["caller_name"] or r["source_path"]
|
|
1225
|
+
key = (r["source_path"], int(r["line"]), str(caller_id))
|
|
1226
|
+
if key not in seen:
|
|
1227
|
+
seen.add(key)
|
|
1228
|
+
conf = str(r["confidence"] or "UNKNOWN").upper()
|
|
1229
|
+
rel = "CALLS" if conf in ("HIGH", "MEDIUM") else "POSSIBLE_CALLS"
|
|
1230
|
+
ev_cls = "AST_VERIFIED" if rel == "CALLS" else ("UNKNOWN" if conf == "UNKNOWN" else "POSSIBLE")
|
|
1231
|
+
callers.append(
|
|
1232
|
+
{
|
|
1233
|
+
"caller": caller_id,
|
|
1234
|
+
"symbol": caller_id,
|
|
1235
|
+
"canonical_id": r["caller_canon"] or r["source_symbol_id"] or "",
|
|
1236
|
+
"target_symbol_id": r["resolved_symbol_id"] or (target_canons[0] if len(target_canons) == 1 else clean_id),
|
|
1237
|
+
"relationship": rel,
|
|
1238
|
+
"evidence_class": ev_cls,
|
|
1239
|
+
"file": r["source_path"],
|
|
1240
|
+
"line": r["line"],
|
|
1241
|
+
"confidence": conf,
|
|
1242
|
+
"call_site": {
|
|
1243
|
+
"file": r["source_path"],
|
|
1244
|
+
"line": r["line"],
|
|
1245
|
+
"callee": r["callee"],
|
|
1246
|
+
},
|
|
1247
|
+
"evidence": make_evidence(
|
|
1248
|
+
file=r["source_path"],
|
|
1249
|
+
start_line=r["line"],
|
|
1250
|
+
end_line=r["line"],
|
|
1251
|
+
evidence_type="call",
|
|
1252
|
+
canonical_id=caller_id,
|
|
1253
|
+
),
|
|
1254
|
+
}
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
callers.sort(key=lambda c: (str(c["file"]), int(c["line"]), str(c["caller"])))
|
|
1258
|
+
|
|
1259
|
+
return {
|
|
1260
|
+
"status": "ok",
|
|
1261
|
+
"symbol": clean_id,
|
|
1262
|
+
"canonical_id": target_canons[0] if len(target_canons) == 1 else clean_id,
|
|
1263
|
+
**meta,
|
|
1264
|
+
"count": len(callers),
|
|
1265
|
+
"callers": callers,
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
# ---------------------------------------------------------------------------
|
|
1270
|
+
# 7. get_callees
|
|
1271
|
+
# ---------------------------------------------------------------------------
|
|
1272
|
+
def get_callees(
|
|
1273
|
+
con: sqlite3.Connection,
|
|
1274
|
+
repository: Path,
|
|
1275
|
+
canonical_id: str = "",
|
|
1276
|
+
symbol: str = "",
|
|
1277
|
+
) -> dict[str, Any]:
|
|
1278
|
+
"""Return functions and methods called by the specified symbol or canonical_id."""
|
|
1279
|
+
unindexed = check_index_available(con, repository)
|
|
1280
|
+
if unindexed:
|
|
1281
|
+
return unindexed
|
|
1282
|
+
|
|
1283
|
+
meta = get_index_metadata(con, repository)
|
|
1284
|
+
if symbol.strip() and canonical_id.strip() and symbol.strip() != canonical_id.strip():
|
|
1285
|
+
return {
|
|
1286
|
+
"status": "error",
|
|
1287
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1288
|
+
"error": {
|
|
1289
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1290
|
+
"message": f"Conflicting arguments: symbol='{symbol}' and canonical_id='{canonical_id}'.",
|
|
1291
|
+
},
|
|
1292
|
+
"canonical_id": canonical_id or symbol,
|
|
1293
|
+
**meta,
|
|
1294
|
+
"count": 0,
|
|
1295
|
+
"callees": [],
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
raw_input = symbol if symbol.strip() else canonical_id
|
|
1299
|
+
clean_id = raw_input.strip()
|
|
1300
|
+
if not clean_id:
|
|
1301
|
+
return {
|
|
1302
|
+
"status": "error",
|
|
1303
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1304
|
+
"error": {
|
|
1305
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1306
|
+
"message": "Symbol or canonical_id must be non-empty.",
|
|
1307
|
+
"next_action": {
|
|
1308
|
+
"command": "codegraph resolve <symbol>",
|
|
1309
|
+
"reason": "Resolve canonical ID before querying callees.",
|
|
1310
|
+
},
|
|
1311
|
+
},
|
|
1312
|
+
"canonical_id": raw_input,
|
|
1313
|
+
**meta,
|
|
1314
|
+
"count": 0,
|
|
1315
|
+
"callees": [],
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
# Find the symbol record to get line bounds (prefer exact canonical_id or qualified_name)
|
|
1319
|
+
sym_row = con.execute(
|
|
1320
|
+
"SELECT id, canonical_id, path, start_line, end_line FROM symbols "
|
|
1321
|
+
"WHERE canonical_id=? OR qualified_name=? OR name=? "
|
|
1322
|
+
"ORDER BY (canonical_id=?) DESC, (qualified_name=?) DESC, path ASC, start_line ASC LIMIT 1",
|
|
1323
|
+
(clean_id, clean_id, clean_id, clean_id, clean_id),
|
|
1324
|
+
).fetchone()
|
|
1325
|
+
|
|
1326
|
+
query_id = sym_row["canonical_id"] if sym_row else clean_id
|
|
1327
|
+
query_path = sym_row["path"] if sym_row else None
|
|
1328
|
+
|
|
1329
|
+
callees: list[dict[str, Any]] = []
|
|
1330
|
+
seen: set[tuple[str, int, str]] = set()
|
|
1331
|
+
|
|
1332
|
+
# 1. Include verified CALLS from 'references' table
|
|
1333
|
+
ref_rows = con.execute(
|
|
1334
|
+
"SELECT source_symbol_id, target_symbol_id, relationship, confidence, path, start_line, end_line, evidence "
|
|
1335
|
+
"FROM 'references' "
|
|
1336
|
+
"WHERE (source_symbol_id=? OR source_symbol_id LIKE ?) AND relationship='CALLS' AND target_symbol_id IS NOT NULL "
|
|
1337
|
+
"ORDER BY start_line ASC, target_symbol_id ASC",
|
|
1338
|
+
(query_id, f"%.{clean_id}"),
|
|
1339
|
+
).fetchall()
|
|
1340
|
+
|
|
1341
|
+
for r in ref_rows:
|
|
1342
|
+
target_cid = str(r["target_symbol_id"])
|
|
1343
|
+
callee_name = target_cid.split(":")[-1].split(".")[-1]
|
|
1344
|
+
key = (r["path"], int(r["start_line"]), callee_name)
|
|
1345
|
+
if key not in seen:
|
|
1346
|
+
seen.add(key)
|
|
1347
|
+
ev_text = str(r["evidence"] or "")
|
|
1348
|
+
ev_cls = "DATAFLOW_VERIFIED" if ("resolved via" in ev_text.lower() or "factory" in ev_text.lower()) else "AST_VERIFIED"
|
|
1349
|
+
callees.append(
|
|
1350
|
+
{
|
|
1351
|
+
"callee": callee_name,
|
|
1352
|
+
"qualified_callee": target_cid,
|
|
1353
|
+
"canonical_id": target_cid,
|
|
1354
|
+
"target_symbol_id": target_cid,
|
|
1355
|
+
"relationship": "CALLS",
|
|
1356
|
+
"evidence_class": ev_cls,
|
|
1357
|
+
"call_type": "resolved_call",
|
|
1358
|
+
"file": r["path"],
|
|
1359
|
+
"line": r["start_line"],
|
|
1360
|
+
"confidence": r["confidence"],
|
|
1361
|
+
"evidence": make_evidence(
|
|
1362
|
+
file=r["path"],
|
|
1363
|
+
start_line=r["start_line"],
|
|
1364
|
+
end_line=r["end_line"] or r["start_line"],
|
|
1365
|
+
evidence_type="call",
|
|
1366
|
+
canonical_id=target_cid,
|
|
1367
|
+
),
|
|
1368
|
+
}
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
# 2. Include calls from 'calls' table
|
|
1372
|
+
if sym_row and query_path:
|
|
1373
|
+
rows = con.execute(
|
|
1374
|
+
"SELECT c.source_path, c.callee, c.qualified_callee, c.line, c.confidence, "
|
|
1375
|
+
"c.source_symbol_id, c.resolved_symbol_id, "
|
|
1376
|
+
"s.canonical_id AS callee_canon, s.path AS callee_path "
|
|
1377
|
+
"FROM calls c "
|
|
1378
|
+
"LEFT JOIN symbols s ON (s.canonical_id = c.resolved_symbol_id OR s.qualified_name = c.qualified_callee) "
|
|
1379
|
+
"WHERE (c.source_symbol_id=? OR (c.source_path=? AND c.line >= ? AND c.line <= ?)) "
|
|
1380
|
+
"ORDER BY c.line ASC",
|
|
1381
|
+
(query_id, query_path, sym_row["start_line"], sym_row["end_line"]),
|
|
1382
|
+
).fetchall()
|
|
1383
|
+
else:
|
|
1384
|
+
rows = con.execute(
|
|
1385
|
+
"SELECT c.source_path, c.callee, c.qualified_callee, c.line, c.confidence, "
|
|
1386
|
+
"c.source_symbol_id, c.resolved_symbol_id, "
|
|
1387
|
+
"s.canonical_id AS callee_canon, s.path AS callee_path "
|
|
1388
|
+
"FROM calls c "
|
|
1389
|
+
"LEFT JOIN symbols s ON (s.canonical_id = c.resolved_symbol_id OR s.qualified_name = c.qualified_callee) "
|
|
1390
|
+
"WHERE c.source_symbol_id=? "
|
|
1391
|
+
"ORDER BY c.line ASC",
|
|
1392
|
+
(query_id,),
|
|
1393
|
+
).fetchall()
|
|
1394
|
+
|
|
1395
|
+
for r in rows:
|
|
1396
|
+
callee_name = r["callee"]
|
|
1397
|
+
target_cid = r["callee_canon"] or r["resolved_symbol_id"]
|
|
1398
|
+
if target_cid or r["callee_path"]:
|
|
1399
|
+
call_type = "resolved_call"
|
|
1400
|
+
elif "." in str(r["qualified_callee"] or ""):
|
|
1401
|
+
call_type = "external_call"
|
|
1402
|
+
else:
|
|
1403
|
+
call_type = "unresolved_call"
|
|
1404
|
+
|
|
1405
|
+
key = (r["source_path"], int(r["line"]), callee_name)
|
|
1406
|
+
if key not in seen:
|
|
1407
|
+
seen.add(key)
|
|
1408
|
+
conf = str(r["confidence"] or "UNKNOWN").upper()
|
|
1409
|
+
rel = "CALLS" if conf in ("HIGH", "MEDIUM") else "POSSIBLE_CALLS"
|
|
1410
|
+
ev_cls = "AST_VERIFIED" if rel == "CALLS" else ("UNKNOWN" if conf == "UNKNOWN" else "POSSIBLE")
|
|
1411
|
+
callees.append(
|
|
1412
|
+
{
|
|
1413
|
+
"callee": callee_name,
|
|
1414
|
+
"qualified_callee": target_cid or r["qualified_callee"] or callee_name,
|
|
1415
|
+
"canonical_id": target_cid,
|
|
1416
|
+
"target_symbol_id": target_cid,
|
|
1417
|
+
"relationship": rel,
|
|
1418
|
+
"evidence_class": ev_cls,
|
|
1419
|
+
"call_type": call_type,
|
|
1420
|
+
"file": r["source_path"],
|
|
1421
|
+
"line": r["line"],
|
|
1422
|
+
"confidence": conf,
|
|
1423
|
+
"evidence": make_evidence(
|
|
1424
|
+
file=r["source_path"],
|
|
1425
|
+
start_line=r["line"],
|
|
1426
|
+
end_line=r["line"],
|
|
1427
|
+
evidence_type="call",
|
|
1428
|
+
canonical_id=target_cid or query_id,
|
|
1429
|
+
),
|
|
1430
|
+
}
|
|
1431
|
+
)
|
|
1432
|
+
|
|
1433
|
+
callees.sort(key=lambda c: (str(c["call_type"]), str(c["canonical_id"] or ""), int(c["line"])))
|
|
1434
|
+
|
|
1435
|
+
return {
|
|
1436
|
+
"status": "ok",
|
|
1437
|
+
"symbol": clean_id,
|
|
1438
|
+
"canonical_id": query_id,
|
|
1439
|
+
**meta,
|
|
1440
|
+
"count": len(callees),
|
|
1441
|
+
"callees": callees,
|
|
1442
|
+
"resolved_calls": [c for c in callees if c["call_type"] == "resolved_call"],
|
|
1443
|
+
"unresolved_calls": [c for c in callees if c["call_type"] == "unresolved_call"],
|
|
1444
|
+
"external_calls": [c for c in callees if c["call_type"] == "external_call"],
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
# ---------------------------------------------------------------------------
|
|
1449
|
+
# 8. trace_path
|
|
1450
|
+
# ---------------------------------------------------------------------------
|
|
1451
|
+
def trace_path(
|
|
1452
|
+
con: sqlite3.Connection,
|
|
1453
|
+
repository: Path,
|
|
1454
|
+
from_symbol: str,
|
|
1455
|
+
to_symbol: str,
|
|
1456
|
+
max_depth: int = 5,
|
|
1457
|
+
) -> dict[str, Any]:
|
|
1458
|
+
"""Find a deterministic relationship path between two known symbols."""
|
|
1459
|
+
unindexed = check_index_available(con, repository)
|
|
1460
|
+
if unindexed:
|
|
1461
|
+
return unindexed
|
|
1462
|
+
|
|
1463
|
+
clean_from = from_symbol.strip()
|
|
1464
|
+
clean_to = to_symbol.strip()
|
|
1465
|
+
meta = get_index_metadata(con, repository)
|
|
1466
|
+
|
|
1467
|
+
if max_depth < 1 or max_depth > 10:
|
|
1468
|
+
return {
|
|
1469
|
+
"status": "error",
|
|
1470
|
+
"error_code": ErrorCode.INVALID_DEPTH.value,
|
|
1471
|
+
"error": {
|
|
1472
|
+
"code": ErrorCode.INVALID_DEPTH.value,
|
|
1473
|
+
"message": f"Invalid graph traversal depth {max_depth}; allowed range is 1..5.",
|
|
1474
|
+
"next_action": {
|
|
1475
|
+
"command": "codegraph trace --depth 2 <symbol>",
|
|
1476
|
+
"reason": "Specify a traversal depth between 1 and 5.",
|
|
1477
|
+
},
|
|
1478
|
+
},
|
|
1479
|
+
"from": from_symbol,
|
|
1480
|
+
"to": to_symbol,
|
|
1481
|
+
**meta,
|
|
1482
|
+
"path": [],
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
bounded_depth = max(1, min(max_depth, 5))
|
|
1486
|
+
|
|
1487
|
+
if not clean_from or not clean_to:
|
|
1488
|
+
return {
|
|
1489
|
+
"status": "error",
|
|
1490
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1491
|
+
"error": {
|
|
1492
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1493
|
+
"message": "Both source_symbol and target_symbol must be non-empty.",
|
|
1494
|
+
"next_action": {
|
|
1495
|
+
"command": "codegraph trace <source> <target>",
|
|
1496
|
+
"reason": "Provide valid source and target symbol names.",
|
|
1497
|
+
},
|
|
1498
|
+
},
|
|
1499
|
+
"from": from_symbol,
|
|
1500
|
+
"to": to_symbol,
|
|
1501
|
+
**meta,
|
|
1502
|
+
"path": [],
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
# Resolve from and to targets (prefer exact canonical_id or qualified_name, or virtual graph_edges nodes like Remotes)
|
|
1506
|
+
from_row = con.execute(
|
|
1507
|
+
"SELECT canonical_id, qualified_name, name FROM symbols "
|
|
1508
|
+
"WHERE canonical_id=? OR qualified_name=? OR name=? "
|
|
1509
|
+
"ORDER BY (canonical_id=?) DESC, (qualified_name=?) DESC, path ASC, start_line ASC LIMIT 1",
|
|
1510
|
+
(clean_from, clean_from, clean_from, clean_from, clean_from),
|
|
1511
|
+
).fetchone()
|
|
1512
|
+
|
|
1513
|
+
to_row = con.execute(
|
|
1514
|
+
"SELECT canonical_id, qualified_name, name FROM symbols "
|
|
1515
|
+
"WHERE canonical_id=? OR qualified_name=? OR name=? "
|
|
1516
|
+
"ORDER BY (canonical_id=?) DESC, (qualified_name=?) DESC, path ASC, start_line ASC LIMIT 1",
|
|
1517
|
+
(clean_to, clean_to, clean_to, clean_to, clean_to),
|
|
1518
|
+
).fetchone()
|
|
1519
|
+
|
|
1520
|
+
# Fallback: check if clean_from or clean_to is a virtual node in graph_edges (e.g. ReplicatedStorage.Remotes.Inventory or persistence.roblox.datastore.PlayerData)
|
|
1521
|
+
from_virtual: str | None = None
|
|
1522
|
+
if not from_row:
|
|
1523
|
+
ge_from = con.execute(
|
|
1524
|
+
"SELECT source, target FROM graph_edges WHERE source=? OR target=? LIMIT 1",
|
|
1525
|
+
(clean_from, clean_from),
|
|
1526
|
+
).fetchone()
|
|
1527
|
+
if ge_from:
|
|
1528
|
+
from_virtual = clean_from
|
|
1529
|
+
|
|
1530
|
+
to_virtual: str | None = None
|
|
1531
|
+
if not to_row:
|
|
1532
|
+
ge_to = con.execute(
|
|
1533
|
+
"SELECT source, target FROM graph_edges WHERE source=? OR target=? LIMIT 1",
|
|
1534
|
+
(clean_to, clean_to),
|
|
1535
|
+
).fetchone()
|
|
1536
|
+
if ge_to:
|
|
1537
|
+
to_virtual = clean_to
|
|
1538
|
+
|
|
1539
|
+
if (not from_row and not from_virtual) or (not to_row and not to_virtual):
|
|
1540
|
+
missing = clean_from if (not from_row and not from_virtual) else clean_to
|
|
1541
|
+
return {
|
|
1542
|
+
"status": "not_found",
|
|
1543
|
+
"error": {
|
|
1544
|
+
"code": ErrorCode.SYMBOL_NOT_FOUND.value,
|
|
1545
|
+
"message": f"Symbol not found: '{missing}'",
|
|
1546
|
+
"next_action": {
|
|
1547
|
+
"command": f"codegraph search {missing}",
|
|
1548
|
+
"reason": "Verify symbol exists in the repository.",
|
|
1549
|
+
},
|
|
1550
|
+
},
|
|
1551
|
+
"from": clean_from,
|
|
1552
|
+
"to": clean_to,
|
|
1553
|
+
**meta,
|
|
1554
|
+
"path": [],
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
start_canon = from_row["canonical_id"] if from_row else str(from_virtual)
|
|
1558
|
+
target_canon = to_row["canonical_id"] if to_row else str(to_virtual)
|
|
1559
|
+
target_names = (
|
|
1560
|
+
{to_row["canonical_id"], to_row["qualified_name"]}
|
|
1561
|
+
if to_row
|
|
1562
|
+
else {target_canon}
|
|
1563
|
+
)
|
|
1564
|
+
if to_row and "." not in clean_to:
|
|
1565
|
+
target_names.add(to_row["name"])
|
|
1566
|
+
# Also include child methods of target_canon if target_canon is a class/service table
|
|
1567
|
+
if to_row:
|
|
1568
|
+
child_rows = con.execute(
|
|
1569
|
+
"SELECT canonical_id, qualified_name FROM symbols WHERE parent_symbol_id=?",
|
|
1570
|
+
(target_canon,),
|
|
1571
|
+
).fetchall()
|
|
1572
|
+
for cr in child_rows:
|
|
1573
|
+
target_names.add(str(cr["canonical_id"]))
|
|
1574
|
+
target_names.add(str(cr["qualified_name"]))
|
|
1575
|
+
|
|
1576
|
+
if start_canon == target_canon:
|
|
1577
|
+
return {
|
|
1578
|
+
"status": "ok",
|
|
1579
|
+
"from": clean_from,
|
|
1580
|
+
"to": clean_to,
|
|
1581
|
+
**meta,
|
|
1582
|
+
"path_length": 0,
|
|
1583
|
+
"path": [],
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
# Deterministic BFS search over verified references, graph_edges, and resolved calls
|
|
1587
|
+
queue: deque[tuple[str, list[dict[str, Any]]]] = deque([(start_canon, [])])
|
|
1588
|
+
visited: set[str] = {start_canon}
|
|
1589
|
+
|
|
1590
|
+
while queue:
|
|
1591
|
+
curr_sym, path = queue.popleft()
|
|
1592
|
+
if len(path) >= bounded_depth:
|
|
1593
|
+
continue
|
|
1594
|
+
|
|
1595
|
+
# Expand curr_sym to include its direct child methods when curr_sym is a class/table
|
|
1596
|
+
curr_candidates = [curr_sym]
|
|
1597
|
+
child_syms = con.execute(
|
|
1598
|
+
"SELECT canonical_id FROM symbols WHERE parent_symbol_id=?",
|
|
1599
|
+
(curr_sym,),
|
|
1600
|
+
).fetchall()
|
|
1601
|
+
for cs in child_syms:
|
|
1602
|
+
curr_candidates.append(str(cs["canonical_id"]))
|
|
1603
|
+
ph_curr = ",".join("?" for _ in curr_candidates)
|
|
1604
|
+
|
|
1605
|
+
# 1. Query outgoing edges from graph_edges
|
|
1606
|
+
graph_rows = con.execute(
|
|
1607
|
+
"SELECT source, target, relationship, confidence, evidence_class, file, start_line, end_line "
|
|
1608
|
+
"FROM graph_edges "
|
|
1609
|
+
f"WHERE source IN ({ph_curr}) AND relationship IN ("
|
|
1610
|
+
"'CALLS', 'DEFINES', 'CONTAINS', 'IMPORTS', 'HANDLED_BY', 'ROUTES_TO', "
|
|
1611
|
+
"'PROVIDES', 'INJECTS', 'RESOLVES_DEPENDENCY', 'DISPATCHES_TO', 'REGISTERS', "
|
|
1612
|
+
"'PRODUCES_TO_QUEUE', 'CONSUMES_FROM_QUEUE', 'STATIC_QUEUE_LINKED', 'OTEL_DISTRIBUTED_VERIFIED', "
|
|
1613
|
+
"'DISPATCHES_TASK', 'TRIGGERS_SIGNAL', 'HANDLES_SIGNAL', 'RENDERS_TEMPLATE', "
|
|
1614
|
+
"'DISPATCHES_FORWARD', 'TOOL_HANDLER', 'PIPELINE_STEP', "
|
|
1615
|
+
"'REQUIRES_MODULE', 'CLIENT_DISPATCHES_REMOTE', 'SERVER_HANDLES_REMOTE', "
|
|
1616
|
+
"'GETS_SERVICE', 'PROVIDES_SERVICE', 'READS_PERSISTENCE', 'WRITES_PERSISTENCE', 'CONFIGURES_PERSISTENCE'"
|
|
1617
|
+
") AND confidence IN ('HIGH', 'MEDIUM') "
|
|
1618
|
+
"ORDER BY relationship ASC, target ASC",
|
|
1619
|
+
curr_candidates,
|
|
1620
|
+
).fetchall()
|
|
1621
|
+
|
|
1622
|
+
# 2. Query outgoing verified CALLS from references
|
|
1623
|
+
ref_rows = con.execute(
|
|
1624
|
+
"SELECT source_symbol_id, target_symbol_id, relationship, confidence, path, start_line, end_line, evidence "
|
|
1625
|
+
"FROM 'references' "
|
|
1626
|
+
f"WHERE source_symbol_id IN ({ph_curr}) AND relationship='CALLS' AND confidence IN ('HIGH', 'MEDIUM') AND target_symbol_id IS NOT NULL "
|
|
1627
|
+
"ORDER BY path ASC, start_line ASC",
|
|
1628
|
+
curr_candidates,
|
|
1629
|
+
).fetchall()
|
|
1630
|
+
|
|
1631
|
+
# 3. Query outgoing resolved calls from calls table
|
|
1632
|
+
edges = con.execute(
|
|
1633
|
+
"SELECT c.callee, c.qualified_callee, c.resolved_symbol_id, c.confidence, c.source_path, c.line "
|
|
1634
|
+
"FROM calls c "
|
|
1635
|
+
f"WHERE c.source_symbol_id IN ({ph_curr}) AND (c.resolved_symbol_id IS NOT NULL OR c.confidence IN ('HIGH', 'MEDIUM')) "
|
|
1636
|
+
"ORDER BY c.source_path ASC, c.line ASC",
|
|
1637
|
+
curr_candidates,
|
|
1638
|
+
).fetchall()
|
|
1639
|
+
|
|
1640
|
+
step_map: dict[tuple[str, str], tuple[str, str, str, str, str, int, int]] = {}
|
|
1641
|
+
for g in graph_rows:
|
|
1642
|
+
dest = str(g["target"])
|
|
1643
|
+
rel = str(g["relationship"])
|
|
1644
|
+
if rel == "CONTAINS" and dest in curr_candidates:
|
|
1645
|
+
continue
|
|
1646
|
+
conf = str(g["confidence"] or "HIGH").upper()
|
|
1647
|
+
ev_cls = str(g["evidence_class"] or "AST_VERIFIED").upper()
|
|
1648
|
+
step_map[(dest, rel)] = (dest, rel, conf, ev_cls, str(g["file"] or ""), int(g["start_line"] or 1), int(g["end_line"] or g["start_line"] or 1))
|
|
1649
|
+
|
|
1650
|
+
for r in ref_rows:
|
|
1651
|
+
dest = str(r["target_symbol_id"])
|
|
1652
|
+
rel = str(r["relationship"])
|
|
1653
|
+
conf = str(r["confidence"] or "HIGH").upper()
|
|
1654
|
+
ev_text = str(r["evidence"] or "")
|
|
1655
|
+
ev_cls = "DATAFLOW_VERIFIED" if ("resolved via" in ev_text.lower() or "factory" in ev_text.lower()) else "AST_VERIFIED"
|
|
1656
|
+
if (dest, rel) not in step_map:
|
|
1657
|
+
step_map[(dest, rel)] = (dest, rel, conf, ev_cls, str(r["path"] or ""), int(r["start_line"] or 1), int(r["end_line"] or r["start_line"] or 1))
|
|
1658
|
+
|
|
1659
|
+
for e in edges:
|
|
1660
|
+
dest = str(e["resolved_symbol_id"] or e["qualified_callee"] or e["callee"])
|
|
1661
|
+
conf = str(e["confidence"] or "HIGH").upper()
|
|
1662
|
+
if (dest, "CALLS") not in step_map:
|
|
1663
|
+
step_map[(dest, "CALLS")] = (dest, "CALLS", conf, "AST_VERIFIED", str(e["source_path"] or ""), int(e["line"] or 1), int(e["line"] or 1))
|
|
1664
|
+
|
|
1665
|
+
all_steps = sorted(step_map.values(), key=lambda s: (s[0], s[1], s[4], s[5]))
|
|
1666
|
+
|
|
1667
|
+
for dest_sym, rel, conf, ev_cls, s_file, s_line, e_line in all_steps:
|
|
1668
|
+
dest_row = con.execute(
|
|
1669
|
+
"SELECT canonical_id, parent_symbol_id FROM symbols "
|
|
1670
|
+
"WHERE canonical_id=? OR qualified_name=? OR name=? "
|
|
1671
|
+
"ORDER BY (canonical_id=?) DESC, (qualified_name=?) DESC LIMIT 1",
|
|
1672
|
+
(dest_sym, dest_sym, dest_sym, dest_sym, dest_sym),
|
|
1673
|
+
).fetchone()
|
|
1674
|
+
dest_canon = dest_row["canonical_id"] if dest_row else dest_sym
|
|
1675
|
+
dest_parent = dest_row["parent_symbol_id"] if dest_row else None
|
|
1676
|
+
|
|
1677
|
+
matched_target = (
|
|
1678
|
+
dest_canon == target_canon
|
|
1679
|
+
or dest_sym in target_names
|
|
1680
|
+
or dest_canon in target_names
|
|
1681
|
+
or (dest_parent is not None and dest_parent == target_canon)
|
|
1682
|
+
)
|
|
1683
|
+
effective_target = target_canon if matched_target else dest_canon
|
|
1684
|
+
|
|
1685
|
+
new_path = list(path) + [
|
|
1686
|
+
{
|
|
1687
|
+
"source": curr_sym,
|
|
1688
|
+
"target": effective_target,
|
|
1689
|
+
"relationship": rel,
|
|
1690
|
+
"confidence": conf,
|
|
1691
|
+
"evidence_class": ev_cls,
|
|
1692
|
+
"file": s_file,
|
|
1693
|
+
"line": s_line,
|
|
1694
|
+
"evidence": make_evidence(
|
|
1695
|
+
file=s_file,
|
|
1696
|
+
start_line=s_line,
|
|
1697
|
+
end_line=e_line,
|
|
1698
|
+
evidence_type="path_edge",
|
|
1699
|
+
canonical_id=effective_target,
|
|
1700
|
+
),
|
|
1701
|
+
}
|
|
1702
|
+
]
|
|
1703
|
+
|
|
1704
|
+
if matched_target:
|
|
1705
|
+
return {
|
|
1706
|
+
"status": "ok",
|
|
1707
|
+
"from": clean_from,
|
|
1708
|
+
"to": clean_to,
|
|
1709
|
+
"source_canonical_id": start_canon,
|
|
1710
|
+
"target_canonical_id": target_canon,
|
|
1711
|
+
**meta,
|
|
1712
|
+
"path_length": len(new_path),
|
|
1713
|
+
"path": new_path,
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
if dest_canon and dest_canon not in visited:
|
|
1717
|
+
visited.add(dest_canon)
|
|
1718
|
+
queue.append((dest_canon, new_path))
|
|
1719
|
+
if dest_parent and dest_parent not in visited:
|
|
1720
|
+
visited.add(dest_parent)
|
|
1721
|
+
queue.append((dest_parent, new_path))
|
|
1722
|
+
|
|
1723
|
+
return {
|
|
1724
|
+
"status": "not_found",
|
|
1725
|
+
"from": clean_from,
|
|
1726
|
+
"to": clean_to,
|
|
1727
|
+
"reachable": False,
|
|
1728
|
+
"message": f"No relationship path found between '{clean_from}' and '{clean_to}' within depth {bounded_depth}.",
|
|
1729
|
+
**meta,
|
|
1730
|
+
"path": [],
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
# ---------------------------------------------------------------------------
|
|
1735
|
+
# 9. get_imports
|
|
1736
|
+
# ---------------------------------------------------------------------------
|
|
1737
|
+
def get_imports(
|
|
1738
|
+
con: sqlite3.Connection,
|
|
1739
|
+
repository: Path,
|
|
1740
|
+
file: str | None = None,
|
|
1741
|
+
canonical_id: str | None = None,
|
|
1742
|
+
symbol: str | None = None,
|
|
1743
|
+
path: str | None = None,
|
|
1744
|
+
) -> dict[str, Any]:
|
|
1745
|
+
"""Return imports and import relationships for a file or canonical symbol."""
|
|
1746
|
+
unindexed = check_index_available(con, repository)
|
|
1747
|
+
if unindexed:
|
|
1748
|
+
return unindexed
|
|
1749
|
+
|
|
1750
|
+
meta = get_index_metadata(con, repository)
|
|
1751
|
+
if file and path and file.strip() and path.strip() and file.strip() != path.strip():
|
|
1752
|
+
return {
|
|
1753
|
+
"status": "invalid_request",
|
|
1754
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1755
|
+
"error": {
|
|
1756
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1757
|
+
"message": f"Conflicting arguments: file='{file}' and path='{path}'.",
|
|
1758
|
+
},
|
|
1759
|
+
**meta,
|
|
1760
|
+
"count": 0,
|
|
1761
|
+
"imports": [],
|
|
1762
|
+
}
|
|
1763
|
+
if symbol and canonical_id and symbol.strip() and canonical_id.strip() and symbol.strip() != canonical_id.strip():
|
|
1764
|
+
return {
|
|
1765
|
+
"status": "invalid_request",
|
|
1766
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1767
|
+
"error": {
|
|
1768
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1769
|
+
"message": f"Conflicting arguments: symbol='{symbol}' and canonical_id='{canonical_id}'.",
|
|
1770
|
+
},
|
|
1771
|
+
**meta,
|
|
1772
|
+
"count": 0,
|
|
1773
|
+
"imports": [],
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
target_path = (file or path or "").strip() or None
|
|
1777
|
+
effective_sym = (symbol or canonical_id or "").strip() or None
|
|
1778
|
+
|
|
1779
|
+
if effective_sym and not target_path:
|
|
1780
|
+
sym_row = con.execute(
|
|
1781
|
+
"SELECT path FROM symbols WHERE canonical_id=? OR qualified_name=? OR name=? LIMIT 1",
|
|
1782
|
+
(effective_sym, effective_sym, effective_sym),
|
|
1783
|
+
).fetchone()
|
|
1784
|
+
if sym_row:
|
|
1785
|
+
target_path = sym_row["path"]
|
|
1786
|
+
|
|
1787
|
+
if not target_path:
|
|
1788
|
+
return {
|
|
1789
|
+
"status": "invalid_request",
|
|
1790
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1791
|
+
"error": {
|
|
1792
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1793
|
+
"message": "At least one of 'file' or 'symbol' ('canonical_id') must be specified.",
|
|
1794
|
+
"next_action": {
|
|
1795
|
+
"command": "codegraph get-file <path>",
|
|
1796
|
+
"reason": "Specify a valid file path or symbol.",
|
|
1797
|
+
},
|
|
1798
|
+
},
|
|
1799
|
+
**meta,
|
|
1800
|
+
"count": 0,
|
|
1801
|
+
"imports": [],
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
rows = con.execute(
|
|
1805
|
+
"SELECT source_path, module, name, alias, imported_module, imported_name, resolved_path, line "
|
|
1806
|
+
"FROM imports WHERE source_path=? ORDER BY line ASC, module ASC",
|
|
1807
|
+
(target_path,),
|
|
1808
|
+
).fetchall()
|
|
1809
|
+
|
|
1810
|
+
imports = [
|
|
1811
|
+
{
|
|
1812
|
+
"source": r["source_path"],
|
|
1813
|
+
"target": r["imported_module"] or r["module"],
|
|
1814
|
+
"import_type": "symbol" if r["name"] else "module",
|
|
1815
|
+
"file": r["source_path"],
|
|
1816
|
+
"line": r["line"],
|
|
1817
|
+
"resolved_target": r["resolved_path"],
|
|
1818
|
+
"evidence": make_evidence(
|
|
1819
|
+
file=r["source_path"],
|
|
1820
|
+
start_line=r["line"],
|
|
1821
|
+
end_line=r["line"],
|
|
1822
|
+
evidence_type="import",
|
|
1823
|
+
canonical_id=r["source_path"],
|
|
1824
|
+
),
|
|
1825
|
+
}
|
|
1826
|
+
for r in rows
|
|
1827
|
+
]
|
|
1828
|
+
|
|
1829
|
+
imports.sort(key=lambda i: (str(i["file"]), int(i["line"]), str(i["target"])))
|
|
1830
|
+
|
|
1831
|
+
return {
|
|
1832
|
+
"status": "ok",
|
|
1833
|
+
"file": target_path,
|
|
1834
|
+
"canonical_id": effective_sym,
|
|
1835
|
+
**meta,
|
|
1836
|
+
"count": len(imports),
|
|
1837
|
+
"imports": imports,
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
# ---------------------------------------------------------------------------
|
|
1842
|
+
# 10. get_dependents
|
|
1843
|
+
# ---------------------------------------------------------------------------
|
|
1844
|
+
def get_dependents(
|
|
1845
|
+
con: sqlite3.Connection,
|
|
1846
|
+
repository: Path,
|
|
1847
|
+
canonical_id: str | None = None,
|
|
1848
|
+
file: str | None = None,
|
|
1849
|
+
symbol: str | None = None,
|
|
1850
|
+
path: str | None = None,
|
|
1851
|
+
) -> dict[str, Any]:
|
|
1852
|
+
"""Reverse dependency query: return files and symbols that depend on the target."""
|
|
1853
|
+
unindexed = check_index_available(con, repository)
|
|
1854
|
+
if unindexed:
|
|
1855
|
+
return unindexed
|
|
1856
|
+
|
|
1857
|
+
meta = get_index_metadata(con, repository)
|
|
1858
|
+
if file and path and file.strip() and path.strip() and file.strip() != path.strip():
|
|
1859
|
+
return {
|
|
1860
|
+
"status": "invalid_request",
|
|
1861
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1862
|
+
"error": {
|
|
1863
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1864
|
+
"message": f"Conflicting arguments: file='{file}' and path='{path}'.",
|
|
1865
|
+
},
|
|
1866
|
+
**meta,
|
|
1867
|
+
"count": 0,
|
|
1868
|
+
"dependents": [],
|
|
1869
|
+
}
|
|
1870
|
+
if symbol and canonical_id and symbol.strip() and canonical_id.strip() and symbol.strip() != canonical_id.strip():
|
|
1871
|
+
return {
|
|
1872
|
+
"status": "invalid_request",
|
|
1873
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1874
|
+
"error": {
|
|
1875
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1876
|
+
"message": f"Conflicting arguments: symbol='{symbol}' and canonical_id='{canonical_id}'.",
|
|
1877
|
+
},
|
|
1878
|
+
**meta,
|
|
1879
|
+
"count": 0,
|
|
1880
|
+
"dependents": [],
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
effective_sym = (symbol or canonical_id or "").strip() or None
|
|
1884
|
+
effective_file = (file or path or "").strip() or None
|
|
1885
|
+
target = effective_sym or effective_file
|
|
1886
|
+
if not target:
|
|
1887
|
+
return {
|
|
1888
|
+
"status": "invalid_request",
|
|
1889
|
+
"error_code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1890
|
+
"error": {
|
|
1891
|
+
"code": ErrorCode.INVALID_ARGUMENT.value,
|
|
1892
|
+
"message": "At least one of 'symbol' ('canonical_id') or 'file' must be specified.",
|
|
1893
|
+
"next_action": {
|
|
1894
|
+
"command": "codegraph resolve <symbol>",
|
|
1895
|
+
"reason": "Specify a valid symbol, canonical ID, or file path.",
|
|
1896
|
+
},
|
|
1897
|
+
},
|
|
1898
|
+
**meta,
|
|
1899
|
+
"count": 0,
|
|
1900
|
+
"dependents": [],
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
clean_target = target.strip()
|
|
1904
|
+
canonical_ids = _resolve_symbol_canonical_ids(con, clean_target) or [clean_target]
|
|
1905
|
+
if clean_target not in canonical_ids:
|
|
1906
|
+
canonical_ids.append(clean_target)
|
|
1907
|
+
short_name = clean_target.split(":")[-1].split(".")[-1]
|
|
1908
|
+
dependents: list[dict[str, Any]] = []
|
|
1909
|
+
seen: set[tuple[str, str, int]] = set()
|
|
1910
|
+
|
|
1911
|
+
# 1. Reverse imports
|
|
1912
|
+
imp_rows = con.execute(
|
|
1913
|
+
"SELECT source_path, line, module FROM imports "
|
|
1914
|
+
"WHERE module=? OR imported_module=? OR resolved_path=? OR source_path=? "
|
|
1915
|
+
"ORDER BY source_path ASC, line ASC",
|
|
1916
|
+
(clean_target, clean_target, clean_target, clean_target),
|
|
1917
|
+
).fetchall()
|
|
1918
|
+
for r in imp_rows:
|
|
1919
|
+
key = ("imports", r["source_path"], r["line"])
|
|
1920
|
+
if key not in seen:
|
|
1921
|
+
seen.add(key)
|
|
1922
|
+
dependents.append(
|
|
1923
|
+
{
|
|
1924
|
+
"dependent": r["source_path"],
|
|
1925
|
+
"dependent_type": "file",
|
|
1926
|
+
"relationship": "imports",
|
|
1927
|
+
"file": r["source_path"],
|
|
1928
|
+
"line": r["line"],
|
|
1929
|
+
"evidence": make_evidence(
|
|
1930
|
+
file=r["source_path"],
|
|
1931
|
+
start_line=r["line"],
|
|
1932
|
+
end_line=r["line"],
|
|
1933
|
+
evidence_type="import",
|
|
1934
|
+
canonical_id=r["source_path"],
|
|
1935
|
+
),
|
|
1936
|
+
}
|
|
1937
|
+
)
|
|
1938
|
+
|
|
1939
|
+
# 2. Reverse calls (verified 'references' + 'calls' table)
|
|
1940
|
+
placeholders = ",".join("?" for _ in canonical_ids)
|
|
1941
|
+
ref_call_rows = con.execute(
|
|
1942
|
+
f"SELECT source_symbol_id, path, start_line FROM 'references' "
|
|
1943
|
+
f"WHERE target_symbol_id IN ({placeholders}) AND relationship='CALLS' "
|
|
1944
|
+
f"ORDER BY path ASC, start_line ASC",
|
|
1945
|
+
canonical_ids,
|
|
1946
|
+
).fetchall()
|
|
1947
|
+
for r in ref_call_rows:
|
|
1948
|
+
dep_sym = r["source_symbol_id"] or r["path"]
|
|
1949
|
+
key = ("calls", r["path"], int(r["start_line"]))
|
|
1950
|
+
if key not in seen:
|
|
1951
|
+
seen.add(key)
|
|
1952
|
+
dependents.append(
|
|
1953
|
+
{
|
|
1954
|
+
"dependent": dep_sym,
|
|
1955
|
+
"dependent_type": "symbol",
|
|
1956
|
+
"relationship": "calls",
|
|
1957
|
+
"file": r["path"],
|
|
1958
|
+
"line": r["start_line"],
|
|
1959
|
+
"evidence": make_evidence(
|
|
1960
|
+
file=r["path"],
|
|
1961
|
+
start_line=r["start_line"],
|
|
1962
|
+
end_line=r["start_line"],
|
|
1963
|
+
evidence_type="call",
|
|
1964
|
+
canonical_id=dep_sym,
|
|
1965
|
+
),
|
|
1966
|
+
}
|
|
1967
|
+
)
|
|
1968
|
+
|
|
1969
|
+
call_rows = con.execute(
|
|
1970
|
+
f"SELECT source_symbol_id, source_path, line FROM calls "
|
|
1971
|
+
f"WHERE resolved_symbol_id IN ({placeholders}) OR qualified_callee IN ({placeholders}) OR callee=? "
|
|
1972
|
+
f"ORDER BY source_path ASC, line ASC",
|
|
1973
|
+
[*canonical_ids, *canonical_ids, short_name],
|
|
1974
|
+
).fetchall()
|
|
1975
|
+
for r in call_rows:
|
|
1976
|
+
dep_sym = r["source_symbol_id"] or r["source_path"]
|
|
1977
|
+
key = ("calls", r["source_path"], int(r["line"]))
|
|
1978
|
+
if key not in seen:
|
|
1979
|
+
seen.add(key)
|
|
1980
|
+
dependents.append(
|
|
1981
|
+
{
|
|
1982
|
+
"dependent": dep_sym,
|
|
1983
|
+
"dependent_type": "symbol",
|
|
1984
|
+
"relationship": "calls",
|
|
1985
|
+
"file": r["source_path"],
|
|
1986
|
+
"line": r["line"],
|
|
1987
|
+
"evidence": make_evidence(
|
|
1988
|
+
file=r["source_path"],
|
|
1989
|
+
start_line=r["line"],
|
|
1990
|
+
end_line=r["line"],
|
|
1991
|
+
evidence_type="call",
|
|
1992
|
+
canonical_id=dep_sym,
|
|
1993
|
+
),
|
|
1994
|
+
}
|
|
1995
|
+
)
|
|
1996
|
+
|
|
1997
|
+
# 3. Inheritance
|
|
1998
|
+
inh_rows = con.execute(
|
|
1999
|
+
"SELECT source_symbol, source_file, line, source_canonical_id FROM inheritance "
|
|
2000
|
+
"WHERE base_name=? OR base_name LIKE ? "
|
|
2001
|
+
"ORDER BY source_file ASC, line ASC",
|
|
2002
|
+
(clean_target, f"%.{short_name}"),
|
|
2003
|
+
).fetchall()
|
|
2004
|
+
for r in inh_rows:
|
|
2005
|
+
key = ("inherits", r["source_file"], r["line"])
|
|
2006
|
+
if key not in seen:
|
|
2007
|
+
seen.add(key)
|
|
2008
|
+
dependents.append(
|
|
2009
|
+
{
|
|
2010
|
+
"dependent": r["source_canonical_id"] or r["source_symbol"],
|
|
2011
|
+
"dependent_type": "symbol",
|
|
2012
|
+
"relationship": "inherits",
|
|
2013
|
+
"file": r["source_file"],
|
|
2014
|
+
"line": r["line"],
|
|
2015
|
+
"evidence": make_evidence(
|
|
2016
|
+
file=r["source_file"],
|
|
2017
|
+
start_line=r["line"],
|
|
2018
|
+
end_line=r["line"],
|
|
2019
|
+
evidence_type="inheritance",
|
|
2020
|
+
canonical_id=r["source_canonical_id"],
|
|
2021
|
+
),
|
|
2022
|
+
}
|
|
2023
|
+
)
|
|
2024
|
+
|
|
2025
|
+
dependents.sort(key=lambda d: (str(d["relationship"]), str(d["file"]), int(d["line"])))
|
|
2026
|
+
|
|
2027
|
+
return {
|
|
2028
|
+
"status": "ok",
|
|
2029
|
+
"target": clean_target,
|
|
2030
|
+
**meta,
|
|
2031
|
+
"count": len(dependents),
|
|
2032
|
+
"dependents": dependents,
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
|
|
2036
|
+
# ---------------------------------------------------------------------------
|
|
2037
|
+
# 11. list_routes
|
|
2038
|
+
# ---------------------------------------------------------------------------
|
|
2039
|
+
def list_routes(
|
|
2040
|
+
con: sqlite3.Connection,
|
|
2041
|
+
repository: Path,
|
|
2042
|
+
framework: str | None = None,
|
|
2043
|
+
method: str | None = None,
|
|
2044
|
+
path: str | None = None,
|
|
2045
|
+
limit: int | None = None,
|
|
2046
|
+
offset: int = 0,
|
|
2047
|
+
) -> dict[str, Any]:
|
|
2048
|
+
"""Expose application routes discovered from the repository."""
|
|
2049
|
+
unindexed = check_index_available(con, repository)
|
|
2050
|
+
if unindexed:
|
|
2051
|
+
return unindexed
|
|
2052
|
+
|
|
2053
|
+
meta = get_index_metadata(con, repository)
|
|
2054
|
+
query = (
|
|
2055
|
+
"SELECT endpoint_id, framework, http_method, route_path, handler_name, "
|
|
2056
|
+
"handler_canonical_id, file_path, line, evidence, confidence "
|
|
2057
|
+
"FROM framework_routes WHERE 1=1 "
|
|
2058
|
+
)
|
|
2059
|
+
params: list[str] = []
|
|
2060
|
+
if framework:
|
|
2061
|
+
query += "AND framework=? "
|
|
2062
|
+
params.append(framework.lower())
|
|
2063
|
+
if method:
|
|
2064
|
+
query += "AND UPPER(http_method)=? "
|
|
2065
|
+
params.append(method.upper())
|
|
2066
|
+
if path:
|
|
2067
|
+
query += "AND route_path LIKE ? "
|
|
2068
|
+
params.append(f"%{path}%")
|
|
2069
|
+
|
|
2070
|
+
query += "ORDER BY route_path ASC, http_method ASC"
|
|
2071
|
+
try:
|
|
2072
|
+
rows = con.execute(query, params).fetchall()
|
|
2073
|
+
except sqlite3.OperationalError:
|
|
2074
|
+
rows = []
|
|
2075
|
+
|
|
2076
|
+
# Fetch HTMX requests and template rendering edges if available
|
|
2077
|
+
htmx_rows: list[sqlite3.Row] = []
|
|
2078
|
+
try:
|
|
2079
|
+
htmx_rows = con.execute(
|
|
2080
|
+
"SELECT target_name, file_path, line, base_expr, dict_entries_json "
|
|
2081
|
+
"FROM local_bindings WHERE expr_kind='HTMX_REQUEST'"
|
|
2082
|
+
).fetchall()
|
|
2083
|
+
except sqlite3.OperationalError:
|
|
2084
|
+
pass
|
|
2085
|
+
|
|
2086
|
+
template_edges: dict[str, list[dict[str, Any]]] = {}
|
|
2087
|
+
try:
|
|
2088
|
+
t_rows = con.execute(
|
|
2089
|
+
"SELECT source, target, file, start_line FROM graph_edges WHERE relationship='RENDERS_TEMPLATE'"
|
|
2090
|
+
).fetchall()
|
|
2091
|
+
for tr in t_rows:
|
|
2092
|
+
template_edges.setdefault(tr["source"], []).append(
|
|
2093
|
+
{
|
|
2094
|
+
"template": tr["target"],
|
|
2095
|
+
"file": tr["file"],
|
|
2096
|
+
"line": tr["start_line"],
|
|
2097
|
+
}
|
|
2098
|
+
)
|
|
2099
|
+
except sqlite3.OperationalError:
|
|
2100
|
+
pass
|
|
2101
|
+
|
|
2102
|
+
def _route_matches_url(route_path: str, url: str, handler_name: str = "", handler_canon: str = "") -> bool:
|
|
2103
|
+
if not route_path or not url:
|
|
2104
|
+
return False
|
|
2105
|
+
if handler_name and (url == handler_name or url.endswith(f".{handler_name}")):
|
|
2106
|
+
return True
|
|
2107
|
+
if handler_canon and url in handler_canon:
|
|
2108
|
+
return True
|
|
2109
|
+
rp = route_path.rstrip("/") or "/"
|
|
2110
|
+
u = url.rstrip("/") or "/"
|
|
2111
|
+
if rp == u:
|
|
2112
|
+
return True
|
|
2113
|
+
u_clean = u.split("?")[0]
|
|
2114
|
+
if rp == u_clean:
|
|
2115
|
+
return True
|
|
2116
|
+
import re
|
|
2117
|
+
pattern = re.sub(r"<[^>]+>", r"[^/]+", rp)
|
|
2118
|
+
pattern = re.sub(r"\{[^}]+\}", r"[^/]+", pattern)
|
|
2119
|
+
pattern = re.sub(r":[\w]+", r"[^/]+", pattern)
|
|
2120
|
+
pattern = f"^{pattern}$"
|
|
2121
|
+
try:
|
|
2122
|
+
return bool(re.match(pattern, u_clean))
|
|
2123
|
+
except Exception:
|
|
2124
|
+
return False
|
|
2125
|
+
|
|
2126
|
+
routes = []
|
|
2127
|
+
for r in rows:
|
|
2128
|
+
r_path = r["route_path"]
|
|
2129
|
+
r_method = (r["http_method"] or "").upper()
|
|
2130
|
+
h_canon = r["handler_canonical_id"] or ""
|
|
2131
|
+
h_name = r["handler_name"] or ""
|
|
2132
|
+
|
|
2133
|
+
# Match HTMX callers
|
|
2134
|
+
htmx_callers: list[dict[str, Any]] = []
|
|
2135
|
+
for hb in htmx_rows:
|
|
2136
|
+
url = hb["target_name"]
|
|
2137
|
+
hb_method = (hb["base_expr"] or "").upper()
|
|
2138
|
+
if (not hb_method or hb_method == r_method or r_method in ("ALL", "ANY")) and _route_matches_url(
|
|
2139
|
+
r_path, url, h_name, h_canon
|
|
2140
|
+
):
|
|
2141
|
+
import json
|
|
2142
|
+
meta_dict: dict[str, Any] = {}
|
|
2143
|
+
try:
|
|
2144
|
+
raw_entries = json.loads(hb["dict_entries_json"] or "[]")
|
|
2145
|
+
if isinstance(raw_entries, list):
|
|
2146
|
+
meta_dict = dict(raw_entries)
|
|
2147
|
+
elif isinstance(raw_entries, dict):
|
|
2148
|
+
meta_dict = raw_entries
|
|
2149
|
+
except Exception:
|
|
2150
|
+
pass
|
|
2151
|
+
htmx_callers.append(
|
|
2152
|
+
{
|
|
2153
|
+
"url": url,
|
|
2154
|
+
"method": hb_method,
|
|
2155
|
+
"file": hb["file_path"],
|
|
2156
|
+
"line": hb["line"],
|
|
2157
|
+
"target": meta_dict.get("target"),
|
|
2158
|
+
"trigger": meta_dict.get("trigger"),
|
|
2159
|
+
"swap": meta_dict.get("swap"),
|
|
2160
|
+
"element": meta_dict.get("element"),
|
|
2161
|
+
}
|
|
2162
|
+
)
|
|
2163
|
+
|
|
2164
|
+
rendered = template_edges.get(h_canon, []) or template_edges.get(h_name, [])
|
|
2165
|
+
|
|
2166
|
+
lineage: dict[str, Any] = {}
|
|
2167
|
+
try:
|
|
2168
|
+
from codegraph.database.interrogation import get_route_db_lineage
|
|
2169
|
+
|
|
2170
|
+
lineage = get_route_db_lineage(
|
|
2171
|
+
con,
|
|
2172
|
+
route_or_handler=h_canon or h_name or r_path,
|
|
2173
|
+
method=r_method,
|
|
2174
|
+
)
|
|
2175
|
+
except Exception:
|
|
2176
|
+
lineage = {}
|
|
2177
|
+
|
|
2178
|
+
routes.append(
|
|
2179
|
+
{
|
|
2180
|
+
"method": r["http_method"],
|
|
2181
|
+
"path": r["route_path"],
|
|
2182
|
+
"handler": r["handler_canonical_id"] or r["handler_name"],
|
|
2183
|
+
"file": r["file_path"],
|
|
2184
|
+
"start_line": r["line"],
|
|
2185
|
+
"end_line": r["line"],
|
|
2186
|
+
"framework": r["framework"],
|
|
2187
|
+
"htmx_callers": htmx_callers,
|
|
2188
|
+
"templates_rendered": rendered,
|
|
2189
|
+
"db_reads": lineage.get("db_reads", []),
|
|
2190
|
+
"db_writes": lineage.get("db_writes", []),
|
|
2191
|
+
"evidence": make_evidence(
|
|
2192
|
+
file=r["file_path"],
|
|
2193
|
+
start_line=r["line"],
|
|
2194
|
+
end_line=r["line"],
|
|
2195
|
+
evidence_type="route",
|
|
2196
|
+
canonical_id=r["handler_canonical_id"],
|
|
2197
|
+
),
|
|
2198
|
+
}
|
|
2199
|
+
)
|
|
2200
|
+
|
|
2201
|
+
total_count = len(routes)
|
|
2202
|
+
if limit is not None:
|
|
2203
|
+
safe_offset = max(0, offset)
|
|
2204
|
+
safe_limit = max(0, limit)
|
|
2205
|
+
paginated_routes = routes[safe_offset : safe_offset + safe_limit]
|
|
2206
|
+
return {
|
|
2207
|
+
"status": "ok",
|
|
2208
|
+
**meta,
|
|
2209
|
+
"count": len(paginated_routes),
|
|
2210
|
+
"total_count": total_count,
|
|
2211
|
+
"limit": safe_limit,
|
|
2212
|
+
"offset": safe_offset,
|
|
2213
|
+
"has_more": (safe_offset + len(paginated_routes)) < total_count,
|
|
2214
|
+
"routes": paginated_routes,
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
return {
|
|
2218
|
+
"status": "ok",
|
|
2219
|
+
**meta,
|
|
2220
|
+
"count": len(routes),
|
|
2221
|
+
"total_count": total_count,
|
|
2222
|
+
"routes": routes,
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
|
|
2226
|
+
# ---------------------------------------------------------------------------
|
|
2227
|
+
# 12. get_architecture
|
|
2228
|
+
# ---------------------------------------------------------------------------
|
|
2229
|
+
def get_architecture(
|
|
2230
|
+
con: sqlite3.Connection,
|
|
2231
|
+
repository: Path,
|
|
2232
|
+
) -> dict[str, Any]:
|
|
2233
|
+
"""Return structural overview of repository architecture."""
|
|
2234
|
+
unindexed = check_index_available(con, repository)
|
|
2235
|
+
if unindexed:
|
|
2236
|
+
return unindexed
|
|
2237
|
+
|
|
2238
|
+
meta = get_index_metadata(con, repository)
|
|
2239
|
+
arch = arch_get_architecture(con, repository)
|
|
2240
|
+
|
|
2241
|
+
route_summary = arch.get("route_summary")
|
|
2242
|
+
routes = route_summary.get("routes", []) if isinstance(route_summary, dict) else []
|
|
2243
|
+
dep_summary = arch.get("dependency_summary")
|
|
2244
|
+
major_deps = dep_summary.get("top_dependencies", []) if isinstance(dep_summary, dict) else []
|
|
2245
|
+
test_summary = arch.get("test_summary")
|
|
2246
|
+
test_frameworks = test_summary.get("frameworks", []) if isinstance(test_summary, dict) else []
|
|
2247
|
+
model_summary = arch.get("data_model_summary")
|
|
2248
|
+
data_models = model_summary.get("models", []) if isinstance(model_summary, dict) else []
|
|
2249
|
+
|
|
2250
|
+
return {
|
|
2251
|
+
"status": "ok",
|
|
2252
|
+
**meta,
|
|
2253
|
+
"repository": str(repository.resolve()),
|
|
2254
|
+
"languages": arch.get("languages", ["Python"]),
|
|
2255
|
+
"directories": arch.get("directories", []),
|
|
2256
|
+
"modules": arch.get("top_level_modules", []),
|
|
2257
|
+
"entrypoints": arch.get("entrypoints", []),
|
|
2258
|
+
"routes": routes,
|
|
2259
|
+
"major_dependencies": major_deps,
|
|
2260
|
+
"test_frameworks": test_frameworks,
|
|
2261
|
+
"data_models": data_models,
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
|
|
2265
|
+
# ---------------------------------------------------------------------------
|
|
2266
|
+
# 13. get_git_impact
|
|
2267
|
+
# ---------------------------------------------------------------------------
|
|
2268
|
+
def get_git_impact(
|
|
2269
|
+
con: sqlite3.Connection,
|
|
2270
|
+
repository: Path,
|
|
2271
|
+
base: str = "HEAD~1",
|
|
2272
|
+
head: str = "HEAD",
|
|
2273
|
+
) -> dict[str, Any]:
|
|
2274
|
+
"""Determine code affected by Git changes between base and head."""
|
|
2275
|
+
unindexed = check_index_available(con, repository)
|
|
2276
|
+
if unindexed:
|
|
2277
|
+
return unindexed
|
|
2278
|
+
|
|
2279
|
+
meta = get_index_metadata(con, repository)
|
|
2280
|
+
diffs = changed_files(repository, since=base, until=head)
|
|
2281
|
+
changed_paths = [d.path for d in diffs]
|
|
2282
|
+
|
|
2283
|
+
changed_symbols: list[str] = []
|
|
2284
|
+
added_symbols: list[str] = []
|
|
2285
|
+
removed_symbols: list[str] = []
|
|
2286
|
+
modified_symbols: list[str] = []
|
|
2287
|
+
|
|
2288
|
+
affected_callers: list[dict[str, Any]] = []
|
|
2289
|
+
affected_callees: list[dict[str, Any]] = []
|
|
2290
|
+
affected_dependents: list[dict[str, Any]] = []
|
|
2291
|
+
affected_routes: list[dict[str, Any]] = []
|
|
2292
|
+
|
|
2293
|
+
for d in diffs:
|
|
2294
|
+
if d.status == "A":
|
|
2295
|
+
s_rows = con.execute("SELECT canonical_id, name FROM symbols WHERE path=?", (d.path,)).fetchall()
|
|
2296
|
+
for s in s_rows:
|
|
2297
|
+
added_symbols.append(s["canonical_id"] or s["name"])
|
|
2298
|
+
elif d.status == "D":
|
|
2299
|
+
removed_symbols.append(d.path)
|
|
2300
|
+
else:
|
|
2301
|
+
s_rows = con.execute("SELECT canonical_id, name FROM symbols WHERE path=?", (d.path,)).fetchall()
|
|
2302
|
+
sym_names = {r["name"] for r in s_rows}
|
|
2303
|
+
matched = changed_symbols_since(repository, base, sym_names)
|
|
2304
|
+
for m in matched:
|
|
2305
|
+
modified_symbols.append(m)
|
|
2306
|
+
changed_symbols.append(m)
|
|
2307
|
+
|
|
2308
|
+
# Compute callers and callees of modified symbols
|
|
2309
|
+
for sym in modified_symbols:
|
|
2310
|
+
callers_res = get_callers(con, repository, sym)
|
|
2311
|
+
for c in callers_res.get("callers", []):
|
|
2312
|
+
affected_callers.append(c)
|
|
2313
|
+
|
|
2314
|
+
callees_res = get_callees(con, repository, sym)
|
|
2315
|
+
for c in callees_res.get("callees", []):
|
|
2316
|
+
affected_callees.append(c)
|
|
2317
|
+
|
|
2318
|
+
dep_res = get_dependents(con, repository, canonical_id=sym)
|
|
2319
|
+
for d in dep_res.get("dependents", []):
|
|
2320
|
+
affected_dependents.append(d)
|
|
2321
|
+
|
|
2322
|
+
# Affected routes
|
|
2323
|
+
for p in changed_paths:
|
|
2324
|
+
r_rows = con.execute("SELECT * FROM framework_routes WHERE file_path=?", (p,)).fetchall()
|
|
2325
|
+
for r in r_rows:
|
|
2326
|
+
affected_routes.append(
|
|
2327
|
+
{
|
|
2328
|
+
"method": r["http_method"],
|
|
2329
|
+
"path": r["route_path"],
|
|
2330
|
+
"handler": r["handler_canonical_id"],
|
|
2331
|
+
"file": r["file_path"],
|
|
2332
|
+
}
|
|
2333
|
+
)
|
|
2334
|
+
|
|
2335
|
+
return {
|
|
2336
|
+
"status": "ok",
|
|
2337
|
+
"base": base,
|
|
2338
|
+
"head": head,
|
|
2339
|
+
**meta,
|
|
2340
|
+
"summary": {
|
|
2341
|
+
"changed_files_count": len(diffs),
|
|
2342
|
+
"affected_symbol_count": len(set(changed_symbols + added_symbols)),
|
|
2343
|
+
"affected_route_count": len(affected_routes),
|
|
2344
|
+
"affected_dependent_count": len(affected_dependents),
|
|
2345
|
+
},
|
|
2346
|
+
"changed_files": [d.as_dict() for d in diffs],
|
|
2347
|
+
"changed_symbols": sorted(list(set(changed_symbols + added_symbols))),
|
|
2348
|
+
"added_symbols": sorted(added_symbols),
|
|
2349
|
+
"removed_symbols": sorted(removed_symbols),
|
|
2350
|
+
"modified_symbols": sorted(modified_symbols),
|
|
2351
|
+
"affected_callers": affected_callers,
|
|
2352
|
+
"affected_callees": affected_callees,
|
|
2353
|
+
"affected_dependents": affected_dependents,
|
|
2354
|
+
"affected_routes": affected_routes,
|
|
2355
|
+
}
|