codegraphcontext 0.5.3__py3-none-any.whl → 0.5.4__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.
- codegraphcontext/api/app.py +19 -5
- codegraphcontext/api/mcp_sse.py +49 -2
- codegraphcontext/cli/cli_helpers.py +123 -69
- codegraphcontext/cli/config_manager.py +3 -0
- codegraphcontext/cli/main.py +10 -5
- codegraphcontext/cli/setup_wizard.py +5 -0
- codegraphcontext/core/__init__.py +70 -48
- codegraphcontext/core/cgc_bundle.py +28 -3
- codegraphcontext/core/database_embedded_kuzu.py +12 -1
- codegraphcontext/core/database_falkordb.py +37 -10
- codegraphcontext/core/falkor_worker.py +16 -3
- codegraphcontext/core/watcher.py +10 -3
- codegraphcontext/server.py +1 -1
- codegraphcontext/tool_definitions.py +12 -2
- codegraphcontext/tools/advanced_language_query_tool.py +2 -0
- codegraphcontext/tools/code_finder.py +15 -12
- codegraphcontext/tools/graph_builder.py +11 -4
- codegraphcontext/tools/indexing/persistence/writer.py +32 -4
- codegraphcontext/tools/indexing/pipeline.py +41 -7
- codegraphcontext/tools/indexing/pre_scan.py +6 -0
- codegraphcontext/tools/indexing/resolution/calls.py +31 -7
- codegraphcontext/tools/indexing/resolution/inheritance.py +16 -2
- codegraphcontext/tools/indexing/resolution/post_resolution.py +13 -4
- codegraphcontext/tools/languages/sfc_common.py +240 -0
- codegraphcontext/tools/languages/solidity.py +1069 -0
- codegraphcontext/tools/languages/solidity_remappings.py +157 -0
- codegraphcontext/tools/languages/svelte.py +38 -0
- codegraphcontext/tools/languages/vue.py +38 -0
- codegraphcontext/tools/query_tool_languages/solidity_toolkit.py +70 -0
- codegraphcontext/tools/tree_sitter_parser.py +3 -0
- codegraphcontext/utils/cypher_readonly.py +27 -6
- codegraphcontext/utils/tree_sitter_manager.py +7 -0
- codegraphcontext/viz/dist/assets/__vite-browser-external-9wXp6ZBx.js +1 -0
- codegraphcontext/viz/dist/assets/function-calls-BtRHrqa2.png +0 -0
- codegraphcontext/viz/dist/assets/graph-total-D1fBAugo.png +0 -0
- codegraphcontext/viz/dist/assets/hero-graph-2voMJp2a.jpg +0 -0
- codegraphcontext/viz/dist/assets/hierarchy-DGADo0YT.png +0 -0
- codegraphcontext/viz/dist/assets/index-C-187lf0.js +5587 -0
- codegraphcontext/viz/dist/assets/index-fNAa6jgv.css +1 -0
- codegraphcontext/viz/dist/assets/parser-pyodide.worker-BgsDfaad.js +370 -0
- codegraphcontext/viz/dist/assets/parser.worker-_nvrecvj.js +233 -0
- codegraphcontext/viz/dist/assets/tree-sitter-qKYAACSa.wasm +0 -0
- codegraphcontext/viz/dist/cgcIcon.png +0 -0
- codegraphcontext/viz/dist/favicon.ico +0 -0
- codegraphcontext/viz/dist/index.html +32 -0
- codegraphcontext/viz/dist/logo-icon.svg +85 -0
- codegraphcontext/viz/dist/logo.svg +100 -0
- codegraphcontext/viz/dist/placeholder.svg +1 -0
- codegraphcontext/viz/dist/preview-image.png +0 -0
- codegraphcontext/viz/dist/robots.txt +14 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-c.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-core.js +1 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-cpp.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-dart.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-go.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-java.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-javascript.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-kotlin.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-perl.wasm +1 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-php.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-python.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-ruby.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-rust.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-swift.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-tsx.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter-typescript.wasm +0 -0
- codegraphcontext/viz/dist/wasm/tree-sitter.wasm +0 -0
- codegraphcontext/viz/dist/wasm/web-tree-sitter.js +4007 -0
- codegraphcontext/viz/dist/wasm/web-tree-sitter.wasm +0 -0
- codegraphcontext/viz/server.py +34 -9
- {codegraphcontext-0.5.3.dist-info → codegraphcontext-0.5.4.dist-info}/METADATA +29 -3
- {codegraphcontext-0.5.3.dist-info → codegraphcontext-0.5.4.dist-info}/RECORD +77 -33
- {codegraphcontext-0.5.3.dist-info → codegraphcontext-0.5.4.dist-info}/WHEEL +0 -0
- {codegraphcontext-0.5.3.dist-info → codegraphcontext-0.5.4.dist-info}/entry_points.txt +0 -0
- {codegraphcontext-0.5.3.dist-info → codegraphcontext-0.5.4.dist-info}/licenses/LICENSE +0 -0
- {codegraphcontext-0.5.3.dist-info → codegraphcontext-0.5.4.dist-info}/top_level.txt +0 -0
codegraphcontext/api/app.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# src/codegraphcontext/api/app.py
|
|
2
2
|
import os
|
|
3
|
-
from fastapi import FastAPI
|
|
3
|
+
from fastapi import Depends, FastAPI
|
|
4
4
|
from fastapi.responses import HTMLResponse
|
|
5
5
|
from fastapi.middleware.cors import CORSMiddleware
|
|
6
6
|
from .router import router
|
|
7
|
-
from .auth import log_auth_status
|
|
7
|
+
from .auth import log_auth_status, require_api_key
|
|
8
8
|
from .mcp_sse import handle_sse, handle_messages
|
|
9
9
|
|
|
10
10
|
def create_app() -> FastAPI:
|
|
@@ -37,9 +37,23 @@ def create_app() -> FastAPI:
|
|
|
37
37
|
"""Liveness probe for load balancers and k8s."""
|
|
38
38
|
return {"status": "ok"}
|
|
39
39
|
|
|
40
|
-
# MCP-over-SSE Endpoints
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
# MCP-over-SSE Endpoints. These dispatch to the same tools as the REST
|
|
41
|
+
# router (execute_cypher_query, add_code_to_graph, delete_repository), so
|
|
42
|
+
# they need the same API-key dependency the router applies — without it,
|
|
43
|
+
# setting CGC_API_KEY left the SSE transport as an unauthenticated path to
|
|
44
|
+
# every tool.
|
|
45
|
+
app.add_api_route(
|
|
46
|
+
"/api/v1/mcp/sse",
|
|
47
|
+
handle_sse,
|
|
48
|
+
methods=["GET"],
|
|
49
|
+
dependencies=[Depends(require_api_key)],
|
|
50
|
+
)
|
|
51
|
+
app.add_api_route(
|
|
52
|
+
"/api/v1/mcp/messages",
|
|
53
|
+
handle_messages,
|
|
54
|
+
methods=["POST"],
|
|
55
|
+
dependencies=[Depends(require_api_key)],
|
|
56
|
+
)
|
|
43
57
|
|
|
44
58
|
@app.get("/", response_class=HTMLResponse)
|
|
45
59
|
async def root():
|
codegraphcontext/api/mcp_sse.py
CHANGED
|
@@ -48,11 +48,41 @@ async def handle_call_tool(name: str, arguments: dict | None) -> list[TextConten
|
|
|
48
48
|
# Create the SSE transport.
|
|
49
49
|
sse = SseServerTransport("/api/v1/mcp/messages")
|
|
50
50
|
|
|
51
|
+
|
|
52
|
+
class _AlreadySentResponse(Response):
|
|
53
|
+
"""Placeholder for a response the MCP transport wrote itself.
|
|
54
|
+
|
|
55
|
+
Both handlers below hand the raw ASGI ``send`` to the SDK, which emits the
|
|
56
|
+
complete response. FastAPI still does ``await endpoint(request)`` followed
|
|
57
|
+
by ``await response(...)``, so returning ``None`` raises ``TypeError:
|
|
58
|
+
'NoneType' object is not callable`` and returning a real ``Response`` starts
|
|
59
|
+
a second response on a finished ASGI cycle. This satisfies FastAPI while
|
|
60
|
+
putting nothing on the wire.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _SendTracker:
|
|
68
|
+
"""ASGI ``send`` wrapper that records whether a response was started."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, send):
|
|
71
|
+
self._send = send
|
|
72
|
+
self.response_started = False
|
|
73
|
+
|
|
74
|
+
async def __call__(self, message) -> None:
|
|
75
|
+
if message.get("type") == "http.response.start":
|
|
76
|
+
self.response_started = True
|
|
77
|
+
await self._send(message)
|
|
78
|
+
|
|
79
|
+
|
|
51
80
|
async def handle_sse(request: Request):
|
|
52
81
|
"""Entry point for the SSE connection."""
|
|
53
82
|
logger.info("SSE client connected")
|
|
83
|
+
sender = _SendTracker(request._send)
|
|
54
84
|
try:
|
|
55
|
-
async with sse.connect_sse(request.scope, request.receive,
|
|
85
|
+
async with sse.connect_sse(request.scope, request.receive, sender) as (read_stream, write_stream):
|
|
56
86
|
await mcp_server.run(
|
|
57
87
|
read_stream,
|
|
58
88
|
write_stream,
|
|
@@ -70,6 +100,10 @@ async def handle_sse(request: Request):
|
|
|
70
100
|
logger.debug("SSE connection closed: %s", type(exc).__name__)
|
|
71
101
|
finally:
|
|
72
102
|
logger.info("SSE client disconnected — handler exited, resources freed")
|
|
103
|
+
if sender.response_started:
|
|
104
|
+
return _AlreadySentResponse()
|
|
105
|
+
# connect_sse bailed out before writing anything (e.g. rejected origin).
|
|
106
|
+
return Response(status_code=500, content="SSE connection could not be established")
|
|
73
107
|
|
|
74
108
|
|
|
75
109
|
async def handle_messages(request: Request):
|
|
@@ -99,7 +133,20 @@ async def handle_messages(request: Request):
|
|
|
99
133
|
media_type="application/json"
|
|
100
134
|
)
|
|
101
135
|
|
|
136
|
+
# `handle_post_message` builds its own Request and awaits `.body()` again.
|
|
137
|
+
# Starlette caches a body on the Request instance, not in the scope, so
|
|
138
|
+
# handing it the raw `receive` would make it wait for a body that has
|
|
139
|
+
# already been drained above — the POST hangs until the client gives up and
|
|
140
|
+
# the message never reaches the session. Replay the buffered bytes instead.
|
|
141
|
+
async def replay_receive() -> dict:
|
|
142
|
+
return {"type": "http.request", "body": raw_body, "more_body": False}
|
|
143
|
+
|
|
144
|
+
sender = _SendTracker(request._send)
|
|
102
145
|
try:
|
|
103
|
-
await sse.handle_post_message(request.scope,
|
|
146
|
+
await sse.handle_post_message(request.scope, replay_receive, sender)
|
|
104
147
|
except Exception as exc:
|
|
105
148
|
logger.debug("Message handler closed: %s", type(exc).__name__)
|
|
149
|
+
|
|
150
|
+
if sender.response_started:
|
|
151
|
+
return _AlreadySentResponse()
|
|
152
|
+
return Response(status_code=202, content="Accepted")
|
|
@@ -239,65 +239,78 @@ def _initialize_services(
|
|
|
239
239
|
return db_manager, graph_builder, code_finder, ctx
|
|
240
240
|
|
|
241
241
|
|
|
242
|
-
async def _run_index_with_progress(graph_builder: GraphBuilder, path_obj: Path, is_dependency: bool = False, cgcignore_path: str = None):
|
|
242
|
+
async def _run_index_with_progress(graph_builder: GraphBuilder, path_obj: Path, is_dependency: bool = False, cgcignore_path: str = None, disable_progress: bool = False):
|
|
243
243
|
"""Internal helper to run indexing with a Live progress bar."""
|
|
244
244
|
job_id = graph_builder.job_manager.create_job(str(path_obj), is_dependency=is_dependency)
|
|
245
245
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
TaskProgressColumn(),
|
|
252
|
-
MofNCompleteColumn(),
|
|
253
|
-
TimeRemainingColumn(),
|
|
254
|
-
TextColumn("[dim]{task.fields[filename]}"),
|
|
255
|
-
console=console,
|
|
256
|
-
transient=True,
|
|
257
|
-
) as progress:
|
|
258
|
-
|
|
259
|
-
task_id = progress.add_task(
|
|
260
|
-
"Indexing...",
|
|
261
|
-
total=None, # Will be updated once file discovery is done
|
|
262
|
-
filename=""
|
|
263
|
-
)
|
|
246
|
+
disable_progress = (
|
|
247
|
+
disable_progress
|
|
248
|
+
or not console.is_terminal
|
|
249
|
+
or os.environ.get("CI", "").lower() == "true"
|
|
250
|
+
)
|
|
264
251
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
)
|
|
252
|
+
if not disable_progress:
|
|
253
|
+
os.environ["CGC_ACTIVE_PROGRESS_BAR"] = "1"
|
|
268
254
|
|
|
269
|
-
|
|
255
|
+
try:
|
|
256
|
+
# Create the progress bar
|
|
257
|
+
with Progress(
|
|
258
|
+
SpinnerColumn(),
|
|
259
|
+
TextColumn("[progress.description]{task.description}"),
|
|
260
|
+
BarColumn(),
|
|
261
|
+
TaskProgressColumn(),
|
|
262
|
+
MofNCompleteColumn(),
|
|
263
|
+
TimeRemainingColumn(),
|
|
264
|
+
TextColumn("[dim]{task.fields[filename]}"),
|
|
265
|
+
console=console,
|
|
266
|
+
transient=True,
|
|
267
|
+
disable=disable_progress,
|
|
268
|
+
) as progress:
|
|
270
269
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
progress.update(task_id, total=job.total_files, completed=job.processed_files)
|
|
277
|
-
|
|
278
|
-
# Prefer post-processing status over the last parsed file path
|
|
279
|
-
current_file = job.status_message or job.current_file or ""
|
|
280
|
-
if len(current_file) > 40:
|
|
281
|
-
current_file = "..." + current_file[-37:]
|
|
282
|
-
progress.update(task_id, filename=current_file)
|
|
270
|
+
task_id = progress.add_task(
|
|
271
|
+
"Indexing...",
|
|
272
|
+
total=None, # Will be updated once file discovery is done
|
|
273
|
+
filename=""
|
|
274
|
+
)
|
|
283
275
|
|
|
284
|
-
|
|
285
|
-
|
|
276
|
+
indexing_task = asyncio.create_task(
|
|
277
|
+
graph_builder.build_graph_from_path_async(path_obj, is_dependency=is_dependency, job_id=job_id, cgcignore_path=cgcignore_path)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
from ..core.jobs import JobStatus
|
|
286
281
|
|
|
287
|
-
|
|
282
|
+
# Poll for updates
|
|
283
|
+
while not indexing_task.done():
|
|
284
|
+
job = graph_builder.job_manager.get_job(job_id)
|
|
285
|
+
if job:
|
|
286
|
+
if job.total_files > 0:
|
|
287
|
+
progress.update(task_id, total=job.total_files, completed=job.processed_files)
|
|
288
|
+
|
|
289
|
+
# Prefer post-processing status over the last parsed file path
|
|
290
|
+
current_file = job.status_message or job.current_file or ""
|
|
291
|
+
if len(current_file) > 40:
|
|
292
|
+
current_file = "..." + current_file[-37:]
|
|
293
|
+
progress.update(task_id, filename=current_file)
|
|
294
|
+
|
|
295
|
+
if job.status in [JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED]:
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
await asyncio.sleep(0.1)
|
|
288
299
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
300
|
+
# Wait for actual completion and handle final state
|
|
301
|
+
try:
|
|
302
|
+
await indexing_task
|
|
303
|
+
job = graph_builder.job_manager.get_job(job_id)
|
|
304
|
+
if job and job.status == JobStatus.FAILED:
|
|
305
|
+
error_msg = job.errors[0] if job.errors else "Unknown error"
|
|
306
|
+
raise RuntimeError(error_msg)
|
|
307
|
+
except Exception as e:
|
|
308
|
+
raise e
|
|
309
|
+
finally:
|
|
310
|
+
os.environ.pop("CGC_ACTIVE_PROGRESS_BAR", None)
|
|
298
311
|
|
|
299
312
|
|
|
300
|
-
def index_helper(path: str, context: Optional[str] = None):
|
|
313
|
+
def index_helper(path: str, context: Optional[str] = None, no_progress: bool = False):
|
|
301
314
|
"""Synchronously indexes a repository in a given context."""
|
|
302
315
|
time_start = time.time()
|
|
303
316
|
path_obj = Path(path).resolve()
|
|
@@ -334,10 +347,16 @@ def index_helper(path: str, context: Optional[str] = None):
|
|
|
334
347
|
file_count = record["file_count"] if record else 0
|
|
335
348
|
|
|
336
349
|
if file_count > 0:
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
350
|
+
expected = graph_builder.estimate_processing_time(path_obj) if path_obj.is_dir() else None
|
|
351
|
+
expected_file_count = expected[0] if expected else None
|
|
352
|
+
if expected_file_count is None or file_count >= expected_file_count:
|
|
353
|
+
console.print(f"[yellow]Repository '{path}' is already indexed with {file_count} files. Skipping.[/yellow]")
|
|
354
|
+
console.print("[dim]💡 Tip: Use 'cgc index --force' to re-index[/dim]")
|
|
355
|
+
db_manager.close_driver()
|
|
356
|
+
return
|
|
357
|
+
console.print(
|
|
358
|
+
f"[yellow]Repository '{path}' has only {file_count} of {expected_file_count} files indexed. Continuing.[/yellow]"
|
|
359
|
+
)
|
|
341
360
|
else:
|
|
342
361
|
console.print(f"[yellow]Repository '{path}' exists but has no files (likely interrupted). Re-indexing...[/yellow]")
|
|
343
362
|
except Exception as e:
|
|
@@ -351,7 +370,7 @@ def index_helper(path: str, context: Optional[str] = None):
|
|
|
351
370
|
console.print(f"Starting indexing for: {path_obj}")
|
|
352
371
|
|
|
353
372
|
try:
|
|
354
|
-
asyncio.run(_run_index_with_progress(graph_builder, path_obj, is_dependency=False, cgcignore_path=ctx.cgcignore_path))
|
|
373
|
+
asyncio.run(_run_index_with_progress(graph_builder, path_obj, is_dependency=False, cgcignore_path=ctx.cgcignore_path, disable_progress=no_progress))
|
|
355
374
|
time_end = time.time()
|
|
356
375
|
elapsed = time_end - time_start
|
|
357
376
|
_print_call_resolution_diagnostics(graph_builder)
|
|
@@ -608,34 +627,56 @@ def _render_offline_visualization(
|
|
|
608
627
|
def _ident(value) -> Optional[str]:
|
|
609
628
|
return None if value is None else str(value)
|
|
610
629
|
|
|
611
|
-
#
|
|
612
|
-
# Ladybug
|
|
613
|
-
#
|
|
630
|
+
# Kùzu spells the internal record keys in lowercase (_label/_src/_dst/_id);
|
|
631
|
+
# Ladybug returns the same fields uppercased (_LABEL/_SRC/_DST/_ID). Look
|
|
632
|
+
# up both spellings so either backend reaches the offline renderer (#1458).
|
|
633
|
+
def _meta(d: dict, key: str, default=None):
|
|
634
|
+
if key in d:
|
|
635
|
+
return d[key]
|
|
636
|
+
return d.get(key.upper(), default)
|
|
637
|
+
|
|
638
|
+
def _has_meta(d: dict, key: str) -> bool:
|
|
639
|
+
return key in d or key.upper() in d
|
|
640
|
+
|
|
641
|
+
# Neo4j returns driver objects carrying .labels / .type that support
|
|
642
|
+
# dict()/Mapping access. FalkorDB's own driver objects also carry
|
|
643
|
+
# .labels on nodes, but are NOT dict-convertible — their properties live
|
|
644
|
+
# in a plain `.properties` dict, and its Edge exposes `.relation` /
|
|
645
|
+
# `.src_node` / `.dest_node` instead of `.type` / `.start_node` /
|
|
646
|
+
# `.end_node`. Kùzu and Ladybug return plain dicts carrying _label plus
|
|
647
|
+
# _src/_dst. Check the driver attributes first — a driver object may
|
|
648
|
+
# also be dict-like.
|
|
614
649
|
def _is_relationship(value) -> bool:
|
|
615
650
|
if hasattr(value, "labels"):
|
|
616
651
|
return False
|
|
617
652
|
if hasattr(value, "type"):
|
|
618
653
|
return True
|
|
619
|
-
|
|
654
|
+
if hasattr(value, "relation") and hasattr(value, "src_node"):
|
|
655
|
+
return True
|
|
656
|
+
return isinstance(value, dict) and _has_meta(value, "_src") and _has_meta(value, "_dst")
|
|
620
657
|
|
|
621
658
|
def _is_node(value) -> bool:
|
|
622
659
|
if hasattr(value, "labels"):
|
|
623
660
|
return True
|
|
624
661
|
if hasattr(value, "type"):
|
|
625
662
|
return False
|
|
663
|
+
if hasattr(value, "relation") and hasattr(value, "src_node"):
|
|
664
|
+
return False
|
|
626
665
|
# Kùzu relationships also carry _label, so _src/_dst is what
|
|
627
666
|
# distinguishes them from nodes.
|
|
628
|
-
return isinstance(value, dict) and "_label"
|
|
667
|
+
return isinstance(value, dict) and _has_meta(value, "_label") and not _has_meta(value, "_src")
|
|
629
668
|
|
|
630
669
|
def _node_payload(value) -> Dict[str, Any]:
|
|
631
670
|
if hasattr(value, "labels"):
|
|
632
|
-
|
|
671
|
+
# FalkorDB's Node keeps properties in `.properties` and is not
|
|
672
|
+
# itself iterable; Neo4j's Node supports dict() via Mapping.
|
|
673
|
+
props = dict(value.properties) if hasattr(value, "properties") else dict(value)
|
|
633
674
|
labels = list(getattr(value, "labels", []) or [])
|
|
634
675
|
label = labels[0] if labels else "Node"
|
|
635
676
|
node_id = getattr(value, "element_id", None) or getattr(value, "id", None)
|
|
636
677
|
else:
|
|
637
|
-
props, label = dict(value), value
|
|
638
|
-
node_id = value
|
|
678
|
+
props, label = dict(value), _meta(value, "_label", "Node")
|
|
679
|
+
node_id = _meta(value, "_id")
|
|
639
680
|
if node_id is None:
|
|
640
681
|
node_id = props.get("path") or props.get("name")
|
|
641
682
|
return {
|
|
@@ -646,20 +687,33 @@ def _render_offline_visualization(
|
|
|
646
687
|
"line_number": props.get("line_number"),
|
|
647
688
|
}
|
|
648
689
|
|
|
690
|
+
def _node_ref_id(ref) -> Any:
|
|
691
|
+
# Neo4j's start_node/end_node are full Node objects; FalkorDB's
|
|
692
|
+
# src_node/dest_node are already the bare node id (an int).
|
|
693
|
+
if isinstance(ref, (int, str)):
|
|
694
|
+
return ref
|
|
695
|
+
return getattr(ref, "element_id", None) or getattr(ref, "id", None)
|
|
696
|
+
|
|
649
697
|
def _edge_payload(value) -> Optional[Dict[str, Any]]:
|
|
698
|
+
# Neo4j: .start_node/.end_node/.type. FalkorDB: .src_node/.dest_node/
|
|
699
|
+
# .relation.
|
|
650
700
|
start = getattr(value, "start_node", None)
|
|
701
|
+
if start is None:
|
|
702
|
+
start = getattr(value, "src_node", None)
|
|
651
703
|
end = getattr(value, "end_node", None)
|
|
704
|
+
if end is None:
|
|
705
|
+
end = getattr(value, "dest_node", None)
|
|
652
706
|
if start is not None and end is not None:
|
|
653
707
|
return {
|
|
654
|
-
"source": _ident(
|
|
655
|
-
"target": _ident(
|
|
656
|
-
"type": getattr(value, "type", "RELATED"),
|
|
708
|
+
"source": _ident(_node_ref_id(start)),
|
|
709
|
+
"target": _ident(_node_ref_id(end)),
|
|
710
|
+
"type": getattr(value, "type", None) or getattr(value, "relation", "RELATED"),
|
|
657
711
|
}
|
|
658
712
|
if isinstance(value, dict):
|
|
659
713
|
return {
|
|
660
|
-
"source": _ident(value
|
|
661
|
-
"target": _ident(value
|
|
662
|
-
"type": value
|
|
714
|
+
"source": _ident(_meta(value, "_src")),
|
|
715
|
+
"target": _ident(_meta(value, "_dst")),
|
|
716
|
+
"type": _meta(value, "_label", "RELATED"),
|
|
663
717
|
}
|
|
664
718
|
return None
|
|
665
719
|
|
|
@@ -803,7 +857,7 @@ def visualize_helper(
|
|
|
803
857
|
db_manager.close_driver()
|
|
804
858
|
|
|
805
859
|
|
|
806
|
-
def reindex_helper(path: str, context: Optional[str] = None):
|
|
860
|
+
def reindex_helper(path: str, context: Optional[str] = None, no_progress: bool = False):
|
|
807
861
|
"""Force re-index by deleting and rebuilding the repository."""
|
|
808
862
|
time_start = time.time()
|
|
809
863
|
path_obj = Path(path).resolve()
|
|
@@ -836,7 +890,7 @@ def reindex_helper(path: str, context: Optional[str] = None):
|
|
|
836
890
|
console.print(f"[cyan]Re-indexing: {path_obj}[/cyan]")
|
|
837
891
|
|
|
838
892
|
try:
|
|
839
|
-
asyncio.run(_run_index_with_progress(graph_builder, path_obj, is_dependency=False, cgcignore_path=ctx.cgcignore_path))
|
|
893
|
+
asyncio.run(_run_index_with_progress(graph_builder, path_obj, is_dependency=False, cgcignore_path=ctx.cgcignore_path, disable_progress=no_progress))
|
|
840
894
|
time_end = time.time()
|
|
841
895
|
elapsed = time_end - time_start
|
|
842
896
|
_print_call_resolution_diagnostics(graph_builder)
|
|
@@ -87,6 +87,8 @@ DEFAULT_CONFIG = {
|
|
|
87
87
|
"DEFAULT_DATABASE": "falkordb",
|
|
88
88
|
"FALKORDB_PATH": str(CONFIG_DIR / "global" / "db" / "falkordb"),
|
|
89
89
|
"FALKORDB_SOCKET_PATH": str(CONFIG_DIR / "global" / "db" / "falkordb.sock"),
|
|
90
|
+
# Empty selects a deterministic port derived from each embedded DB socket.
|
|
91
|
+
"FALKORDB_PORT": "",
|
|
90
92
|
"LADYBUGDB_PATH": str(CONFIG_DIR / "global" / "db" / "ladybugdb"),
|
|
91
93
|
"KUZUDB_PATH": str(CONFIG_DIR / "global" / "db" / "kuzudb"),
|
|
92
94
|
"INDEX_VARIABLES": "true",
|
|
@@ -142,6 +144,7 @@ CONFIG_DESCRIPTIONS = {
|
|
|
142
144
|
"DEFAULT_DATABASE": "Default database backend (neo4j|falkordb|falkordb-remote|kuzudb|nornic|ladybugdb)",
|
|
143
145
|
"FALKORDB_PATH": "Path to FalkorDB database file",
|
|
144
146
|
"FALKORDB_SOCKET_PATH": "Path to FalkorDB Unix socket",
|
|
147
|
+
"FALKORDB_PORT": "Optional FalkorDB port override (empty = per-database port)",
|
|
145
148
|
"LADYBUGDB_PATH": "Path to LadybugDB database directory",
|
|
146
149
|
"KUZUDB_PATH": "Path to KuzuDB database directory",
|
|
147
150
|
"INDEX_VARIABLES": "Index variable nodes in the graph (lighter graph if false)",
|
codegraphcontext/cli/main.py
CHANGED
|
@@ -1440,6 +1440,7 @@ def index(
|
|
|
1440
1440
|
force: bool = typer.Option(False, "--force", "-f", help="Force re-index (delete existing and rebuild)"),
|
|
1441
1441
|
context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use (overrides mode/default)"),
|
|
1442
1442
|
summarize: bool = typer.Option(False, "--summarize", "-s", help="Show a summary of the indexed codebase after indexing"),
|
|
1443
|
+
no_progress: bool = typer.Option(False, "--no-progress", help="Disable live progress rendering during indexing."),
|
|
1443
1444
|
):
|
|
1444
1445
|
"""
|
|
1445
1446
|
Indexes a directory or file by adding it to the code graph.
|
|
@@ -1455,9 +1456,9 @@ def index(
|
|
|
1455
1456
|
try:
|
|
1456
1457
|
if force:
|
|
1457
1458
|
console.print("[yellow]Force re-indexing (--force flag detected)[/yellow]")
|
|
1458
|
-
reindex_helper(path, context)
|
|
1459
|
+
reindex_helper(path, context, no_progress=no_progress)
|
|
1459
1460
|
else:
|
|
1460
|
-
index_helper(path, context)
|
|
1461
|
+
index_helper(path, context, no_progress=no_progress)
|
|
1461
1462
|
except typer.Exit:
|
|
1462
1463
|
# typer.Exit subclasses RuntimeError and str() is empty, so the handler
|
|
1463
1464
|
# below caught it, printed nothing, and returned 0 — every helper that
|
|
@@ -3050,12 +3051,13 @@ def index_abbrev(
|
|
|
3050
3051
|
path: Optional[str] = typer.Argument(None, help="Path to index"),
|
|
3051
3052
|
force: bool = typer.Option(False, "--force", "-f", help="Force re-index (delete existing and rebuild)"),
|
|
3052
3053
|
summarize: bool = typer.Option(False, "--summarize", "-s", help="Display a codebase summary after indexing"),
|
|
3053
|
-
context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use")
|
|
3054
|
+
context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use"),
|
|
3055
|
+
no_progress: bool = typer.Option(False, "--no-progress", help="Disable live progress rendering during indexing.")
|
|
3054
3056
|
):
|
|
3055
3057
|
"""Shortcut for 'cgc index'"""
|
|
3056
3058
|
# `summarize` must be passed explicitly: omitted, it keeps its OptionInfo
|
|
3057
3059
|
# sentinel, which is truthy — so `cgc i` always printed the summary.
|
|
3058
|
-
index(path, force=force, summarize=summarize, context=context)
|
|
3060
|
+
index(path, force=force, summarize=summarize, context=context, no_progress=no_progress)
|
|
3059
3061
|
|
|
3060
3062
|
@app.command("ls", rich_help_panel="Shortcuts")
|
|
3061
3063
|
def list_abbrev(
|
|
@@ -3084,7 +3086,10 @@ def visualize_abbrev(
|
|
|
3084
3086
|
):
|
|
3085
3087
|
"""Shortcut for 'cgc visualize'"""
|
|
3086
3088
|
_load_credentials()
|
|
3087
|
-
|
|
3089
|
+
# `port` must be passed by keyword: visualize_helper's second positional
|
|
3090
|
+
# parameter is `host`, so the int landed there and the server tried to bind
|
|
3091
|
+
# to host=8000 while ignoring --port entirely.
|
|
3092
|
+
visualize_helper(repo, port=port, context=context)
|
|
3088
3093
|
|
|
3089
3094
|
@app.command("w", rich_help_panel="Shortcuts")
|
|
3090
3095
|
def watch_abbrev(
|
|
@@ -184,6 +184,11 @@ def find_jetbrains_mcp_config():
|
|
|
184
184
|
configs.append(mcp_file)
|
|
185
185
|
print(mcp_file)
|
|
186
186
|
return configs
|
|
187
|
+
# Always return a list: callers store this directly in the config_paths
|
|
188
|
+
# mapping and iterate it, so returning None (no JetBrains install, or no
|
|
189
|
+
# mcpServer.xml yet) raised a TypeError instead of falling through to the
|
|
190
|
+
# "configure manually" path.
|
|
191
|
+
return configs
|
|
187
192
|
|
|
188
193
|
|
|
189
194
|
def convert_mcp_json_to_yaml():
|
|
@@ -20,7 +20,9 @@ from pathlib import Path
|
|
|
20
20
|
from typing import Union, Optional
|
|
21
21
|
import importlib.util
|
|
22
22
|
|
|
23
|
-
#
|
|
23
|
+
# Retained for compatibility with callers that inspect this module attribute.
|
|
24
|
+
# FalkorDB startup failures are now tracked by FalkorDBManager per database
|
|
25
|
+
# configuration, rather than disabling the backend for the whole process.
|
|
24
26
|
_FALKORDB_DISABLED = False
|
|
25
27
|
|
|
26
28
|
|
|
@@ -48,15 +50,56 @@ def _fallback_db_path_for(db_path: Optional[str], target_backend: str) -> Option
|
|
|
48
50
|
return db_path
|
|
49
51
|
|
|
50
52
|
|
|
53
|
+
def _try_fallback_backends(db_path: Optional[str], candidates, *, reason: str):
|
|
54
|
+
"""
|
|
55
|
+
Return the first available backend from ``candidates``, naming it in the log.
|
|
56
|
+
|
|
57
|
+
Every fallback path funnels through here so the backend a user actually ends
|
|
58
|
+
up on is always recorded. A silent switch is effectively undebuggable: the
|
|
59
|
+
only trace left is that the *requested* backend was requested, so an
|
|
60
|
+
interpreter where FalkorDB Lite cannot load looks identical to one where it
|
|
61
|
+
loaded fine, and queries run against a different (often empty) database.
|
|
62
|
+
|
|
63
|
+
Returns ``None`` when no candidate is available, so callers keep control of
|
|
64
|
+
the error they raise.
|
|
65
|
+
"""
|
|
66
|
+
from codegraphcontext.utils.debug_log import warning_logger
|
|
67
|
+
|
|
68
|
+
for name in candidates:
|
|
69
|
+
if name == 'kuzudb' and _is_kuzudb_available():
|
|
70
|
+
from .database_kuzu import KuzuDBManager
|
|
71
|
+
path = _fallback_db_path_for(db_path, 'kuzudb')
|
|
72
|
+
warning_logger(
|
|
73
|
+
f"Database backend fallback: {reason} "
|
|
74
|
+
f"Now using KùzuDB at {path or 'default path'}."
|
|
75
|
+
)
|
|
76
|
+
return KuzuDBManager(db_path=path)
|
|
77
|
+
if name == 'ladybugdb' and _is_ladybugdb_available():
|
|
78
|
+
from .database_ladybug import LadybugDBManager
|
|
79
|
+
path = _fallback_db_path_for(db_path, 'ladybugdb')
|
|
80
|
+
warning_logger(
|
|
81
|
+
f"Database backend fallback: {reason} "
|
|
82
|
+
f"Now using LadybugDB at {path or 'default path'}."
|
|
83
|
+
)
|
|
84
|
+
return LadybugDBManager(db_path=path)
|
|
85
|
+
if name == 'neo4j' and _is_neo4j_configured():
|
|
86
|
+
from .database import DatabaseManager
|
|
87
|
+
warning_logger(f"Database backend fallback: {reason} Now using Neo4j Server.")
|
|
88
|
+
return DatabaseManager()
|
|
89
|
+
if name == 'nornic' and _is_nornic_configured():
|
|
90
|
+
from .database_nornic import NornicDBManager
|
|
91
|
+
warning_logger(f"Database backend fallback: {reason} Now using Nornic DB.")
|
|
92
|
+
return NornicDBManager()
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
51
96
|
def mark_falkordb_unavailable() -> None:
|
|
52
|
-
"""
|
|
53
|
-
global _FALKORDB_DISABLED
|
|
54
|
-
_FALKORDB_DISABLED = True
|
|
97
|
+
"""Compatibility hook; startup failures are scoped by FalkorDBManager."""
|
|
55
98
|
|
|
56
99
|
|
|
57
100
|
def is_falkordb_usable() -> bool:
|
|
58
|
-
"""True when FalkorDB Lite is
|
|
59
|
-
return _is_falkordb_available()
|
|
101
|
+
"""True when FalkorDB Lite is available on this system."""
|
|
102
|
+
return _is_falkordb_available()
|
|
60
103
|
|
|
61
104
|
def _is_kuzudb_available() -> bool:
|
|
62
105
|
"""Check if KùzuDB is installed."""
|
|
@@ -124,17 +167,13 @@ def get_database_manager(db_path: Optional[str] = None) -> Union['DatabaseManage
|
|
|
124
167
|
db_type = db_type.lower()
|
|
125
168
|
if db_type == 'kuzudb':
|
|
126
169
|
if not _is_kuzudb_available():
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return DatabaseManager()
|
|
135
|
-
if _is_nornic_configured():
|
|
136
|
-
from .database_nornic import NornicDBManager
|
|
137
|
-
return NornicDBManager()
|
|
170
|
+
mgr = _try_fallback_backends(
|
|
171
|
+
db_path,
|
|
172
|
+
('ladybugdb', 'neo4j', 'nornic'),
|
|
173
|
+
reason="database was set to 'kuzudb' but Kùzu is not installed.",
|
|
174
|
+
)
|
|
175
|
+
if mgr is not None:
|
|
176
|
+
return mgr
|
|
138
177
|
raise ValueError("Database set to 'kuzudb' but Kùzu is not installed.\nRun 'pip install kuzu'")
|
|
139
178
|
from .database_kuzu import KuzuDBManager
|
|
140
179
|
info_logger(f"Using KùzuDB (explicit) at {db_path or 'default path'}")
|
|
@@ -142,23 +181,13 @@ def get_database_manager(db_path: Optional[str] = None) -> Union['DatabaseManage
|
|
|
142
181
|
|
|
143
182
|
elif db_type == 'falkordb':
|
|
144
183
|
if not is_falkordb_usable():
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return
|
|
152
|
-
if _is_ladybugdb_available():
|
|
153
|
-
from .database_ladybug import LadybugDBManager
|
|
154
|
-
return LadybugDBManager(db_path=_fallback_db_path_for(db_path, 'ladybugdb'))
|
|
155
|
-
if _is_neo4j_configured():
|
|
156
|
-
from .database import DatabaseManager
|
|
157
|
-
info_logger("Using Neo4j Server (fallback)")
|
|
158
|
-
return DatabaseManager()
|
|
159
|
-
if _is_nornic_configured():
|
|
160
|
-
from .database_nornic import NornicDBManager
|
|
161
|
-
return NornicDBManager()
|
|
184
|
+
mgr = _try_fallback_backends(
|
|
185
|
+
db_path,
|
|
186
|
+
('kuzudb', 'ladybugdb', 'neo4j', 'nornic'),
|
|
187
|
+
reason="FalkorDB Lite is not supported or not installed here.",
|
|
188
|
+
)
|
|
189
|
+
if mgr is not None:
|
|
190
|
+
return mgr
|
|
162
191
|
raise ValueError(
|
|
163
192
|
"Database set to 'falkordb' but FalkorDB Lite is not installed or not supported on this OS.\n"
|
|
164
193
|
"Install 'falkordblite' or configure a supported alternative such as KùzuDB or Neo4j."
|
|
@@ -172,20 +201,13 @@ def get_database_manager(db_path: Optional[str] = None) -> Union['DatabaseManage
|
|
|
172
201
|
return mgr
|
|
173
202
|
except FalkorDBUnavailableError as falkor_err:
|
|
174
203
|
mark_falkordb_unavailable()
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return
|
|
182
|
-
if _is_neo4j_configured():
|
|
183
|
-
from .database import DatabaseManager
|
|
184
|
-
info_logger("Using Neo4j Server (fallback)")
|
|
185
|
-
return DatabaseManager()
|
|
186
|
-
if _is_nornic_configured():
|
|
187
|
-
from .database_nornic import NornicDBManager
|
|
188
|
-
return NornicDBManager()
|
|
204
|
+
mgr = _try_fallback_backends(
|
|
205
|
+
db_path,
|
|
206
|
+
('kuzudb', 'ladybugdb', 'neo4j', 'nornic'),
|
|
207
|
+
reason=f"FalkorDB Lite was requested but is not functional ({falkor_err}).",
|
|
208
|
+
)
|
|
209
|
+
if mgr is not None:
|
|
210
|
+
return mgr
|
|
189
211
|
raise
|
|
190
212
|
|
|
191
213
|
elif db_type == 'falkordb-remote':
|