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