codegraphcontext 0.4.15__py3-none-any.whl → 0.4.17__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 (51) hide show
  1. codegraphcontext/api/router.py +78 -23
  2. codegraphcontext/cli/cli_helpers.py +111 -71
  3. codegraphcontext/cli/config_manager.py +68 -23
  4. codegraphcontext/cli/main.py +138 -65
  5. codegraphcontext/cli/registry_commands.py +34 -11
  6. codegraphcontext/cli/visualizer.py +154 -40
  7. codegraphcontext/core/__init__.py +33 -19
  8. codegraphcontext/core/bundle_registry.py +18 -4
  9. codegraphcontext/core/cgc_bundle.py +297 -201
  10. codegraphcontext/core/cgcignore.py +127 -4
  11. codegraphcontext/core/database_falkordb.py +45 -21
  12. codegraphcontext/core/database_kuzu.py +38 -23
  13. codegraphcontext/core/database_ladybug.py +25 -14
  14. codegraphcontext/core/falkor_worker.py +39 -3
  15. codegraphcontext/core/jobs.py +1 -0
  16. codegraphcontext/core/watcher.py +45 -4
  17. codegraphcontext/server.py +57 -21
  18. codegraphcontext/tools/code_finder.py +16 -13
  19. codegraphcontext/tools/graph_builder.py +11 -8
  20. codegraphcontext/tools/handlers/analysis_handlers.py +4 -2
  21. codegraphcontext/tools/handlers/indexing_handlers.py +16 -38
  22. codegraphcontext/tools/handlers/management_handlers.py +68 -16
  23. codegraphcontext/tools/handlers/query_handlers.py +24 -37
  24. codegraphcontext/tools/handlers/watcher_handlers.py +38 -27
  25. codegraphcontext/tools/indexing/constants.py +4 -0
  26. codegraphcontext/tools/indexing/persistence/writer.py +150 -136
  27. codegraphcontext/tools/indexing/pipeline.py +20 -0
  28. codegraphcontext/tools/indexing/resolution/calls.py +14 -1
  29. codegraphcontext/tools/languages/c.py +13 -3
  30. codegraphcontext/tools/languages/dart.py +13 -3
  31. codegraphcontext/tools/languages/elisp.py +13 -3
  32. codegraphcontext/tools/languages/elixir.py +13 -3
  33. codegraphcontext/tools/languages/go.py +13 -3
  34. codegraphcontext/tools/languages/javascript.py +13 -3
  35. codegraphcontext/tools/languages/lua.py +13 -3
  36. codegraphcontext/tools/languages/perl.py +13 -3
  37. codegraphcontext/tools/languages/python.py +77 -35
  38. codegraphcontext/tools/languages/ruby.py +13 -3
  39. codegraphcontext/tools/languages/swift.py +13 -3
  40. codegraphcontext/tools/languages/typescript.py +13 -3
  41. codegraphcontext/tools/report_generator.py +80 -15
  42. codegraphcontext/tools/scip_indexer.py +6 -1
  43. codegraphcontext/utils/cypher_readonly.py +65 -0
  44. codegraphcontext/utils/path_sandbox.py +90 -0
  45. codegraphcontext/viz/server.py +57 -37
  46. {codegraphcontext-0.4.15.dist-info → codegraphcontext-0.4.17.dist-info}/METADATA +83 -3
  47. {codegraphcontext-0.4.15.dist-info → codegraphcontext-0.4.17.dist-info}/RECORD +51 -49
  48. {codegraphcontext-0.4.15.dist-info → codegraphcontext-0.4.17.dist-info}/WHEEL +0 -0
  49. {codegraphcontext-0.4.15.dist-info → codegraphcontext-0.4.17.dist-info}/entry_points.txt +0 -0
  50. {codegraphcontext-0.4.15.dist-info → codegraphcontext-0.4.17.dist-info}/licenses/LICENSE +0 -0
  51. {codegraphcontext-0.4.15.dist-info → codegraphcontext-0.4.17.dist-info}/top_level.txt +0 -0
@@ -13,6 +13,8 @@ from .schemas import (
13
13
  )
14
14
  from codegraphcontext.server import MCPServer
15
15
 
16
+ import socket
17
+
16
18
  router = APIRouter()
17
19
 
18
20
  # Global server instance (initialized on startup)
@@ -25,9 +27,19 @@ def get_server() -> MCPServer:
25
27
  _server_instance = MCPServer(cwd=Path.cwd())
26
28
  return _server_instance
27
29
 
30
+ def raise_service_unavailable(exc: Exception):
31
+ raise HTTPException(
32
+ status_code=503,
33
+ detail="Database service unavailable",
34
+ ) from exc
35
+
28
36
  @router.get("/status", response_model=ApiResponse)
29
37
  async def get_status(server: MCPServer = Depends(get_server)):
30
- status = server.db_manager.is_connected()
38
+ try:
39
+ status = server.db_manager.is_connected()
40
+ except (OSError, socket.error) as exc:
41
+ raise_service_unavailable(exc)
42
+
31
43
  return ApiResponse(
32
44
  status="ok",
33
45
  message="Connected" if status else "Disconnected",
@@ -43,16 +55,35 @@ async def list_tools(server: MCPServer = Depends(get_server)):
43
55
 
44
56
  @router.post("/tools/call", response_model=ApiResponse)
45
57
  async def call_tool(
46
- request: ToolCallRequest,
58
+ request: ToolCallRequest,
47
59
  server: MCPServer = Depends(get_server)
48
60
  ):
49
61
  try:
50
- result = await server.handle_tool_call(request.name, request.arguments)
62
+ result = await server.handle_tool_call(
63
+ request.name,
64
+ request.arguments
65
+ )
66
+
51
67
  if "error" in result:
52
- return ApiResponse(status="error", error=result["error"])
53
- return ApiResponse(status="ok", data=result)
68
+ return ApiResponse(
69
+ status="error",
70
+ error=result["error"]
71
+ )
72
+
73
+ return ApiResponse(
74
+ status="ok",
75
+ data=result
76
+ )
77
+
78
+ except (OSError, socket.error) as exc:
79
+ raise_service_unavailable(exc)
80
+
54
81
  except Exception as e:
55
- return ApiResponse(status="error", error=str(e))
82
+ return ApiResponse(
83
+ status="error",
84
+ error=str(e)
85
+ )
86
+
56
87
 
57
88
  @router.post("/index", response_model=ApiResponse)
58
89
  async def index_repository(
@@ -60,21 +91,27 @@ async def index_repository(
60
91
  background_tasks: BackgroundTasks,
61
92
  server: MCPServer = Depends(get_server)
62
93
  ):
63
- # Map to add_code_to_graph tool
64
94
  args = {
65
95
  "path": request.path,
66
96
  "repo_name": request.repo_name,
67
97
  "branch": request.branch,
68
98
  "force": request.force
69
99
  }
70
-
71
- # We call handle_tool_call which is async
72
- # But add_code_to_graph starts a background job anyway
73
- result = await server.handle_tool_call("add_code_to_graph", args)
74
-
100
+
101
+ try:
102
+ result = await server.handle_tool_call(
103
+ "add_code_to_graph",
104
+ args
105
+ )
106
+ except (OSError, socket.error) as exc:
107
+ raise_service_unavailable(exc)
108
+
75
109
  if "error" in result:
76
- raise HTTPException(status_code=400, detail=result["error"])
77
-
110
+ raise HTTPException(
111
+ status_code=400,
112
+ detail=result["error"]
113
+ )
114
+
78
115
  return ApiResponse(
79
116
  status="ok",
80
117
  message="Indexing job started",
@@ -86,17 +123,35 @@ async def execute_query(
86
123
  request: QueryRequest,
87
124
  server: MCPServer = Depends(get_server)
88
125
  ):
89
- result = await server.handle_tool_call("execute_cypher_query", {
90
- "cypher_query": request.query,
91
- "params": request.params
92
- })
93
-
126
+ try:
127
+ result = await server.handle_tool_call(
128
+ "execute_cypher_query",
129
+ {
130
+ "cypher_query": request.query,
131
+ "params": request.params,
132
+ },
133
+ )
134
+ except (OSError, socket.error) as exc:
135
+ raise_service_unavailable(exc)
136
+
94
137
  if "error" in result:
95
138
  return ApiResponse(status="error", error=result["error"])
96
-
139
+
97
140
  return ApiResponse(status="ok", data=result)
98
141
 
99
142
  @router.get("/repositories", response_model=ApiResponse)
100
- async def list_repositories(server: MCPServer = Depends(get_server)):
101
- result = await server.handle_tool_call("list_indexed_repositories", {})
102
- return ApiResponse(status="ok", data=result)
143
+ async def list_repositories(
144
+ server: MCPServer = Depends(get_server)
145
+ ):
146
+ try:
147
+ result = await server.handle_tool_call(
148
+ "list_indexed_repositories",
149
+ {}
150
+ )
151
+ except (OSError, socket.error) as exc:
152
+ raise_service_unavailable(exc)
153
+
154
+ return ApiResponse(
155
+ status="ok",
156
+ data=result
157
+ )
@@ -29,11 +29,36 @@ from ..tools.package_resolver import get_local_package_path
29
29
  from ..utils.debug_log import info_logger, warning_logger
30
30
  from ..core.database import Neo4jConnectionError
31
31
  from ..utils.repo_path import any_repo_matches_path
32
- from .config_manager import resolve_context, ResolvedContext, register_repo_in_context, ensure_first_run_bootstrap
32
+ from .config_manager import (
33
+ resolve_context,
34
+ ResolvedContext,
35
+ register_repo_in_context,
36
+ ensure_first_run_bootstrap,
37
+ ContextNotFoundError,
38
+ )
33
39
 
34
40
  console = Console()
35
41
 
36
42
 
43
+ def _fail_services_init() -> None:
44
+ """Abort the CLI command when database/services could not be initialized."""
45
+ raise typer.Exit(code=1)
46
+
47
+
48
+ def _kuzu_fallback_path(ctx: ResolvedContext) -> Optional[str]:
49
+ """Derive a KùzuDB directory when falling back from another backend."""
50
+ runtime = os.getenv("CGC_RUNTIME_DB_PATH")
51
+ if runtime:
52
+ return str(Path(runtime).expanduser().resolve())
53
+ if ctx.db_path:
54
+ return str(Path(ctx.db_path).parent / "kuzudb")
55
+ try:
56
+ from .config_manager import _default_global_db_path
57
+ return _default_global_db_path("kuzudb")
58
+ except Exception:
59
+ return None
60
+
61
+
37
62
  def _print_call_resolution_diagnostics(graph_builder: GraphBuilder, limit: int = 5) -> None:
38
63
  diagnostics = getattr(graph_builder, "last_call_resolution_diagnostics", [])
39
64
  if not diagnostics:
@@ -59,14 +84,21 @@ def _print_call_resolution_diagnostics(graph_builder: GraphBuilder, limit: int =
59
84
  console.print(table)
60
85
 
61
86
 
62
- def _initialize_services(cli_context_flag: Optional[str] = None) -> tuple[Any, Any, Any, ResolvedContext]:
87
+ def _initialize_services(
88
+ cli_context_flag: Optional[str] = None,
89
+ cwd: Optional[Path] = None,
90
+ ) -> tuple[Any, Any, Any, ResolvedContext]:
63
91
  """
64
92
  Initializes and returns core service managers based on the resolved context.
65
93
  Returns (db_manager, graph_builder, code_finder, resolved_context).
66
94
  """
67
95
  ensure_first_run_bootstrap()
68
96
  console.print("[dim]Resolving context...[/dim]")
69
- ctx = resolve_context(cli_context_flag)
97
+ try:
98
+ ctx = resolve_context(cli_context_flag, cwd=cwd)
99
+ except ContextNotFoundError as exc:
100
+ console.print(f"[bold red]Error:[/bold red] {exc}")
101
+ raise typer.Exit(code=1)
70
102
 
71
103
  # Let the user know what context we're operating in
72
104
  if ctx.mode == "named":
@@ -93,7 +125,7 @@ def _initialize_services(cli_context_flag: Optional[str] = None) -> tuple[Any, A
93
125
  db_manager = get_database_manager(db_path=runtime_path or ctx.db_path)
94
126
  except ValueError as e:
95
127
  console.print(f"[bold red]Database Configuration Error:[/bold red] {e}")
96
- return None, None, None, ctx
128
+ _fail_services_init()
97
129
 
98
130
  try:
99
131
  db_manager.get_driver()
@@ -101,6 +133,8 @@ def _initialize_services(cli_context_flag: Optional[str] = None) -> tuple[Any, A
101
133
  # Check if this is a FalkorDB failure that should trigger a KùzuDB fallback
102
134
  from ..core.database_falkordb import FalkorDBUnavailableError
103
135
  if isinstance(e, FalkorDBUnavailableError):
136
+ from ..core import mark_falkordb_unavailable
137
+ mark_falkordb_unavailable()
104
138
  console.print(f"[yellow]⚠ FalkorDB Lite is not functional in this environment: {e}[/yellow]")
105
139
  console.print("[cyan]Falling back to KùzuDB for a reliable experience...[/cyan]")
106
140
 
@@ -110,15 +144,16 @@ def _initialize_services(cli_context_flag: Optional[str] = None) -> tuple[Any, A
110
144
  except Exception:
111
145
  pass
112
146
 
113
- # Re-initialize explicitly with KùzuDB
147
+ # Re-initialize explicitly with KùzuDB (never reuse the FalkorDB directory)
114
148
  from ..core.database_kuzu import KuzuDBManager
115
- db_manager = KuzuDBManager()
149
+ kuzu_path = _kuzu_fallback_path(ctx)
150
+ db_manager = KuzuDBManager(db_path=kuzu_path)
116
151
  try:
117
152
  db_manager.get_driver()
118
153
  console.print("[green]✓[/green] Successfully switched to KùzuDB fallback")
119
154
  except Exception as kuzu_e:
120
155
  console.print(f"[bold red]Critical Error:[/bold red] Both FalkorDB and KùzuDB failed: {kuzu_e}")
121
- return None, None, None, ctx
156
+ _fail_services_init()
122
157
  else:
123
158
  selected_db = (
124
159
  os.environ.get("CGC_RUNTIME_DB_TYPE")
@@ -135,20 +170,20 @@ def _initialize_services(cli_context_flag: Optional[str] = None) -> tuple[Any, A
135
170
  console.print("[cyan]Neo4j failed and CGC_ALLOW_NEO4J_FALLBACK=true. Falling back to KuzuDB...[/cyan]")
136
171
  try:
137
172
  from ..core.database_kuzu import KuzuDBManager
138
- db_manager = KuzuDBManager()
173
+ db_manager = KuzuDBManager(db_path=_kuzu_fallback_path(ctx))
139
174
  db_manager.get_driver()
140
175
  console.print("[green]✓[/green] Successfully switched to KuzuDB fallback")
141
176
  except Exception as kuzu_e:
142
177
  console.print(f"[bold red]Critical Error:[/bold red] Neo4j failed and KuzuDB fallback failed: {kuzu_e}")
143
- return None, None, None, ctx
178
+ _fail_services_init()
144
179
  else:
145
180
  if selected_db == "neo4j":
146
181
  console.print("[yellow]Tip:[/yellow] To continue without Neo4j, rerun with --db kuzudb")
147
- return None, None, None, ctx
182
+ _fail_services_init()
148
183
  else:
149
184
  console.print(f"[bold red]Database Connection Error:[/bold red] {e}")
150
185
  console.print("Please ensure your database is configured correctly or run 'cgc doctor'.")
151
- return None, None, None, ctx
186
+ _fail_services_init()
152
187
 
153
188
  # The GraphBuilder requires an event loop, even for synchronous-style execution
154
189
  try:
@@ -199,8 +234,8 @@ async def _run_index_with_progress(graph_builder: GraphBuilder, path_obj: Path,
199
234
  if job.total_files > 0:
200
235
  progress.update(task_id, total=job.total_files, completed=job.processed_files)
201
236
 
202
- # Update the current filename in the UI
203
- current_file = job.current_file or ""
237
+ # Prefer post-processing status over the last parsed file path
238
+ current_file = job.status_message or job.current_file or ""
204
239
  if len(current_file) > 40:
205
240
  current_file = "..." + current_file[-37:]
206
241
  progress.update(task_id, filename=current_file)
@@ -224,17 +259,18 @@ async def _run_index_with_progress(graph_builder: GraphBuilder, path_obj: Path,
224
259
  def index_helper(path: str, context: Optional[str] = None):
225
260
  """Synchronously indexes a repository in a given context."""
226
261
  time_start = time.time()
227
- services = _initialize_services(context)
262
+ path_obj = Path(path).resolve()
263
+ index_cwd = path_obj if path_obj.is_dir() else path_obj.parent
264
+ services = _initialize_services(context, cwd=index_cwd)
228
265
  if not all(services[:3]):
229
- return
266
+ _fail_services_init()
230
267
 
231
268
  db_manager, graph_builder, code_finder, ctx = services
232
- path_obj = Path(path).resolve()
233
269
 
234
270
  if not path_obj.exists():
235
271
  console.print(f"[red]Error: Path does not exist: {path_obj}[/red]")
236
272
  db_manager.close_driver()
237
- return
273
+ raise typer.Exit(code=1)
238
274
 
239
275
  indexed_repos = code_finder.list_indexed_repositories()
240
276
  repo_exists = any_repo_matches_path(indexed_repos, path_obj)
@@ -262,9 +298,10 @@ def index_helper(path: str, context: Optional[str] = None):
262
298
  except Exception as e:
263
299
  console.print(f"[yellow]Warning: Could not check file count: {e}. Proceeding with indexing...[/yellow]")
264
300
 
265
- # Auto-register the repo into the named context (auto-creates if needed)
266
301
  if context and ctx.mode == "named":
267
- register_repo_in_context(context, str(path_obj), auto_create=True)
302
+ if not register_repo_in_context(context, str(path_obj), auto_create=False):
303
+ db_manager.close_driver()
304
+ raise typer.Exit(code=1)
268
305
 
269
306
  console.print(f"Starting indexing for: {path_obj}")
270
307
 
@@ -298,7 +335,7 @@ def add_package_helper(package_name: str, language: str, context: Optional[str]
298
335
  """Synchronously indexes a package."""
299
336
  services = _initialize_services(context)
300
337
  if not all(services[:3]):
301
- return
338
+ _fail_services_init()
302
339
 
303
340
  db_manager, graph_builder, code_finder, ctx = services
304
341
 
@@ -306,7 +343,7 @@ def add_package_helper(package_name: str, language: str, context: Optional[str]
306
343
  if not package_path_str:
307
344
  console.print(f"[red]Error: Could not find package '{package_name}' for language '{language}'.[/red]")
308
345
  db_manager.close_driver()
309
- return
346
+ raise typer.Exit(code=1)
310
347
 
311
348
  package_path = Path(package_path_str)
312
349
 
@@ -324,6 +361,7 @@ def add_package_helper(package_name: str, language: str, context: Optional[str]
324
361
  console.print(f"[green]Successfully finished indexing package: {package_name}[/green]")
325
362
  except Exception as e:
326
363
  console.print(f"[bold red]An error occurred during package indexing:[/bold red] {e}")
364
+ raise typer.Exit(code=1)
327
365
  finally:
328
366
  db_manager.close_driver()
329
367
 
@@ -332,7 +370,7 @@ def list_repos_helper(context: Optional[str] = None):
332
370
  """Lists all indexed repositories."""
333
371
  services = _initialize_services(context)
334
372
  if not all(services[:3]):
335
- return
373
+ _fail_services_init()
336
374
 
337
375
  db_manager, _, code_finder, ctx = services
338
376
 
@@ -362,7 +400,7 @@ def delete_helper(repo_path: str, context: Optional[str] = None):
362
400
  """Deletes a repository from the graph."""
363
401
  services = _initialize_services(context)
364
402
  if not all(services[:3]):
365
- return
403
+ _fail_services_init()
366
404
 
367
405
  db_manager, graph_builder, _, ctx = services
368
406
 
@@ -382,26 +420,29 @@ def cypher_helper(query: str, context: Optional[str] = None):
382
420
  """Executes a read-only Cypher query."""
383
421
  services = _initialize_services(context)
384
422
  if not all(services[:3]):
385
- return
423
+ _fail_services_init()
386
424
 
387
425
  db_manager, _, _, ctx = services
388
-
389
- # Replicating safety checks from MCPServer (using word boundaries to avoid false positives like 'createEmail')
390
- import re
391
- forbidden_keywords = ['CREATE', 'MERGE', 'DELETE', 'SET', 'REMOVE', 'DROP', 'CALL apoc']
392
- pattern = r'\b(' + '|'.join(forbidden_keywords) + r')\b'
393
- if re.search(pattern, query, re.IGNORECASE):
394
- console.print("[bold red]Error: This command only supports read-only queries.[/bold red]")
426
+
427
+ from ..utils.cypher_readonly import is_read_only_cypher, read_only_rejection_message
428
+
429
+ if not is_read_only_cypher(query):
430
+ console.print(f"[bold red]Error:[/bold red] {read_only_rejection_message()}")
395
431
  db_manager.close_driver()
396
- return
432
+ raise typer.Exit(code=1)
433
+
434
+ backend = getattr(db_manager, "get_backend_type", lambda: "neo4j")()
435
+ session_kwargs = {"default_access_mode": "READ"} if backend == "neo4j" else {}
397
436
 
398
437
  try:
399
- with db_manager.get_driver().session() as session:
438
+ with db_manager.get_driver().session(**session_kwargs) as session:
400
439
  result = session.run(query)
401
440
  records = [record.data() for record in result]
402
441
  console.print(json.dumps(records, indent=2))
403
442
  except Exception as e:
404
443
  console.print(f"[bold red]An error occurred while executing query:[/bold red] {e}")
444
+ db_manager.close_driver()
445
+ raise typer.Exit(code=1)
405
446
  finally:
406
447
  db_manager.close_driver()
407
448
 
@@ -409,34 +450,25 @@ def cypher_helper(query: str, context: Optional[str] = None):
409
450
  def cypher_helper_visual(query: str, context: Optional[str] = None):
410
451
  """Executes a read-only Cypher query and visualizes the results."""
411
452
  from .visualizer import visualize_cypher_results
412
-
453
+ from ..utils.cypher_readonly import is_read_only_cypher, read_only_rejection_message
454
+
413
455
  services = _initialize_services(context)
414
456
  if not all(services[:3]):
415
- return
457
+ _fail_services_init()
416
458
 
417
459
  db_manager, _, _, ctx = services
418
-
419
- # Replicating safety checks from MCPServer (using word boundaries to avoid false positives like 'createEmail')
420
- import re
421
- forbidden_keywords = ['CREATE', 'MERGE', 'DELETE', 'SET', 'REMOVE', 'DROP', 'CALL apoc']
422
- pattern = r'\b(' + '|'.join(forbidden_keywords) + r')\b'
423
- if re.search(pattern, query, re.IGNORECASE):
424
- console.print("[bold red]Error: This command only supports read-only queries.[/bold red]")
460
+
461
+ if not is_read_only_cypher(query):
462
+ console.print(f"[bold red]Error:[/bold red] {read_only_rejection_message()}")
425
463
  db_manager.close_driver()
426
- return
464
+ raise typer.Exit(code=1)
427
465
 
428
466
  try:
429
- with db_manager.get_driver().session() as session:
430
- result = session.run(query)
431
- records = [record.data() for record in result]
432
-
433
- if not records:
434
- console.print("[yellow]No results to visualize.[/yellow]")
435
- return # finally block will close driver
436
-
437
- visualize_cypher_results(records, query)
467
+ visualize_cypher_results(query)
438
468
  except Exception as e:
439
469
  console.print(f"[bold red]An error occurred while executing query:[/bold red] {e}")
470
+ db_manager.close_driver()
471
+ raise typer.Exit(code=1)
440
472
  finally:
441
473
  db_manager.close_driver()
442
474
 
@@ -445,11 +477,16 @@ import uvicorn
445
477
  import urllib.parse
446
478
  from ..viz.server import run_server, set_db_manager
447
479
 
448
- def visualize_helper(repo_path: Optional[str] = None, port: int = 8000, context: Optional[str] = None):
480
+ def visualize_helper(
481
+ repo_path: Optional[str] = None,
482
+ port: int = 8000,
483
+ context: Optional[str] = None,
484
+ cypher_query: Optional[str] = None,
485
+ ):
449
486
  """Generates an interactive visualization using the Playground UI."""
450
487
  services = _initialize_services(context)
451
488
  if not all(services[:3]):
452
- return
489
+ _fail_services_init()
453
490
 
454
491
  db_manager, _, _, ctx = services
455
492
 
@@ -516,7 +553,9 @@ def visualize_helper(repo_path: Optional[str] = None, port: int = 8000, context:
516
553
  params = {"backend": backend_url}
517
554
  if repo_path:
518
555
  params["repo_path"] = str(Path(repo_path).resolve())
519
-
556
+ if cypher_query:
557
+ params["cypher_query"] = cypher_query
558
+
520
559
  query_string = urllib.parse.urlencode(params)
521
560
  visualization_url = f"{backend_url}/explore?{query_string}"
522
561
 
@@ -537,6 +576,7 @@ def visualize_helper(repo_path: Optional[str] = None, port: int = 8000, context:
537
576
  run_server(host="127.0.0.1", port=port, static_dir=str(static_dir))
538
577
  except Exception as e:
539
578
  console.print(f"[bold red]An error occurred while running the server:[/bold red] {e}")
579
+ raise typer.Exit(code=1)
540
580
  finally:
541
581
  db_manager.close_driver()
542
582
 
@@ -544,17 +584,18 @@ def visualize_helper(repo_path: Optional[str] = None, port: int = 8000, context:
544
584
  def reindex_helper(path: str, context: Optional[str] = None):
545
585
  """Force re-index by deleting and rebuilding the repository."""
546
586
  time_start = time.time()
547
- services = _initialize_services(context)
587
+ path_obj = Path(path).resolve()
588
+ index_cwd = path_obj if path_obj.is_dir() else path_obj.parent
589
+ services = _initialize_services(context, cwd=index_cwd)
548
590
  if not all(services[:3]):
549
- return
591
+ _fail_services_init()
550
592
 
551
593
  db_manager, graph_builder, code_finder, ctx = services
552
- path_obj = Path(path).resolve()
553
594
 
554
595
  if not path_obj.exists():
555
596
  console.print(f"[red]Error: Path does not exist: {path_obj}[/red]")
556
597
  db_manager.close_driver()
557
- return
598
+ raise typer.Exit(code=1)
558
599
 
559
600
  # Check if already indexed
560
601
  indexed_repos = code_finder.list_indexed_repositories()
@@ -568,7 +609,7 @@ def reindex_helper(path: str, context: Optional[str] = None):
568
609
  except Exception as e:
569
610
  console.print(f"[red]Error deleting old index: {e}[/red]")
570
611
  db_manager.close_driver()
571
- return
612
+ raise typer.Exit(code=1)
572
613
 
573
614
  console.print(f"[cyan]Re-indexing: {path_obj}[/cyan]")
574
615
 
@@ -595,7 +636,7 @@ def clean_helper(context: Optional[str] = None):
595
636
  """Remove orphaned nodes and relationships from the database."""
596
637
  services = _initialize_services(context)
597
638
  if not all(services[:3]):
598
- return
639
+ _fail_services_init()
599
640
 
600
641
  db_manager, _, _, ctx = services
601
642
 
@@ -606,14 +647,13 @@ def clean_helper(context: Optional[str] = None):
606
647
  batch_size = 500
607
648
 
608
649
  with db_manager.get_driver().session() as session:
609
- # Layer-by-layer deletion: iteratively delete nodes that lost
610
- # their CONTAINS parent. Each pass peels one layer of the
611
- # Repository → File → Class/Function → Variable hierarchy.
650
+ # Delete nodes with no incoming relationships (true orphans).
651
+ # Parameters (HAS_PARAMETER), import Modules (IMPORTS), etc. are kept.
612
652
  while True:
613
653
  result = session.run("""
614
654
  MATCH (n)
615
655
  WHERE NOT n:Repository
616
- AND NOT ()-[:CONTAINS]->(n)
656
+ AND NOT ()-[]->(n)
617
657
  WITH n LIMIT $batch_size
618
658
  DETACH DELETE n
619
659
  RETURN count(n) as deleted
@@ -643,7 +683,7 @@ def stats_helper(path: str = None, context: Optional[str] = None):
643
683
  """Show indexing statistics for a repository or overall."""
644
684
  services = _initialize_services(context)
645
685
  if not all(services[:3]):
646
- return
686
+ _fail_services_init()
647
687
 
648
688
  db_manager, _, code_finder, ctx = services
649
689
 
@@ -741,7 +781,7 @@ def stats_helper(path: str = None, context: Optional[str] = None):
741
781
  db_manager.close_driver()
742
782
 
743
783
 
744
- def watch_helper(path: str, context: Optional[str] = None):
784
+ def watch_helper(path: str, context: Optional[str] = None, use_polling: Optional[bool] = None):
745
785
  """Watch a directory for changes and auto-update the graph (blocking mode)."""
746
786
  import logging
747
787
  from ..core.watcher import CodeWatcher
@@ -753,7 +793,7 @@ def watch_helper(path: str, context: Optional[str] = None):
753
793
 
754
794
  services = _initialize_services(context)
755
795
  if not all(services[:3]):
756
- return
796
+ _fail_services_init()
757
797
 
758
798
  db_manager, graph_builder, code_finder, ctx = services
759
799
  path_obj = Path(path).resolve()
@@ -761,12 +801,12 @@ def watch_helper(path: str, context: Optional[str] = None):
761
801
  if not path_obj.exists():
762
802
  console.print(f"[red]Error: Path does not exist: {path_obj}[/red]")
763
803
  db_manager.close_driver()
764
- return
804
+ raise typer.Exit(code=1)
765
805
 
766
806
  if not path_obj.is_dir():
767
807
  console.print(f"[red]Error: Path must be a directory: {path_obj}[/red]")
768
808
  db_manager.close_driver()
769
- return
809
+ raise typer.Exit(code=1)
770
810
 
771
811
  console.print(f"[bold cyan]🔍 Watching {path_obj} for changes...[/bold cyan]")
772
812
 
@@ -796,7 +836,7 @@ def watch_helper(path: str, context: Optional[str] = None):
796
836
 
797
837
  # Create watcher instance
798
838
  job_manager = JobManager()
799
- watcher = CodeWatcher(graph_builder, job_manager)
839
+ watcher = CodeWatcher(graph_builder, job_manager, use_polling=use_polling)
800
840
 
801
841
  try:
802
842
  # Start the observer thread