codegraph-engine 2.1.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 (62) hide show
  1. codegraph/__init__.py +37 -0
  2. codegraph/agent.py +26 -0
  3. codegraph/architecture.py +328 -0
  4. codegraph/audit.py +106 -0
  5. codegraph/cache.py +95 -0
  6. codegraph/cli.py +854 -0
  7. codegraph/config.py +43 -0
  8. codegraph/constraints.py +238 -0
  9. codegraph/context.py +1228 -0
  10. codegraph/epistemic.py +90 -0
  11. codegraph/errors.py +275 -0
  12. codegraph/evidence/__init__.py +15 -0
  13. codegraph/evidence/citations.py +397 -0
  14. codegraph/frameworks.py +434 -0
  15. codegraph/freshness.py +295 -0
  16. codegraph/git.py +278 -0
  17. codegraph/graph/__init__.py +46 -0
  18. codegraph/graph/models.py +41 -0
  19. codegraph/graph/traversal.py +1291 -0
  20. codegraph/indexing/__init__.py +4 -0
  21. codegraph/indexing/classifier.py +274 -0
  22. codegraph/indexing/indexer.py +943 -0
  23. codegraph/indexing/models.py +338 -0
  24. codegraph/indexing/parser.py +1240 -0
  25. codegraph/indexing/scanner.py +200 -0
  26. codegraph/indexing/test_framework.py +116 -0
  27. codegraph/interrogation.py +1582 -0
  28. codegraph/llm/__init__.py +3 -0
  29. codegraph/llm/base.py +15 -0
  30. codegraph/llm/context.py +20 -0
  31. codegraph/mcp/__init__.py +3 -0
  32. codegraph/mcp/server.py +736 -0
  33. codegraph/memory/__init__.py +3 -0
  34. codegraph/memory/store.py +46 -0
  35. codegraph/models.py +289 -0
  36. codegraph/observability.py +151 -0
  37. codegraph/optimizer.py +372 -0
  38. codegraph/planner.py +417 -0
  39. codegraph/py.typed +1 -0
  40. codegraph/query_expansion.py +199 -0
  41. codegraph/ranking.py +363 -0
  42. codegraph/resolver.py +843 -0
  43. codegraph/resources/__init__.py +45 -0
  44. codegraph/resources/cache.py +117 -0
  45. codegraph/resources/coalescer.py +83 -0
  46. codegraph/resources/debouncer.py +98 -0
  47. codegraph/resources/governor.py +232 -0
  48. codegraph/resources/policy.py +123 -0
  49. codegraph/retrieval_policy.py +220 -0
  50. codegraph/search/__init__.py +23 -0
  51. codegraph/search/hybrid.py +301 -0
  52. codegraph/search/semantic.py +28 -0
  53. codegraph/security/__init__.py +3 -0
  54. codegraph/security/paths.py +35 -0
  55. codegraph/target_resolver.py +348 -0
  56. codegraph/task.py +637 -0
  57. codegraph_engine-2.1.1.dist-info/METADATA +334 -0
  58. codegraph_engine-2.1.1.dist-info/RECORD +62 -0
  59. codegraph_engine-2.1.1.dist-info/WHEEL +5 -0
  60. codegraph_engine-2.1.1.dist-info/entry_points.txt +2 -0
  61. codegraph_engine-2.1.1.dist-info/licenses/LICENSE +21 -0
  62. codegraph_engine-2.1.1.dist-info/top_level.txt +1 -0
codegraph/cli.py ADDED
@@ -0,0 +1,854 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from codegraph import __version__
11
+ from codegraph.architecture import get_architecture
12
+ from codegraph.config import Settings
13
+ from codegraph.context import get_context
14
+ from codegraph.errors import ErrorCode, SecurityError
15
+ from codegraph.freshness import check_freshness, index_generation
16
+ from codegraph.graph import trace_call
17
+ from codegraph.graph.traversal import get_focused_graph, get_graph_summary
18
+ from codegraph.indexing import Indexer
19
+ from codegraph.indexing.indexer import check_database_health
20
+ from codegraph.interrogation import (
21
+ get_dependents as interrogation_get_dependents,
22
+ )
23
+ from codegraph.interrogation import (
24
+ get_imports as interrogation_get_imports,
25
+ )
26
+ from codegraph.interrogation import (
27
+ get_symbol as interrogation_get_symbol,
28
+ )
29
+ from codegraph.interrogation import (
30
+ resolve_symbol as interrogation_resolve_symbol,
31
+ )
32
+ from codegraph.memory import MemoryStore
33
+ from codegraph.planner import build_retrieval_plan
34
+ from codegraph.resolver import ReferenceResolver
35
+ from codegraph.search import search as search_code
36
+ from codegraph.security import is_sensitive, safe_path
37
+ from codegraph.task import TargetExpressionType, classify_target_expression, normalize_task_spec
38
+
39
+ app = typer.Typer(no_args_is_help=True, help="Evidence-backed local codebase intelligence.")
40
+
41
+
42
+ def _version_callback(value: bool) -> None:
43
+ if value:
44
+ typer.echo(f"codegraph {__version__}")
45
+ raise typer.Exit()
46
+
47
+
48
+ @app.callback()
49
+ def main(
50
+ version: Annotated[
51
+ bool,
52
+ typer.Option("--version", "-v", help="Show version and exit", callback=_version_callback, is_eager=True),
53
+ ] = False,
54
+ ) -> None:
55
+ """Evidence-backed local codebase intelligence for MCP clients and AI agents."""
56
+ pass
57
+
58
+
59
+ def _resolve_repo(
60
+ path: Path | None = None,
61
+ repository: Path | None = None,
62
+ ) -> Path:
63
+ """Resolve repository path with priority: explicit option > positional argument > current directory."""
64
+ target: Path
65
+ if repository is not None:
66
+ target = repository
67
+ elif path is not None:
68
+ target = path
69
+ else:
70
+ target = Path.cwd()
71
+
72
+ if not target.exists():
73
+ typer.echo(
74
+ json.dumps(
75
+ {
76
+ "status": "error",
77
+ "error": {
78
+ "code": ErrorCode.INVALID_PATH.value,
79
+ "message": f"Repository path does not exist: {target}",
80
+ "next_action": {
81
+ "command": "codegraph init .",
82
+ "reason": "Specify an existing directory path.",
83
+ },
84
+ },
85
+ },
86
+ indent=2,
87
+ )
88
+ )
89
+ raise typer.Exit(code=1)
90
+
91
+ if not target.is_dir():
92
+ typer.echo(
93
+ json.dumps(
94
+ {
95
+ "status": "error",
96
+ "error": {
97
+ "code": ErrorCode.INVALID_PATH.value,
98
+ "message": f"Repository path is not a directory: {target}",
99
+ "next_action": {
100
+ "command": "codegraph init .",
101
+ "reason": "Specify a valid directory path.",
102
+ },
103
+ },
104
+ },
105
+ indent=2,
106
+ )
107
+ )
108
+ raise typer.Exit(code=1)
109
+
110
+ return target.resolve()
111
+
112
+
113
+ def _ensure_indexed(repo: Path) -> bool:
114
+ """Check if repository has an index database; if not, output structured INDEX_NOT_FOUND error."""
115
+ db = repo / ".codegraph.sqlite3"
116
+ if not db.exists():
117
+ typer.echo(
118
+ json.dumps(
119
+ {
120
+ "status": "error",
121
+ "error": {
122
+ "code": ErrorCode.INDEX_NOT_FOUND.value,
123
+ "message": "No CodeGraph index exists for this repository.",
124
+ "next_action": {
125
+ "command": "codegraph init",
126
+ "reason": "Initialize the repository before querying it.",
127
+ },
128
+ },
129
+ },
130
+ indent=2,
131
+ )
132
+ )
133
+ return False
134
+ return True
135
+
136
+
137
+ def _indexer(repository: Path, db_path: Path | None = None) -> Indexer:
138
+ return Indexer(repository, Settings(repository=repository, db_path=db_path))
139
+
140
+
141
+ @app.command()
142
+ def index(
143
+ path: Annotated[Path | None, typer.Argument(help="Repository path to index (default: current directory)")] = None,
144
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
145
+ json_output: Annotated[bool, typer.Option("--json")] = False,
146
+ ) -> None:
147
+ """Securely index supported source files, skipping unchanged content."""
148
+ repo = _resolve_repo(path, repository)
149
+ result = _indexer(repo).index()
150
+ typer.echo(json.dumps(result) if json_output else " ".join(f"{k}={v}" for k, v in result.items()))
151
+
152
+
153
+ @app.command()
154
+ def init(
155
+ path: Annotated[Path | None, typer.Argument(help="Repository path to initialize (default: current directory)")] = None,
156
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
157
+ json_output: Annotated[bool, typer.Option("--json")] = False,
158
+ ) -> None:
159
+ """Initialize repository indexing and configuration."""
160
+ target_repo = _resolve_repo(path, repository)
161
+ result = _indexer(target_repo).index()
162
+ if json_output:
163
+ typer.echo(json.dumps({"status": "initialized", "repository": str(target_repo.resolve()), "indexing": result}, indent=2))
164
+ else:
165
+ typer.echo(f"Initialized CodeGraph repository at {target_repo.resolve()} (indexed {result.get('indexed_files', 0)} files)")
166
+
167
+
168
+ @app.command()
169
+ def search(
170
+ query: str,
171
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
172
+ top_k: int = 10,
173
+ json_output: Annotated[bool, typer.Option("--json")] = False,
174
+ ) -> None:
175
+ """Search indexed code with exact evidence."""
176
+ repo = _resolve_repo(repository=repository)
177
+ if not _ensure_indexed(repo):
178
+ return
179
+ if not query.strip():
180
+ typer.echo(
181
+ json.dumps(
182
+ {
183
+ "status": "error",
184
+ "error": {
185
+ "code": ErrorCode.INVALID_ARGUMENT.value,
186
+ "message": "Search query must be non-empty.",
187
+ "next_action": {
188
+ "command": "codegraph search <query>",
189
+ "reason": "Provide a non-empty search query.",
190
+ },
191
+ },
192
+ },
193
+ indent=2,
194
+ )
195
+ )
196
+ return
197
+ with _indexer(repo).session() as con:
198
+ result = [r.as_dict() for r in search_code(con, query, top_k)]
199
+ typer.echo(
200
+ json.dumps(result, indent=2)
201
+ if json_output
202
+ else "\n\n".join(
203
+ f"{r['file']}:{r['start_line']}-{r['end_line']} {r['symbol'] or ''} score={r['score']}\n{r['snippet']}"
204
+ for r in result
205
+ )
206
+ )
207
+
208
+
209
+ @app.command()
210
+ def symbols(
211
+ path: str,
212
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
213
+ ) -> None:
214
+ """Show extracted symbols for an indexed file."""
215
+ repo = _resolve_repo(repository=repository)
216
+ if not _ensure_indexed(repo):
217
+ return
218
+ try:
219
+ safe_path(repo, path)
220
+ except SecurityError:
221
+ typer.echo(
222
+ json.dumps(
223
+ {
224
+ "status": "error",
225
+ "error": {
226
+ "code": ErrorCode.PATH_OUTSIDE_REPOSITORY.value,
227
+ "message": f"Path traversal blocked: '{path}' resides outside repository boundary.",
228
+ "next_action": {
229
+ "command": "codegraph status",
230
+ "reason": "Ensure all queried paths reside within the repository root.",
231
+ },
232
+ },
233
+ },
234
+ indent=2,
235
+ )
236
+ )
237
+ return
238
+ with _indexer(repo).session() as con:
239
+ rows = con.execute(
240
+ "SELECT canonical_id, qualified_name, kind, start_line, end_line FROM symbols WHERE path=? ORDER BY start_line",
241
+ (path,),
242
+ ).fetchall()
243
+ typer.echo(json.dumps([dict(r) for r in rows], indent=2))
244
+
245
+
246
+ @app.command()
247
+ def trace(
248
+ symbol: str,
249
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
250
+ callers: Annotated[bool, typer.Option("--callers", help="Trace caller hierarchy")] = False,
251
+ callees: Annotated[bool, typer.Option("--callees", help="Trace direct callees")] = False,
252
+ both: Annotated[bool, typer.Option("--both", help="Trace both callers and callees")] = False,
253
+ depth: Annotated[int, typer.Option("--depth", "-d", help="Max trace depth (clamped to max 5)")] = 2,
254
+ ) -> None:
255
+ """Trace a symbol definition, callers, and callees with explicit confidence labels."""
256
+ repo = _resolve_repo(repository=repository)
257
+ if not _ensure_indexed(repo):
258
+ return
259
+ if depth < 1 or depth > 10:
260
+ typer.echo(
261
+ json.dumps(
262
+ {
263
+ "status": "error",
264
+ "error": {
265
+ "code": ErrorCode.INVALID_DEPTH.value,
266
+ "message": f"Invalid graph traversal depth {depth}; allowed range is 1..5.",
267
+ "next_action": {
268
+ "command": "codegraph trace --depth 2 <symbol>",
269
+ "reason": "Specify a traversal depth between 1 and 5.",
270
+ },
271
+ },
272
+ },
273
+ indent=2,
274
+ )
275
+ )
276
+ return
277
+ bounded_depth = max(1, min(depth, 5))
278
+ effective_callers = callers
279
+ effective_callees = callees
280
+ effective_both = both
281
+ if not callers and not callees and not both:
282
+ effective_both = True
283
+
284
+ with _indexer(repo).session() as con:
285
+ res = trace_call(con, symbol, max_depth=bounded_depth, callers=effective_callers, callees=effective_callees, both=effective_both)
286
+ if not res:
287
+ sym_row = con.execute(
288
+ "SELECT 1 FROM symbols WHERE canonical_id=? OR qualified_name=? OR name=? LIMIT 1",
289
+ (symbol, symbol, symbol),
290
+ ).fetchone()
291
+ if not sym_row:
292
+ typer.echo(
293
+ json.dumps(
294
+ {
295
+ "status": "unknown",
296
+ "error": {
297
+ "code": ErrorCode.SYMBOL_NOT_FOUND.value,
298
+ "message": f"No matching symbol was found for '{symbol}'.",
299
+ "next_action": {
300
+ "command": f"codegraph search {symbol}",
301
+ "reason": "Search with a broader query term.",
302
+ },
303
+ },
304
+ },
305
+ indent=2,
306
+ )
307
+ )
308
+ return
309
+ typer.echo(json.dumps(res, indent=2))
310
+
311
+
312
+ @app.command("get-symbol")
313
+ def get_symbol_cmd(
314
+ symbol: str,
315
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
316
+ json_output: Annotated[bool, typer.Option("--json")] = True,
317
+ ) -> None:
318
+ """Get authoritative AST details for a single symbol by name or canonical ID."""
319
+ repo = _resolve_repo(repository=repository)
320
+ if not _ensure_indexed(repo):
321
+ return
322
+ with _indexer(repo).session() as con:
323
+ res = interrogation_get_symbol(con, repo, symbol)
324
+ typer.echo(json.dumps(res, indent=2))
325
+
326
+
327
+ @app.command("resolve-symbol")
328
+ def resolve_symbol_cmd(
329
+ query: str,
330
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
331
+ json_output: Annotated[bool, typer.Option("--json")] = True,
332
+ ) -> None:
333
+ """Determine whether an exact/canonical symbol exists or return ambiguous candidates."""
334
+ repo = _resolve_repo(repository=repository)
335
+ if not _ensure_indexed(repo):
336
+ return
337
+ with _indexer(repo).session() as con:
338
+ res = interrogation_resolve_symbol(con, repo, query)
339
+ typer.echo(json.dumps(res, indent=2))
340
+
341
+
342
+ @app.command()
343
+ def graph(
344
+ path: Annotated[Path | None, typer.Argument(help="Repository path")] = None,
345
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
346
+ module: Annotated[str | None, typer.Option("--module", "-m", help="Filter focused graph by module")] = None,
347
+ symbol: Annotated[str | None, typer.Option("--symbol", "-s", help="Filter focused graph by symbol")] = None,
348
+ depth: Annotated[int, typer.Option("--depth", "-d", help="Traversal depth for focused graph (max 5)")] = 2,
349
+ ) -> None:
350
+ """Summarize indexed graph nodes, edges, modules, or inspect focused subgraphs."""
351
+ repo = _resolve_repo(path, repository)
352
+ if not _ensure_indexed(repo):
353
+ return
354
+ bounded_depth = max(1, min(depth, 5))
355
+ with _indexer(repo).session() as con:
356
+ if module or symbol:
357
+ result = get_focused_graph(con, module=module, symbol=symbol, depth=bounded_depth)
358
+ else:
359
+ result = get_graph_summary(con)
360
+ typer.echo(json.dumps(result, indent=2))
361
+
362
+
363
+ @app.command()
364
+ def routes(
365
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
366
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
367
+ framework: Annotated[str | None, typer.Option("--framework", "-f", help="Filter by framework (e.g. flask, fastapi)")] = None,
368
+ method: Annotated[str | None, typer.Option("--method", "-m", help="Filter by HTTP method (e.g. GET, POST)")] = None,
369
+ route_path: Annotated[str | None, typer.Option("--path", "-p", help="Filter by route path substring")] = None,
370
+ json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON array")] = False,
371
+ ) -> None:
372
+ """List and inspect indexed framework routes."""
373
+ repo = _resolve_repo(path, repository)
374
+ if not _ensure_indexed(repo):
375
+ return
376
+ with _indexer(repo).session() as con:
377
+ query = (
378
+ "SELECT endpoint_id, framework, http_method, route_path, handler_name, "
379
+ "handler_canonical_id, file_path, line, evidence, confidence "
380
+ "FROM framework_routes WHERE 1=1 "
381
+ )
382
+ params: list[str] = []
383
+ if framework:
384
+ query += "AND framework=? "
385
+ params.append(framework.lower())
386
+ if method:
387
+ query += "AND UPPER(http_method)=? "
388
+ params.append(method.upper())
389
+ if route_path:
390
+ query += "AND route_path LIKE ? "
391
+ params.append(f"%{route_path}%")
392
+ query += "ORDER BY route_path, http_method"
393
+
394
+ try:
395
+ rows = con.execute(query, params).fetchall()
396
+ except sqlite3.OperationalError:
397
+ rows = []
398
+
399
+ route_list = [
400
+ {
401
+ "endpoint_id": r["endpoint_id"],
402
+ "http_method": r["http_method"],
403
+ "route_path": r["route_path"],
404
+ "handler": r["handler_name"],
405
+ "canonical_id": r["handler_canonical_id"],
406
+ "file": r["file_path"],
407
+ "line": r["line"],
408
+ "framework": r["framework"],
409
+ "evidence": r["evidence"],
410
+ "confidence": r["confidence"],
411
+ }
412
+ for r in rows
413
+ ]
414
+
415
+ if json_output:
416
+ typer.echo(json.dumps(route_list, indent=2))
417
+ else:
418
+ if not route_list:
419
+ typer.echo("No framework routes indexed or matching criteria.")
420
+ return
421
+ typer.echo(f"Found {len(route_list)} route(s):")
422
+ for r in route_list:
423
+ typer.echo(f" [{r['http_method']}] {r['route_path']} -> {r['handler']} ({r['file']}:{r['line']}) [{r['framework']}]")
424
+
425
+
426
+ @app.command()
427
+ def imports(
428
+ path: Annotated[str | None, typer.Argument(help="Repository-relative file path to inspect imports for (default: inspect all imports)")] = None,
429
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
430
+ json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON array")] = True,
431
+ ) -> None:
432
+ """Show what a file or module imports."""
433
+ repo = _resolve_repo(repository=repository)
434
+ if not _ensure_indexed(repo):
435
+ return
436
+ with _indexer(repo).session() as con:
437
+ res = interrogation_get_imports(con, repo, file=path)
438
+ typer.echo(json.dumps(res, indent=2))
439
+
440
+
441
+ @app.command()
442
+ def dependents(
443
+ target: Annotated[str, typer.Argument(help="File path or symbol canonical ID to find dependents/importers for")],
444
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
445
+ json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON array")] = True,
446
+ ) -> None:
447
+ """Show what files and symbols import or depend on the target file/symbol."""
448
+ repo = _resolve_repo(repository=repository)
449
+ if not _ensure_indexed(repo):
450
+ return
451
+ with _indexer(repo).session() as con:
452
+ is_path = "/" in target or target.endswith((".py", ".js", ".ts", ".jsx", ".tsx"))
453
+ if is_path:
454
+ res = interrogation_get_dependents(con, repo, file=target)
455
+ else:
456
+ res = interrogation_get_dependents(con, repo, canonical_id=target)
457
+ typer.echo(json.dumps(res, indent=2))
458
+
459
+
460
+ @app.command()
461
+ def architecture(
462
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
463
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
464
+ json_output: Annotated[bool, typer.Option("--json", help="Output full architecture model in JSON")] = True,
465
+ ) -> None:
466
+ """Summarize repository architecture including modules, frameworks, routes, and tests."""
467
+ repo = _resolve_repo(path, repository)
468
+ if not _ensure_indexed(repo):
469
+ return
470
+ with _indexer(repo).session() as con:
471
+ arch = get_architecture(con, repo)
472
+ typer.echo(json.dumps(arch, indent=2))
473
+
474
+
475
+ @app.command()
476
+ def debug(
477
+ question: str,
478
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
479
+ ) -> None:
480
+ """Return source-backed facts and clearly-labelled debugging hypotheses."""
481
+ repo = _resolve_repo(repository=repository)
482
+ if not _ensure_indexed(repo):
483
+ return
484
+ with _indexer(repo).session() as con:
485
+ spec, _ = normalize_task_spec(question, con=con)
486
+ unknown_explicit_targets = []
487
+ for t in spec.targets:
488
+ expr_type = classify_target_expression(t)
489
+ if expr_type == TargetExpressionType.EXPLICIT_SYMBOL_TARGET:
490
+ row = con.execute(
491
+ "SELECT 1 FROM symbols WHERE canonical_id=? OR qualified_name=? OR name=? "
492
+ "UNION SELECT 1 FROM framework_routes WHERE endpoint_id=? OR route_path=? "
493
+ "LIMIT 1",
494
+ (t, t, t, t, t),
495
+ ).fetchone()
496
+ if not row:
497
+ unknown_explicit_targets.append(t)
498
+
499
+ if unknown_explicit_targets:
500
+ unknown_target = unknown_explicit_targets[0]
501
+ lexical_evidence = [item.as_dict() for item in search_code(con, question, 10)]
502
+ typer.echo(
503
+ json.dumps(
504
+ {
505
+ "TARGET": unknown_target,
506
+ "STATUS": "UNKNOWN",
507
+ "MESSAGE": "No repository symbol/route/module matching the explicit target was found.",
508
+ "RELATED_LEXICAL_RESULTS": lexical_evidence,
509
+ },
510
+ indent=2,
511
+ )
512
+ )
513
+ return
514
+
515
+ evidence = [item.as_dict() for item in search_code(con, question, 10)]
516
+ typer.echo(
517
+ json.dumps(
518
+ {
519
+ "CONFIRMED FACT": evidence,
520
+ "LIKELY CAUSE": [
521
+ "A cited branch or raised exception may explain the symptom; this is not confirmed."
522
+ ],
523
+ "POSSIBLE CAUSE": [
524
+ "Other uncited runtime configuration or dependency behavior may contribute."
525
+ ],
526
+ "RECOMMENDATION": ["Reproduce with input that exercises each cited branch."],
527
+ },
528
+ indent=2,
529
+ )
530
+ )
531
+
532
+
533
+ @app.command()
534
+ def serve(
535
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
536
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
537
+ profile: Annotated[str, typer.Option("--profile", help="Tool profile: core | minimal | developer | full")] = "full",
538
+ ) -> None:
539
+ """Run the stdio MCP server (requires the optional mcp extra)."""
540
+ repo = _resolve_repo(path, repository)
541
+ from codegraph.mcp import create_server
542
+
543
+ create_server(repo, profile=profile).run()
544
+
545
+
546
+ mcp_app = typer.Typer(help="MCP server commands.")
547
+ app.add_typer(mcp_app, name="mcp")
548
+
549
+
550
+ @mcp_app.command("serve")
551
+ def mcp_serve(
552
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
553
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
554
+ profile: Annotated[str, typer.Option("--profile", help="Tool profile: core | minimal | developer | full")] = "full",
555
+ ) -> None:
556
+ """Run the stdio MCP server (requires the optional mcp extra)."""
557
+ repo = _resolve_repo(path, repository)
558
+ from codegraph.mcp import create_server
559
+
560
+ create_server(repo, profile=profile).run()
561
+
562
+
563
+ @app.command()
564
+ def doctor(
565
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
566
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
567
+ resources: Annotated[bool, typer.Option("--resources")] = False,
568
+ database: Annotated[bool, typer.Option("--database", help="Run comprehensive database integrity and schema checks")] = False,
569
+ json_output: Annotated[bool, typer.Option("--json")] = False,
570
+ ) -> None:
571
+ """Check repository and index readiness, database integrity, and freshness."""
572
+ repo = _resolve_repo(path, repository)
573
+ indexer_inst = _indexer(repo)
574
+ db = repo / ".codegraph.sqlite3"
575
+ result: dict[str, object] = {
576
+ "repository": str(repo.resolve()),
577
+ "index_exists": db.exists(),
578
+ "python": "supported",
579
+ }
580
+ if db.exists():
581
+ with indexer_inst.session() as con:
582
+ health = check_database_health(con, repo)
583
+ freshness_rep = check_freshness(repo, con)
584
+ gen = index_generation(con)
585
+ result["health"] = health
586
+ result["status"] = (
587
+ "healthy"
588
+ if health.get("status") in ("healthy", "OK") and freshness_rep.status.value == "FRESH"
589
+ else "warning"
590
+ )
591
+ result["freshness"] = freshness_rep.status.value
592
+ result["freshness_detail"] = freshness_rep.detail
593
+ result["index_generation"] = gen
594
+ result["counts"] = {
595
+ "files": con.execute("SELECT count(*) FROM files").fetchone()[0],
596
+ "symbols": con.execute("SELECT count(*) FROM symbols").fetchone()[0],
597
+ "chunks": con.execute("SELECT count(*) FROM chunks").fetchone()[0],
598
+ "framework_routes": con.execute("SELECT count(*) FROM framework_routes").fetchone()[0],
599
+ "graph_edges": con.execute("SELECT count(*) FROM graph_edges").fetchone()[0],
600
+ }
601
+ else:
602
+ result["status"] = "not_indexed"
603
+
604
+ if database:
605
+ if not db.exists():
606
+ typer.echo(json.dumps({"status": "not_indexed", "error": "Database does not exist"}, indent=2))
607
+ return
608
+ with indexer_inst.session() as con:
609
+ health = check_database_health(con, repo)
610
+ typer.echo(json.dumps(health, indent=2))
611
+ return
612
+
613
+ result["resources"] = indexer_inst.governor.get_state().as_dict()
614
+ typer.echo(json.dumps(result, indent=2))
615
+
616
+
617
+ @app.command()
618
+ def resolve(
619
+ symbol: str,
620
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
621
+ json_output: Annotated[bool, typer.Option("--json")] = False,
622
+ ) -> None:
623
+ """Resolve a symbol with authoritative diagnostic, callers, and callees."""
624
+ repo = _resolve_repo(repository=repository)
625
+ if not _ensure_indexed(repo):
626
+ return
627
+ with _indexer(repo).session() as con:
628
+ diag = ReferenceResolver.resolve_symbol_diagnostic(con, symbol)
629
+ typer.echo(json.dumps(diag, indent=2))
630
+
631
+
632
+ @app.command()
633
+ def privacy(
634
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
635
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
636
+ json_output: Annotated[bool, typer.Option("--json")] = False,
637
+ ) -> None:
638
+ """Verify repository privacy boundaries and ensure no sensitive data is indexed."""
639
+ repo = _resolve_repo(path, repository)
640
+ db = repo / ".codegraph.sqlite3"
641
+ indexed_files: list[str] = []
642
+ if db.exists():
643
+ with _indexer(repo).session() as con:
644
+ indexed_files = [r[0] for r in con.execute("SELECT path FROM files").fetchall()]
645
+ sensitive_leaks = [p for p in indexed_files if is_sensitive(Path(p))]
646
+ report: dict[str, object] = {
647
+ "repository": str(repo.resolve()),
648
+ "sensitive_files_indexed": len(sensitive_leaks),
649
+ "leaks": sensitive_leaks,
650
+ "clean": len(sensitive_leaks) == 0,
651
+ "boundary_enforced": True,
652
+ "source_upload": "DISABLED (100% local execution)",
653
+ "telemetry": "DISABLED",
654
+ "network_access": "DISABLED",
655
+ "shell_execution": "DISABLED",
656
+ "model_api_required": False,
657
+ "prompt_injection_boundary": "STRICT (code and repo docs treated as passive DATA)",
658
+ }
659
+ typer.echo(json.dumps(report, indent=2))
660
+
661
+
662
+ @app.command()
663
+ def status(
664
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
665
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
666
+ json_output: Annotated[bool, typer.Option("--json")] = False,
667
+ ) -> None:
668
+ """Show repository indexing status, freshness, and graph statistics."""
669
+ repo = _resolve_repo(path, repository)
670
+ db = repo / ".codegraph.sqlite3"
671
+ if not db.exists():
672
+ typer.echo(
673
+ json.dumps(
674
+ {
675
+ "status": "error",
676
+ "repository": str(repo.resolve()),
677
+ "error": {
678
+ "code": ErrorCode.INDEX_NOT_FOUND.value,
679
+ "message": "No CodeGraph index exists for this repository.",
680
+ "next_action": {
681
+ "command": "codegraph init",
682
+ "reason": "Initialize the repository before querying it.",
683
+ },
684
+ },
685
+ },
686
+ indent=2,
687
+ )
688
+ )
689
+ return
690
+ indexer_inst = _indexer(repo)
691
+ with indexer_inst.session() as con:
692
+ freshness_rep = check_freshness(repo, con)
693
+ file_count = con.execute("SELECT count(*) FROM files").fetchone()[0]
694
+ symbol_count = con.execute("SELECT count(*) FROM symbols").fetchone()[0]
695
+ chunk_count = con.execute("SELECT count(*) FROM chunks").fetchone()[0]
696
+ route_count = con.execute("SELECT count(*) FROM framework_routes").fetchone()[0]
697
+ edge_count = con.execute("SELECT count(*) FROM graph_edges").fetchone()[0]
698
+ gov_state = indexer_inst.governor.get_state().as_dict()
699
+ rep = {
700
+ "repository": str(repo.resolve()),
701
+ "freshness": freshness_rep.status.value,
702
+ "freshness_detail": freshness_rep.detail,
703
+ "files": file_count,
704
+ "chunks": chunk_count,
705
+ "symbols": symbol_count,
706
+ "framework_routes": route_count,
707
+ "graph_edges": edge_count,
708
+ "resource_profile": gov_state["policy_profile"],
709
+ "pressure_level": gov_state["pressure_level"],
710
+ "activity_mode": gov_state["activity_mode"],
711
+ "estimated_memory_mb": gov_state["estimated_memory_mb"],
712
+ "modified_files": freshness_rep.modified_files,
713
+ "deleted_files": freshness_rep.deleted_files,
714
+ "parse_failed_files": freshness_rep.parse_failed_files,
715
+ }
716
+ typer.echo(json.dumps(rep, indent=2))
717
+
718
+
719
+ @app.command()
720
+ def context(
721
+ task: str,
722
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
723
+ intent: Annotated[str | None, typer.Option("--intent", "-i")] = None,
724
+ max_tokens: Annotated[int, typer.Option("--max-tokens")] = 20000,
725
+ top_k: Annotated[int, typer.Option("--top-k")] = 15,
726
+ mode: Annotated[str, typer.Option("--mode", "-m", help="FAST | BALANCED | DEEP")] = "BALANCED",
727
+ explain: Annotated[bool, typer.Option("--explain", "-e", help="Explain budget rejections and retrieval details")] = False,
728
+ json_output: Annotated[bool, typer.Option("--json")] = False,
729
+ ) -> None:
730
+ """Compile evidence-backed context packet for an AI agent task."""
731
+ repo = _resolve_repo(repository=repository)
732
+ if not _ensure_indexed(repo):
733
+ return
734
+ with _indexer(repo).session() as con:
735
+ packet = get_context(
736
+ con,
737
+ repo,
738
+ task=task,
739
+ intent=intent,
740
+ max_tokens=max_tokens,
741
+ top_k=top_k,
742
+ mode=mode,
743
+ explain=explain,
744
+ )
745
+ typer.echo(json.dumps(packet.as_dict(), indent=2))
746
+
747
+
748
+ @app.command("explain-context")
749
+ def explain_context(
750
+ task: str,
751
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
752
+ intent: Annotated[str | None, typer.Option("--intent", "-i")] = None,
753
+ max_tokens: Annotated[int, typer.Option("--max-tokens")] = 20000,
754
+ mode: Annotated[str, typer.Option("--mode", "-m")] = "BALANCED",
755
+ ) -> None:
756
+ """Compile context with explicit explainability and budget rejection details."""
757
+ repo = _resolve_repo(repository=repository)
758
+ if not _ensure_indexed(repo):
759
+ return
760
+ with _indexer(repo).session() as con:
761
+ packet = get_context(
762
+ con,
763
+ repo,
764
+ task=task,
765
+ intent=intent,
766
+ max_tokens=max_tokens,
767
+ mode=mode,
768
+ explain=True,
769
+ )
770
+ typer.echo(json.dumps(packet.as_dict(), indent=2))
771
+
772
+
773
+ @app.command()
774
+ def task(
775
+ prompt: str,
776
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
777
+ json_output: Annotated[bool, typer.Option("--json")] = False,
778
+ ) -> None:
779
+ """Normalize a prompt into a structured TaskSpec and detect ambiguities."""
780
+ repo = _resolve_repo(repository=repository)
781
+ if not _ensure_indexed(repo):
782
+ return
783
+ with _indexer(repo).session() as con:
784
+ spec, ambiguities = normalize_task_spec(prompt, con)
785
+ res = {
786
+ "task_spec": spec.as_dict(),
787
+ "ambiguities": [a.as_dict() for a in ambiguities],
788
+ }
789
+ typer.echo(json.dumps(res, indent=2))
790
+
791
+
792
+ @app.command()
793
+ def plan(
794
+ prompt: str,
795
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
796
+ max_tokens: Annotated[int, typer.Option("--max-tokens")] = 20000,
797
+ json_output: Annotated[bool, typer.Option("--json")] = False,
798
+ ) -> None:
799
+ """Construct a deterministic RetrievalPlan for a task."""
800
+ repo = _resolve_repo(repository=repository)
801
+ if not _ensure_indexed(repo):
802
+ return
803
+ with _indexer(repo).session() as con:
804
+ spec, ambiguities = normalize_task_spec(prompt, con)
805
+ retrieval_plan = build_retrieval_plan(spec, ambiguities, con, token_budget=max_tokens)
806
+ typer.echo(json.dumps(retrieval_plan.as_dict(), indent=2))
807
+
808
+
809
+ @app.command()
810
+ def benchmark(
811
+ path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
812
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
813
+ suite: Annotated[Path | None, typer.Option("--suite", "-s")] = None,
814
+ max_tokens: Annotated[int, typer.Option("--max-tokens")] = 20000,
815
+ json_output: Annotated[bool, typer.Option("--json")] = False,
816
+ ) -> None:
817
+ """Run deterministic codebase intelligence benchmark suite."""
818
+ repo = _resolve_repo(path, repository)
819
+ from benchmarks.runner import run_benchmark_suite
820
+
821
+ result = run_benchmark_suite(repo, suite_file=suite, max_tokens=max_tokens)
822
+ typer.echo(json.dumps(result, indent=2))
823
+
824
+
825
+ memory_app = typer.Typer(help="Inspect or clear repository-scoped durable notes.")
826
+ app.add_typer(memory_app, name="memory")
827
+
828
+
829
+ @memory_app.command("list")
830
+ def memory_list(
831
+ path: Annotated[Path | None, typer.Argument(help="Repository path")] = None,
832
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
833
+ ) -> None:
834
+ repo = _resolve_repo(path, repository)
835
+ typer.echo(json.dumps(MemoryStore(repo / ".codegraph.sqlite3").list(), indent=2))
836
+
837
+
838
+ @memory_app.command("clear")
839
+ def memory_clear(
840
+ path: Annotated[Path | None, typer.Argument(help="Repository path")] = None,
841
+ repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
842
+ ) -> None:
843
+ repo = _resolve_repo(path, repository)
844
+ MemoryStore(repo / ".codegraph.sqlite3").clear()
845
+ typer.echo("Repository memory cleared.")
846
+
847
+
848
+ @app.command()
849
+ def version() -> None:
850
+ typer.echo(__version__)
851
+
852
+
853
+ if __name__ == "__main__":
854
+ app()