codegraph-engine 2.1.8__py3-none-any.whl → 2.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codegraph/__init__.py +1 -1
- codegraph/cli.py +60 -14
- codegraph/context.py +3 -0
- codegraph/database/interrogation.py +30 -7
- codegraph/freshness.py +59 -21
- codegraph/indexing/indexer.py +4 -1
- codegraph/indexing/parser.py +55 -21
- codegraph/interrogation.py +19 -0
- codegraph/mcp/server.py +8 -1
- codegraph/runtime/runner.py +204 -0
- codegraph_engine-2.2.1.dist-info/METADATA +627 -0
- {codegraph_engine-2.1.8.dist-info → codegraph_engine-2.2.1.dist-info}/RECORD +16 -15
- codegraph_engine-2.1.8.dist-info/METADATA +0 -642
- {codegraph_engine-2.1.8.dist-info → codegraph_engine-2.2.1.dist-info}/WHEEL +0 -0
- {codegraph_engine-2.1.8.dist-info → codegraph_engine-2.2.1.dist-info}/entry_points.txt +0 -0
- {codegraph_engine-2.1.8.dist-info → codegraph_engine-2.2.1.dist-info}/licenses/LICENSE +0 -0
- {codegraph_engine-2.1.8.dist-info → codegraph_engine-2.2.1.dist-info}/top_level.txt +0 -0
codegraph/__init__.py
CHANGED
codegraph/cli.py
CHANGED
|
@@ -434,6 +434,8 @@ def routes(
|
|
|
434
434
|
framework: Annotated[str | None, typer.Option("--framework", "-f", help="Filter by framework (e.g. flask, fastapi)")] = None,
|
|
435
435
|
method: Annotated[str | None, typer.Option("--method", "-m", help="Filter by HTTP method (e.g. GET, POST)")] = None,
|
|
436
436
|
route_path: Annotated[str | None, typer.Option("--path", "-p", help="Filter by route path substring")] = None,
|
|
437
|
+
limit: Annotated[int | None, typer.Option("--limit", "-l", help="Maximum number of routes to return")] = None,
|
|
438
|
+
offset: Annotated[int, typer.Option("--offset", help="Number of routes to skip")] = 0,
|
|
437
439
|
json_output: Annotated[bool, typer.Option("--json", help="Output raw JSON array")] = False,
|
|
438
440
|
) -> None:
|
|
439
441
|
"""List and inspect indexed framework routes."""
|
|
@@ -479,6 +481,11 @@ def routes(
|
|
|
479
481
|
for r in rows
|
|
480
482
|
]
|
|
481
483
|
|
|
484
|
+
if limit is not None:
|
|
485
|
+
safe_offset = max(0, offset)
|
|
486
|
+
safe_limit = max(0, limit)
|
|
487
|
+
route_list = route_list[safe_offset : safe_offset + safe_limit]
|
|
488
|
+
|
|
482
489
|
if json_output:
|
|
483
490
|
typer.echo(json.dumps(route_list, indent=2))
|
|
484
491
|
else:
|
|
@@ -597,19 +604,60 @@ def debug(
|
|
|
597
604
|
)
|
|
598
605
|
|
|
599
606
|
|
|
600
|
-
@app.command(
|
|
601
|
-
|
|
602
|
-
|
|
607
|
+
@app.command(
|
|
608
|
+
"run",
|
|
609
|
+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
|
|
610
|
+
)
|
|
611
|
+
def run_cmd(
|
|
612
|
+
ctx: typer.Context,
|
|
613
|
+
command: Annotated[list[str] | None, typer.Argument(help="Development command and arguments to execute")] = None,
|
|
603
614
|
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
604
|
-
|
|
615
|
+
sample_rate: Annotated[float, typer.Option("--sample-rate", help="Sampling rate for trace capture (0.0..1.0)")] = 1.0,
|
|
616
|
+
) -> None:
|
|
617
|
+
"""Execute a development command or server with transparent runtime trace capture."""
|
|
618
|
+
full_cmd = (command or []) + ctx.args
|
|
619
|
+
if not full_cmd:
|
|
620
|
+
cli_echo("Error: Command cannot be empty. Usage: codegraph run <command> [args...]")
|
|
621
|
+
raise typer.Exit(code=1)
|
|
622
|
+
|
|
623
|
+
repo = _resolve_repo(None, repository)
|
|
624
|
+
from codegraph.runtime.runner import run_with_telemetry
|
|
625
|
+
|
|
626
|
+
exit_code = run_with_telemetry(full_cmd, repository=repo, sample_rate=sample_rate)
|
|
627
|
+
raise typer.Exit(code=exit_code)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _run_server(
|
|
631
|
+
path: Path | None,
|
|
632
|
+
repository: Path | None,
|
|
633
|
+
profile: str,
|
|
634
|
+
transport: str = "stdio",
|
|
635
|
+
host: str = "127.0.0.1",
|
|
636
|
+
port: int = 8765,
|
|
605
637
|
) -> None:
|
|
606
|
-
"""Run the stdio MCP server (requires the optional mcp extra)."""
|
|
607
638
|
repo = _resolve_repo(path, repository)
|
|
608
639
|
from codegraph.mcp import create_server
|
|
609
640
|
from codegraph.process_lifecycle import run_mcp_stdio_server
|
|
610
641
|
|
|
611
|
-
|
|
612
|
-
|
|
642
|
+
if transport.lower() == "sse":
|
|
643
|
+
server = create_server(repo, profile=profile, host=host, port=port)
|
|
644
|
+
server.run(transport="sse")
|
|
645
|
+
else:
|
|
646
|
+
server = create_server(repo, profile=profile)
|
|
647
|
+
run_mcp_stdio_server(server, repo, profile=profile)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
@app.command()
|
|
651
|
+
def serve(
|
|
652
|
+
path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
|
|
653
|
+
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
654
|
+
profile: Annotated[str, typer.Option("--profile", help="Tool profile: core | minimal | developer | full")] = "full",
|
|
655
|
+
transport: Annotated[str, typer.Option("--transport", "-t", help="MCP transport: stdio | sse")] = "stdio",
|
|
656
|
+
host: Annotated[str, typer.Option("--host", help="Host for SSE server")] = "127.0.0.1",
|
|
657
|
+
port: Annotated[int, typer.Option("--port", "-p", help="Port for SSE server")] = 8765,
|
|
658
|
+
) -> None:
|
|
659
|
+
"""Run the CodeGraph MCP server via stdio or SSE transport."""
|
|
660
|
+
_run_server(path, repository, profile, transport=transport, host=host, port=port)
|
|
613
661
|
|
|
614
662
|
|
|
615
663
|
mcp_app = typer.Typer(help="MCP server commands.")
|
|
@@ -621,14 +669,12 @@ def mcp_serve(
|
|
|
621
669
|
path: Annotated[Path | None, typer.Argument(help="Repository path (default: current directory)")] = None,
|
|
622
670
|
repository: Annotated[Path | None, typer.Option("--repository", "-r", "--repo", help="Repository path")] = None,
|
|
623
671
|
profile: Annotated[str, typer.Option("--profile", help="Tool profile: core | graph | minimal | developer | full")] = "full",
|
|
672
|
+
transport: Annotated[str, typer.Option("--transport", "-t", help="MCP transport: stdio | sse")] = "stdio",
|
|
673
|
+
host: Annotated[str, typer.Option("--host", help="Host for SSE server")] = "127.0.0.1",
|
|
674
|
+
port: Annotated[int, typer.Option("--port", "-p", help="Port for SSE server")] = 8765,
|
|
624
675
|
) -> None:
|
|
625
|
-
"""Run the
|
|
626
|
-
|
|
627
|
-
from codegraph.mcp import create_server
|
|
628
|
-
from codegraph.process_lifecycle import run_mcp_stdio_server
|
|
629
|
-
|
|
630
|
-
server = create_server(repo, profile=profile)
|
|
631
|
-
run_mcp_stdio_server(server, repo, profile=profile)
|
|
676
|
+
"""Run the CodeGraph MCP server via stdio or SSE transport."""
|
|
677
|
+
_run_server(path, repository, profile, transport=transport, host=host, port=port)
|
|
632
678
|
|
|
633
679
|
|
|
634
680
|
def _execute_stop(
|
codegraph/context.py
CHANGED
|
@@ -594,6 +594,7 @@ def _get_context_impl(
|
|
|
594
594
|
if path:
|
|
595
595
|
c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
596
596
|
c.row_factory = sqlite3.Row
|
|
597
|
+
c.execute("PRAGMA busy_timeout = 15000")
|
|
597
598
|
try:
|
|
598
599
|
for query in queries:
|
|
599
600
|
for item in search(
|
|
@@ -629,6 +630,7 @@ def _get_context_impl(
|
|
|
629
630
|
if path:
|
|
630
631
|
c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
631
632
|
c.row_factory = sqlite3.Row
|
|
633
|
+
c.execute("PRAGMA busy_timeout = 15000")
|
|
632
634
|
try:
|
|
633
635
|
return c.execute(
|
|
634
636
|
"SELECT route_path, http_method, handler_name, file_path, line, endpoint_id FROM framework_routes"
|
|
@@ -665,6 +667,7 @@ def _get_context_impl(
|
|
|
665
667
|
if path:
|
|
666
668
|
c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
667
669
|
c.row_factory = sqlite3.Row
|
|
670
|
+
c.execute("PRAGMA busy_timeout = 15000")
|
|
668
671
|
try:
|
|
669
672
|
archs.append(get_architecture(c, repo))
|
|
670
673
|
finally:
|
|
@@ -748,6 +748,8 @@ def get_db_schema(
|
|
|
748
748
|
schema: str | None = None,
|
|
749
749
|
*,
|
|
750
750
|
dialect: str | None = None,
|
|
751
|
+
limit: int | None = None,
|
|
752
|
+
offset: int = 0,
|
|
751
753
|
) -> dict[str, Any]:
|
|
752
754
|
"""Return the complete repository database schema overview with safe credential redaction."""
|
|
753
755
|
if isinstance(repo_or_dialect, str) and not dialect:
|
|
@@ -800,21 +802,42 @@ def get_db_schema(
|
|
|
800
802
|
|
|
801
803
|
dialects = sorted({t["dialect"] for t in tables if t.get("dialect")}) or ["UNKNOWN"]
|
|
802
804
|
|
|
803
|
-
|
|
805
|
+
safe_tables = tables
|
|
806
|
+
safe_models = models
|
|
807
|
+
safe_migrations = migrations
|
|
808
|
+
safe_cols = columns
|
|
809
|
+
safe_rels = rels
|
|
810
|
+
|
|
811
|
+
if limit is not None:
|
|
812
|
+
safe_offset = max(0, offset)
|
|
813
|
+
safe_limit = max(0, limit)
|
|
814
|
+
safe_tables = tables[safe_offset : safe_offset + safe_limit]
|
|
815
|
+
safe_models = models[safe_offset : safe_offset + safe_limit]
|
|
816
|
+
safe_migrations = migrations[safe_offset : safe_offset + safe_limit]
|
|
817
|
+
safe_cols = columns[: max(safe_limit * 5, 20)]
|
|
818
|
+
safe_rels = rels[: max(safe_limit * 5, 20)]
|
|
819
|
+
|
|
820
|
+
result_dict: dict[str, Any] = {
|
|
804
821
|
"status": "ok",
|
|
805
822
|
"dialects": dialects,
|
|
806
823
|
"table_count": len(tables),
|
|
807
824
|
"column_count": len(columns),
|
|
808
825
|
"model_count": len(models),
|
|
809
826
|
"migration_count": len(migrations),
|
|
810
|
-
"tables":
|
|
811
|
-
"columns":
|
|
812
|
-
"models":
|
|
813
|
-
"relationships":
|
|
814
|
-
"migrations":
|
|
827
|
+
"tables": safe_tables,
|
|
828
|
+
"columns": safe_cols,
|
|
829
|
+
"models": safe_models,
|
|
830
|
+
"relationships": safe_rels,
|
|
831
|
+
"migrations": safe_migrations,
|
|
815
832
|
"env_variables": env_vars,
|
|
816
833
|
"database_credentials": "REDACTED",
|
|
817
|
-
}
|
|
834
|
+
}
|
|
835
|
+
if limit is not None:
|
|
836
|
+
result_dict["limit"] = safe_limit
|
|
837
|
+
result_dict["offset"] = safe_offset
|
|
838
|
+
result_dict["has_more"] = (safe_offset + len(safe_tables)) < len(tables)
|
|
839
|
+
|
|
840
|
+
return redact_payload(result_dict)
|
|
818
841
|
|
|
819
842
|
|
|
820
843
|
def get_db_impact(
|
codegraph/freshness.py
CHANGED
|
@@ -235,30 +235,68 @@ def check_freshness(
|
|
|
235
235
|
deleted: list[str] = []
|
|
236
236
|
parse_failed: list[str] = []
|
|
237
237
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
238
|
+
# Fast path 1: Git commit diff (branch switch or commit advance)
|
|
239
|
+
git_diff_handled = False
|
|
240
|
+
if cur_commit and idx_commit and cur_commit != idx_commit:
|
|
241
|
+
diff_out = _run_git(repository, "diff", "--name-status", idx_commit, cur_commit)
|
|
242
|
+
if diff_out is not None:
|
|
243
|
+
git_diff_handled = True
|
|
244
|
+
for line in diff_out.splitlines():
|
|
245
|
+
parts = line.strip().split(maxsplit=1)
|
|
246
|
+
if len(parts) == 2:
|
|
247
|
+
action, fpath = parts[0], parts[1]
|
|
248
|
+
if action.startswith("D"):
|
|
249
|
+
deleted.append(fpath)
|
|
250
|
+
else:
|
|
251
|
+
modified.append(fpath)
|
|
252
|
+
|
|
253
|
+
# Fast path 2: Git status when HEAD commit is unchanged
|
|
254
|
+
if not git_diff_handled and cur_commit and idx_commit and cur_commit == idx_commit:
|
|
255
|
+
status_out = _run_git(repository, "status", "--porcelain", "--untracked-files=no")
|
|
256
|
+
if status_out is not None and not status_out.strip():
|
|
257
|
+
# Working tree has zero tracked changes against HEAD
|
|
258
|
+
git_diff_handled = True
|
|
253
259
|
try:
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
+
pf_rows = con.execute("SELECT path FROM files WHERE status='parse_failed'").fetchall()
|
|
261
|
+
for r in pf_rows:
|
|
262
|
+
parse_failed.append(str(r[0]))
|
|
263
|
+
modified.append(str(r[0]))
|
|
264
|
+
except sqlite3.OperationalError:
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
if not git_diff_handled:
|
|
268
|
+
try:
|
|
269
|
+
rows = con.execute("SELECT path, hash, status, indexed_at FROM files").fetchall()
|
|
270
|
+
except sqlite3.OperationalError:
|
|
271
|
+
try:
|
|
272
|
+
rows = con.execute("SELECT path, hash, status, 0 AS indexed_at FROM files").fetchall()
|
|
273
|
+
except sqlite3.OperationalError:
|
|
274
|
+
rows = con.execute("SELECT path, hash, 'ok' AS status, 0 AS indexed_at FROM files").fetchall()
|
|
275
|
+
|
|
276
|
+
for row in rows:
|
|
277
|
+
rel = str(row["path"])
|
|
278
|
+
if row["status"] == "parse_failed":
|
|
279
|
+
parse_failed.append(rel)
|
|
260
280
|
if rel not in modified:
|
|
261
281
|
modified.append(rel)
|
|
282
|
+
abs_path = repository / rel
|
|
283
|
+
if not abs_path.exists():
|
|
284
|
+
deleted.append(rel)
|
|
285
|
+
else:
|
|
286
|
+
try:
|
|
287
|
+
st = abs_path.stat()
|
|
288
|
+
# Mtime check: skip reading & hashing files strictly older than indexing time
|
|
289
|
+
idx_at = int(row["indexed_at"] or 0)
|
|
290
|
+
if idx_at > 0 and int(st.st_mtime) < idx_at and row["status"] != "parse_failed":
|
|
291
|
+
continue
|
|
292
|
+
digest = hashlib.sha256(
|
|
293
|
+
abs_path.read_text(encoding="utf-8", errors="replace").encode()
|
|
294
|
+
).hexdigest()
|
|
295
|
+
if digest != row["hash"] and rel not in modified:
|
|
296
|
+
modified.append(rel)
|
|
297
|
+
except OSError:
|
|
298
|
+
if rel not in modified:
|
|
299
|
+
modified.append(rel)
|
|
262
300
|
|
|
263
301
|
changed = len(modified) + len(deleted)
|
|
264
302
|
if changed == 0 and not parse_failed and (idx_commit == cur_commit or cur_commit is None):
|
codegraph/indexing/indexer.py
CHANGED
|
@@ -493,8 +493,11 @@ class Indexer:
|
|
|
493
493
|
con = sqlite3.connect(self.db_path)
|
|
494
494
|
con.row_factory = make_compat_row
|
|
495
495
|
con.execute("PRAGMA journal_mode = WAL")
|
|
496
|
+
con.execute("PRAGMA synchronous = NORMAL")
|
|
496
497
|
con.execute("PRAGMA foreign_keys = ON")
|
|
497
|
-
con.execute("PRAGMA busy_timeout =
|
|
498
|
+
con.execute("PRAGMA busy_timeout = 15000")
|
|
499
|
+
con.execute("PRAGMA cache_size = -64000")
|
|
500
|
+
con.execute("PRAGMA temp_store = MEMORY")
|
|
498
501
|
self._ensure_schema(con)
|
|
499
502
|
return con
|
|
500
503
|
|
codegraph/indexing/parser.py
CHANGED
|
@@ -597,29 +597,63 @@ class _PythonScopeVisitor(ast.NodeVisitor):
|
|
|
597
597
|
doc = ast.get_docstring(node)
|
|
598
598
|
c_hash = _normalized_body_hash(self.lines, node.lineno, end_line)
|
|
599
599
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
600
|
+
# Avoid duplicate canonical symbol IDs for @name.setter within the same class
|
|
601
|
+
is_setter = any(d.endswith(".setter") for d in decorators)
|
|
602
|
+
existing_prop = None
|
|
603
|
+
if is_setter and parent_kind == "class":
|
|
604
|
+
for s in self.symbols:
|
|
605
|
+
if s.canonical_id == canon_id and s.kind == "property":
|
|
606
|
+
existing_prop = s
|
|
607
|
+
break
|
|
608
|
+
|
|
609
|
+
if existing_prop is not None:
|
|
610
|
+
# Merge setter signature/span into the existing property symbol
|
|
611
|
+
idx = self.symbols.index(existing_prop)
|
|
612
|
+
self.symbols[idx] = Symbol(
|
|
613
|
+
id=existing_prop.id,
|
|
614
|
+
canonical_id=existing_prop.canonical_id,
|
|
615
|
+
name=existing_prop.name,
|
|
616
|
+
qualified_name=existing_prop.qualified_name,
|
|
617
|
+
kind="property",
|
|
618
|
+
language=existing_prop.language,
|
|
619
|
+
module=existing_prop.module,
|
|
620
|
+
path=existing_prop.path,
|
|
621
|
+
file_path=existing_prop.file_path,
|
|
622
|
+
scope=existing_prop.scope,
|
|
623
|
+
signature=f"{existing_prop.signature} | setter: {sig}" if existing_prop.signature else sig,
|
|
624
|
+
start_line=existing_prop.start_line,
|
|
625
|
+
end_line=max(existing_prop.end_line, end_line),
|
|
615
626
|
content_hash=c_hash,
|
|
616
|
-
parent_symbol_id=
|
|
617
|
-
decorators=decorators,
|
|
618
|
-
return_type=
|
|
619
|
-
parameter_count=
|
|
620
|
-
documentation=doc,
|
|
627
|
+
parent_symbol_id=existing_prop.parent_symbol_id,
|
|
628
|
+
decorators=list(dict.fromkeys(existing_prop.decorators + decorators)),
|
|
629
|
+
return_type=existing_prop.return_type,
|
|
630
|
+
parameter_count=existing_prop.parameter_count,
|
|
631
|
+
documentation=existing_prop.documentation or doc,
|
|
632
|
+
)
|
|
633
|
+
else:
|
|
634
|
+
self.symbols.append(
|
|
635
|
+
Symbol(
|
|
636
|
+
id=canon_id,
|
|
637
|
+
canonical_id=canon_id,
|
|
638
|
+
name=node.name,
|
|
639
|
+
qualified_name=qname,
|
|
640
|
+
kind=kind,
|
|
641
|
+
language="python",
|
|
642
|
+
module=self.module,
|
|
643
|
+
path=self.file_path,
|
|
644
|
+
file_path=self.file_path,
|
|
645
|
+
scope=parent_scope,
|
|
646
|
+
signature=sig,
|
|
647
|
+
start_line=node.lineno,
|
|
648
|
+
end_line=end_line,
|
|
649
|
+
content_hash=c_hash,
|
|
650
|
+
parent_symbol_id=parent_canon,
|
|
651
|
+
decorators=decorators,
|
|
652
|
+
return_type=ret_type,
|
|
653
|
+
parameter_count=param_count,
|
|
654
|
+
documentation=doc,
|
|
655
|
+
)
|
|
621
656
|
)
|
|
622
|
-
)
|
|
623
657
|
|
|
624
658
|
# Framework analyzer hook: check function/class for route decorators during this traversal
|
|
625
659
|
if node.decorator_list or isinstance(node, ast.ClassDef):
|
codegraph/interrogation.py
CHANGED
|
@@ -1979,6 +1979,8 @@ def list_routes(
|
|
|
1979
1979
|
framework: str | None = None,
|
|
1980
1980
|
method: str | None = None,
|
|
1981
1981
|
path: str | None = None,
|
|
1982
|
+
limit: int | None = None,
|
|
1983
|
+
offset: int = 0,
|
|
1982
1984
|
) -> dict[str, Any]:
|
|
1983
1985
|
"""Expose application routes discovered from the repository."""
|
|
1984
1986
|
unindexed = check_index_available(con, repository)
|
|
@@ -2028,10 +2030,27 @@ def list_routes(
|
|
|
2028
2030
|
for r in rows
|
|
2029
2031
|
]
|
|
2030
2032
|
|
|
2033
|
+
total_count = len(routes)
|
|
2034
|
+
if limit is not None:
|
|
2035
|
+
safe_offset = max(0, offset)
|
|
2036
|
+
safe_limit = max(0, limit)
|
|
2037
|
+
paginated_routes = routes[safe_offset : safe_offset + safe_limit]
|
|
2038
|
+
return {
|
|
2039
|
+
"status": "ok",
|
|
2040
|
+
**meta,
|
|
2041
|
+
"count": len(paginated_routes),
|
|
2042
|
+
"total_count": total_count,
|
|
2043
|
+
"limit": safe_limit,
|
|
2044
|
+
"offset": safe_offset,
|
|
2045
|
+
"has_more": (safe_offset + len(paginated_routes)) < total_count,
|
|
2046
|
+
"routes": paginated_routes,
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2031
2049
|
return {
|
|
2032
2050
|
"status": "ok",
|
|
2033
2051
|
**meta,
|
|
2034
2052
|
"count": len(routes),
|
|
2053
|
+
"total_count": total_count,
|
|
2035
2054
|
"routes": routes,
|
|
2036
2055
|
}
|
|
2037
2056
|
|
codegraph/mcp/server.py
CHANGED
|
@@ -173,6 +173,8 @@ def create_server(
|
|
|
173
173
|
repository: Path,
|
|
174
174
|
settings: Settings | None = None,
|
|
175
175
|
profile: str = "full",
|
|
176
|
+
host: str = "127.0.0.1",
|
|
177
|
+
port: int = 8765,
|
|
176
178
|
) -> Any:
|
|
177
179
|
"""Build an MCP FastMCP server lazily so normal CLI use needs no MCP dependency."""
|
|
178
180
|
try:
|
|
@@ -184,7 +186,12 @@ def create_server(
|
|
|
184
186
|
|
|
185
187
|
indexer = Indexer(repository, settings)
|
|
186
188
|
memory = MemoryStore(indexer.db_path)
|
|
187
|
-
app = FastMCP(
|
|
189
|
+
app = FastMCP(
|
|
190
|
+
"CodeGraph MCP",
|
|
191
|
+
instructions=render_agent_rules("antigravity"),
|
|
192
|
+
host=host,
|
|
193
|
+
port=port,
|
|
194
|
+
)
|
|
188
195
|
max_read_bytes = indexer.settings.max_read_bytes
|
|
189
196
|
|
|
190
197
|
# -----------------------------------------------------------------------
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Zero-Friction Development Server Runtime Interceptor (v2.2.0).
|
|
2
|
+
|
|
3
|
+
Enables 1-line development telemetry capture:
|
|
4
|
+
codegraph run npm run dev
|
|
5
|
+
codegraph run uvicorn main:app
|
|
6
|
+
codegraph run python app.py
|
|
7
|
+
|
|
8
|
+
Security & Reliability Invariants:
|
|
9
|
+
- Passes stdin/stdout/stderr directly to the user's terminal with zero distortion.
|
|
10
|
+
- Strips authorization headers, cookies, and sensitive credentials via existing redaction rules.
|
|
11
|
+
- Streams captured HTTP requests and uncaught exceptions directly into `.codegraph/runtime.sqlite3`
|
|
12
|
+
without requiring code changes to the user's application.
|
|
13
|
+
- Cleans up child processes and temporary interceptor hooks on exit.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from codegraph.indexing import Indexer
|
|
25
|
+
from codegraph.runtime.ingestor import ingest_runtime_traces
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _generate_node_interceptor(output_jsonl: Path) -> str:
|
|
29
|
+
"""Generate lightweight Node.js HTTP/Express/Fastify request interceptor."""
|
|
30
|
+
escaped_log_path = json.dumps(str(output_jsonl))
|
|
31
|
+
return f"""// CodeGraph Node.js Runtime Interceptor
|
|
32
|
+
const http = require('http');
|
|
33
|
+
const https = require('https');
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
|
|
36
|
+
const logPath = {escaped_log_path};
|
|
37
|
+
|
|
38
|
+
function emitEvent(event) {{
|
|
39
|
+
try {{
|
|
40
|
+
fs.appendFileSync(logPath, JSON.stringify(event) + '\\n');
|
|
41
|
+
}} catch (_) {{}}
|
|
42
|
+
}}
|
|
43
|
+
|
|
44
|
+
function hookServer(serverProto) {{
|
|
45
|
+
const origEmit = serverProto.emit;
|
|
46
|
+
serverProto.emit = function(event, req, res) {{
|
|
47
|
+
if (event === 'request' && req && res) {{
|
|
48
|
+
const startTime = Date.now();
|
|
49
|
+
const method = req.method || 'GET';
|
|
50
|
+
const url = req.url ? req.url.split('?')[0] : '/';
|
|
51
|
+
|
|
52
|
+
res.on('finish', () => {{
|
|
53
|
+
const duration = Date.now() - startTime;
|
|
54
|
+
emitEvent({{
|
|
55
|
+
trace_id: 'tr_' + Math.random().toString(36).substring(2, 10),
|
|
56
|
+
span_id: 'sp_' + Math.random().toString(36).substring(2, 10),
|
|
57
|
+
timestamp: new Date().toISOString(),
|
|
58
|
+
http_method: method,
|
|
59
|
+
route_path: url,
|
|
60
|
+
status_code: res.statusCode,
|
|
61
|
+
duration_ms: duration,
|
|
62
|
+
service_name: 'node_dev_server',
|
|
63
|
+
source_format: 'json'
|
|
64
|
+
}});
|
|
65
|
+
}});
|
|
66
|
+
}}
|
|
67
|
+
return origEmit.apply(this, arguments);
|
|
68
|
+
}};
|
|
69
|
+
}}
|
|
70
|
+
|
|
71
|
+
try {{
|
|
72
|
+
hookServer(http.Server.prototype);
|
|
73
|
+
}} catch (_) {{}}
|
|
74
|
+
|
|
75
|
+
process.on('uncaughtExceptionMonitor', (err) => {{
|
|
76
|
+
emitEvent({{
|
|
77
|
+
trace_id: 'err_' + Math.random().toString(36).substring(2, 10),
|
|
78
|
+
span_id: 'sp_err',
|
|
79
|
+
timestamp: new Date().toISOString(),
|
|
80
|
+
exception_type: (err && err.name) ? err.name : 'UncaughtException',
|
|
81
|
+
service_name: 'node_dev_server',
|
|
82
|
+
status_code: 500,
|
|
83
|
+
source_format: 'json'
|
|
84
|
+
}});
|
|
85
|
+
}});
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _generate_python_interceptor(output_jsonl: Path) -> str:
|
|
90
|
+
"""Generate lightweight Python WSGI/ASGI/FastAPI request interceptor."""
|
|
91
|
+
escaped_log_path = repr(str(output_jsonl))
|
|
92
|
+
return f"""# CodeGraph Python Runtime Interceptor
|
|
93
|
+
import sys
|
|
94
|
+
import json
|
|
95
|
+
import time
|
|
96
|
+
|
|
97
|
+
LOG_PATH = {escaped_log_path}
|
|
98
|
+
|
|
99
|
+
def emit_event(event):
|
|
100
|
+
try:
|
|
101
|
+
with open(LOG_PATH, "a", encoding="utf-8") as f:
|
|
102
|
+
f.write(json.dumps(event) + "\\n")
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
# Intercept uncaught exceptions
|
|
107
|
+
_orig_excepthook = sys.excepthook
|
|
108
|
+
def _cg_excepthook(exc_type, exc_value, exc_traceback):
|
|
109
|
+
emit_event({{
|
|
110
|
+
"trace_id": "err_" + str(int(time.time())),
|
|
111
|
+
"span_id": "sp_err",
|
|
112
|
+
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
113
|
+
"exception_type": getattr(exc_type, "__name__", "Exception"),
|
|
114
|
+
"service_name": "python_dev_server",
|
|
115
|
+
"status_code": 500,
|
|
116
|
+
"source_format": "json"
|
|
117
|
+
}})
|
|
118
|
+
return _orig_excepthook(exc_type, exc_value, exc_traceback)
|
|
119
|
+
|
|
120
|
+
sys.excepthook = _cg_excepthook
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run_with_telemetry(
|
|
125
|
+
command: list[str],
|
|
126
|
+
repository: Path,
|
|
127
|
+
sample_rate: float = 1.0,
|
|
128
|
+
) -> int:
|
|
129
|
+
"""Execute a development command with transparent runtime trace capture."""
|
|
130
|
+
if not command:
|
|
131
|
+
raise ValueError("Command cannot be empty.")
|
|
132
|
+
|
|
133
|
+
hooks_dir = repository / ".codegraph" / "runtime_hooks"
|
|
134
|
+
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
trace_file = hooks_dir / "active_traces.jsonl"
|
|
136
|
+
# Ensure fresh trace buffer
|
|
137
|
+
if trace_file.exists():
|
|
138
|
+
try:
|
|
139
|
+
trace_file.unlink()
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
node_hook = hooks_dir / "cg_node_hook.js"
|
|
144
|
+
node_hook.write_text(_generate_node_interceptor(trace_file), encoding="utf-8")
|
|
145
|
+
|
|
146
|
+
python_hook = hooks_dir / "cg_python_hook.py"
|
|
147
|
+
python_hook.write_text(_generate_python_interceptor(trace_file), encoding="utf-8")
|
|
148
|
+
|
|
149
|
+
env = dict(os.environ)
|
|
150
|
+
|
|
151
|
+
# Inject Node hook
|
|
152
|
+
old_node_opts = env.get("NODE_OPTIONS", "")
|
|
153
|
+
env["NODE_OPTIONS"] = f"--require {node_hook} {old_node_opts}".strip()
|
|
154
|
+
|
|
155
|
+
# Inject Python hook
|
|
156
|
+
old_py_start = env.get("PYTHONSTARTUP", "")
|
|
157
|
+
if not old_py_start:
|
|
158
|
+
env["PYTHONSTARTUP"] = str(python_hook)
|
|
159
|
+
|
|
160
|
+
# Background ingestion thread
|
|
161
|
+
stop_event = threading.Event()
|
|
162
|
+
indexer = Indexer(repository)
|
|
163
|
+
|
|
164
|
+
def _ingest_loop() -> None:
|
|
165
|
+
last_pos = 0
|
|
166
|
+
while not stop_event.is_set():
|
|
167
|
+
time.sleep(1.0)
|
|
168
|
+
if not trace_file.exists():
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
size = trace_file.stat().st_size
|
|
172
|
+
if size > last_pos:
|
|
173
|
+
with open(trace_file, encoding="utf-8", errors="replace") as f:
|
|
174
|
+
f.seek(last_pos)
|
|
175
|
+
new_content = f.read()
|
|
176
|
+
last_pos = f.tell()
|
|
177
|
+
if new_content.strip():
|
|
178
|
+
with indexer.session() as con:
|
|
179
|
+
ingest_runtime_traces(con, new_content, repository=repository, max_events=1000, sampling_rate=sample_rate)
|
|
180
|
+
except Exception:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
ingest_worker = threading.Thread(target=_ingest_loop, daemon=True)
|
|
184
|
+
ingest_worker.start()
|
|
185
|
+
|
|
186
|
+
# Spawn interactive dev process
|
|
187
|
+
try:
|
|
188
|
+
proc = subprocess.Popen(command, cwd=str(repository), env=env)
|
|
189
|
+
returncode = proc.wait()
|
|
190
|
+
except KeyboardInterrupt:
|
|
191
|
+
returncode = 0
|
|
192
|
+
finally:
|
|
193
|
+
stop_event.set()
|
|
194
|
+
# Drain remaining lines on shutdown
|
|
195
|
+
if trace_file.exists():
|
|
196
|
+
try:
|
|
197
|
+
content = trace_file.read_text(encoding="utf-8", errors="replace")
|
|
198
|
+
if content.strip():
|
|
199
|
+
with indexer.session() as con:
|
|
200
|
+
ingest_runtime_traces(con, content, repository=repository, max_events=1000, sampling_rate=sample_rate)
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
return returncode
|