cg-code-graph 0.10.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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- codegraph/xcode.py +251 -0
codegraph/mcp_server.py
ADDED
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
"""code-graph MCP server (stdio). Exposes the SQLite graph to agents with compact, token-efficient output.
|
|
2
|
+
|
|
3
|
+
Run: .venv/bin/python -m codegraph.mcp_server --db out/graph.db [--root path/to/project --gates path/to/gates.json] [--plans plans/]
|
|
4
|
+
|
|
5
|
+
Tools: reaches, impact, callers, siblings, writers, readers, roundtrip, lint_async_state, routes, node, search, stats, starters, index, downstream, path,
|
|
6
|
+
api_calls, resolutions, channels, bridges, protocol_links, llm_tools, external_systems, tests_covering, coverage, platforms, platform_divergence, plan_list, plan_load, plan_validate, plan_check, plan_baseline (planned-change layer,
|
|
7
|
+
plans/<name>.yaml).
|
|
8
|
+
Point --db at a combined graph (codegraph.cli link ...) to query across repos (frontend pages -> backend routes -> tables).
|
|
9
|
+
All results are plain text: grouped by module / entry-point kind, one line per item, each with the
|
|
10
|
+
shortest evidence path (KIND@file:line hops). Every edge comes from parsers and static rules, so the same graph always
|
|
11
|
+
gives the same answer. Paths in replies are repo-relative.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import contextvars
|
|
17
|
+
import functools
|
|
18
|
+
import inspect
|
|
19
|
+
import sqlite3
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import threading
|
|
23
|
+
from collections import defaultdict
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Annotated, Any
|
|
26
|
+
|
|
27
|
+
from mcp.server.mcpserver import MCPServer
|
|
28
|
+
from mcp_types import CallToolResult, TextContent
|
|
29
|
+
from typing_extensions import NotRequired, TypedDict
|
|
30
|
+
|
|
31
|
+
from .core.store import GraphStore
|
|
32
|
+
from . import query as Q
|
|
33
|
+
|
|
34
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
35
|
+
STATE = {"db": str(ROOT / "out" / "graph.db"), "root": None, "gates": None, "plans": None, "lock": threading.Lock()}
|
|
36
|
+
|
|
37
|
+
server = MCPServer(
|
|
38
|
+
name="code-graph",
|
|
39
|
+
instructions=(
|
|
40
|
+
"Code graph of a project: classes, functions, routes, commands, jobs, pages, DB tables/columns, connections, "
|
|
41
|
+
"config/env keys, each edge with file:line evidence and a confidence (exact / resolved / heuristic). Stacks: "
|
|
42
|
+
"Laravel, Django (django-ninja, DRF), FastAPI/Starlette, Flask, NestJS, Next.js, Express/Fastify/Koa/Hono, Nuxt/Vue, Flutter/Dart, Rust, C, C++. "
|
|
43
|
+
"Check the blast radius before editing: `reaches` lists everything that depends on a symbol/column/connection "
|
|
44
|
+
"(grouped runtime / library / operator / UI / dev / gated), `impact` gives callers up to entry points, `siblings` "
|
|
45
|
+
"finds parallel code that usually needs the same change, `writers` shows who writes a table, `routes` lists routes "
|
|
46
|
+
"with their middleware / guards / auth (filter to routes reaching a write, a table or any target, and to routes "
|
|
47
|
+
"missing a given middleware or any auth), `node` / `search` look things up (search also matches middleware and "
|
|
48
|
+
"guard names). Specs: table.column | table:<t> | connection:<name|glob*> | env:<KEY> | config:<a.b> | "
|
|
49
|
+
"Class.method or Class::method (either separator in every language) | Class | pkg.module.func (Python) | file#name (TS/JS: src/app.ts#listOrders, "
|
|
50
|
+
"svc.ts#OrderService.create; the file part may be a path suffix). "
|
|
51
|
+
"On a combined graph (backend + frontend) also: page:/route/path | app/pages/x.vue | useComposable.fn | "
|
|
52
|
+
"route:<METHOD> <uri>; `downstream` follows a page/component forward to backend routes and tables, `path` gives "
|
|
53
|
+
"one evidence chain (and flags keys a call site passes that the request never sends), `api_calls` lists frontend "
|
|
54
|
+
"HTTP calls with their matched backend routes. "
|
|
55
|
+
"Rust / C / C++: specs are paths like crate::module::Type::method, <Type as Trait>::method, ns::Class::method, a "
|
|
56
|
+
"bare function name, a file (src/x.rs, src/x.c), env:<KEY>, unsafe:<crate>, feature:<pkg>/<feature>, "
|
|
57
|
+
"cfg:<atom>, define:<MACRO>; entry kinds are main / ffi_export (RUNTIME), public_api (LIBRARY) and test / bench / "
|
|
58
|
+
"example / build_script (DEV); trait/virtual dispatch hops are IMPLEMENTED_BY / OVERRIDDEN_BY. "
|
|
59
|
+
"`resolutions` lists every place a concept (e.g. timezone) is resolved from request input / settings / columns / "
|
|
60
|
+
"literal fallbacks, groups them into fallback chains, shows where chains diverge, which routes reach each, and "
|
|
61
|
+
"whether the frontend sends the key (plus client-side fallbacks and keys a helper drops before the request). "
|
|
62
|
+
"Realtime: `channels` lists broadcast channels (Laravel Broadcast::channel) with who can join (auth route + "
|
|
63
|
+
"middleware, the callback and the checks it calls), which events publish on each (broadcastOn, dispatch sites, "
|
|
64
|
+
"entry points) and, on a combined graph, which client code / pages subscribe (Echo / pusher-js) and listen for "
|
|
65
|
+
"which events; spec channel:<pattern>. "
|
|
66
|
+
"Tests: test code (tests/, *.spec.ts / *.test.ts, e2e specs) is indexed as `test` nodes kept out of every other "
|
|
67
|
+
"query (TEST_* edges never propagate); `tests_covering` lists the tests that exercise a symbol / route / table, "
|
|
68
|
+
"direct (the test calls or requests it) and transitive (through application code). "
|
|
69
|
+
"Platforms: code under a platform condition (#[cfg(windows)], cfg!, #ifdef _WIN32, Platform.OS / Platform.select, "
|
|
70
|
+
"Platform.isIOS / kIsWeb, .ios.ts / .android.ts / .native.ts files, Dart conditional imports) is tagged with "
|
|
71
|
+
"the targets it is built for (`[ios, android]` after a symbol); pass platform=\"ios\" (windows, linux, macos, ios, "
|
|
72
|
+
"android, web) to reaches / impact / downstream / path / routes / search to see that target's build only. "
|
|
73
|
+
"`platforms` lists the targets and tagged code, `platform_divergence` the gaps: a target no variant covers, "
|
|
74
|
+
"API differences between variants, calls into code that is not built on a target. "
|
|
75
|
+
"Bridges: web / native bridge calls (Capacitor plugins, React Native / Expo modules, Flutter platform channels) go "
|
|
76
|
+
"JS / Dart call -SENDS_TO-> endpoint:<protocol>:<module>#<method> -RECEIVED_BY-> Kotlin / Java / Swift / ObjC "
|
|
77
|
+
"method (each receiver tagged with its platform), so impact / reaches cross the bridge; `bridges` lists them with "
|
|
78
|
+
"methods missing on a platform, unreceived sends and external modules. "
|
|
79
|
+
"When a query finds nothing, the reply says why and which query to run instead. "
|
|
80
|
+
"`starters` lists first questions derived from this graph (unguarded write routes, most-reached tables, "
|
|
81
|
+
"most-called functions), each with the call to run. "
|
|
82
|
+
"COVERAGE: `coverage` says which languages / files the index covers (exact, heuristic only, skipped because an "
|
|
83
|
+
"indexer is missing, or unsupported, e.g. Go/Java/Kotlin/Swift/QML/shell files), how many files of each language "
|
|
84
|
+
"are indexed (parse failures, unmapped files) and the blind spots: route / handler registrations cg does not "
|
|
85
|
+
"model, with file:line. Partial answers end with a `coverage note:` line, and every reply's structured content "
|
|
86
|
+
"has a `completeness` object (complete: true/false). For anything not covered, heuristic only or at a blind spot, "
|
|
87
|
+
"fall back to your normal search and file reading: an empty cg answer there is not proof of absence. "
|
|
88
|
+
"`callers` lists direct callers (one level); `impact` follows them to entry points. "
|
|
89
|
+
"PLANS: an agreed change scope lives in plans/<name>.yaml (add_nodes / modify / add_edges / forbid / require, "
|
|
90
|
+
"issue links) and overlays the graph without changing it. Workflow: write the plan -> `plan_check` (compact "
|
|
91
|
+
"summary with counts and the top items; details=true for every item with file:line, forbidden paths and open "
|
|
92
|
+
"findings) -> refine the plan -> `plan_baseline` -> implement -> `index` -> `plan_check(verify=true)` (planned "
|
|
93
|
+
"nodes/edges exist, forbidden paths gone or guarded, modified targets changed)."),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _rel_text(txt: str) -> str:
|
|
98
|
+
"""Make every absolute path in a reply repo-relative: first the working directory / this checkout, then the indexed
|
|
99
|
+
repo roots (whose files the combined graph already prefixes with the repo name) and the DB directory."""
|
|
100
|
+
if not isinstance(txt, str):
|
|
101
|
+
return txt
|
|
102
|
+
first = {str(Path.cwd()), str(ROOT)}
|
|
103
|
+
other = set()
|
|
104
|
+
for k in ("root", "plans"):
|
|
105
|
+
if STATE.get(k):
|
|
106
|
+
other.add(str(Path(STATE[k]).resolve().parent))
|
|
107
|
+
other.add(str(Path(STATE["db"]).resolve().parent))
|
|
108
|
+
try:
|
|
109
|
+
m = GraphStore(STATE["db"]).meta()
|
|
110
|
+
for r in [m.get("root")] + list((m.get("sources") or {}).values()):
|
|
111
|
+
if r:
|
|
112
|
+
other.add(str(Path(r).resolve().parent))
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
for group in (first, other):
|
|
116
|
+
for b in sorted((b for b in group if b and b != os.sep), key=len, reverse=True):
|
|
117
|
+
txt = txt.replace(b + os.sep, "")
|
|
118
|
+
home = str(Path.home())
|
|
119
|
+
return txt.replace(home + os.sep, "~/") if home and home != os.sep else txt
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _display(p) -> str | None:
|
|
123
|
+
"""A path as shown to the agent: relative to the working directory or this checkout, else to the indexed repo."""
|
|
124
|
+
if not p:
|
|
125
|
+
return p
|
|
126
|
+
return _rel_text(str(Path(p).resolve()))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
EMPTY_MARKERS = ("no method matches", "no symbol matches", "not found:", "no node matches", "no matches for",
|
|
130
|
+
"nothing depends", "no writers recorded", "no table ", "no path", "no forward path",
|
|
131
|
+
"has no recorded callers", "no callers found in indexed code", "no direct callers", "no siblings found", "no routes, tables", "no indexed test reaches",
|
|
132
|
+
"nothing matched the spec", "no channel matches", "no broadcast channels", "no bridge endpoint matches",
|
|
133
|
+
"no web / native bridge calls")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _coverage_note() -> str:
|
|
137
|
+
from .coverage import for_graph, note
|
|
138
|
+
try:
|
|
139
|
+
return note(for_graph(_st()))
|
|
140
|
+
except Exception: # noqa: BLE001
|
|
141
|
+
return ""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ToolReply(TypedDict):
|
|
145
|
+
"""Structured content of every tool reply: the text reply plus machine-readable completeness."""
|
|
146
|
+
result: str
|
|
147
|
+
completeness: dict[str, Any]
|
|
148
|
+
platform: NotRequired[dict[str, Any]] # the --platform filter a reply applied (target, excluded, unevaluated)
|
|
149
|
+
overrides: NotRequired[dict[str, Any]] # impact: the override relation of the targets ({overrides, overridden_by})
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# scope of the answer a tool is producing, set by the tool while it runs (see _scope); read by the tool() wrapper
|
|
153
|
+
_SCOPE: contextvars.ContextVar = contextvars.ContextVar("cg_answer_scope", default=None)
|
|
154
|
+
# the platform filter the current reply applied (codegraph/platforms.py filter_info), for the structured content
|
|
155
|
+
_PF: contextvars.ContextVar = contextvars.ContextVar("cg_platform_filter", default=None)
|
|
156
|
+
# extra machine-readable parts of the current reply (e.g. impact's override relation), merged into the structured content
|
|
157
|
+
_EXTRA: contextvars.ContextVar = contextvars.ContextVar("cg_reply_extra", default=None)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class PlatformError(ValueError):
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _platform(st: GraphStore, platform: str | None) -> tuple[str | None, list[str]]:
|
|
165
|
+
"""Resolve a `platform` argument; returns (target, [the reply's filter line]). Records the filter for the
|
|
166
|
+
structured content. Unknown names raise PlatformError (a readable reply listing the known targets)."""
|
|
167
|
+
if not platform:
|
|
168
|
+
return None, []
|
|
169
|
+
from .platforms import filter_info, render_filter, resolve_platform
|
|
170
|
+
try:
|
|
171
|
+
p = resolve_platform(platform)
|
|
172
|
+
except ValueError as e:
|
|
173
|
+
raise PlatformError(str(e)) from None
|
|
174
|
+
info = filter_info(st, p)
|
|
175
|
+
_PF.set(info)
|
|
176
|
+
return p, [render_filter(info)]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _scope(ids=None, categories=("route", "handler"), whole: bool = False, note: bool = True, unsupported=None) -> None:
|
|
180
|
+
"""Declare what the current answer is about: node ids (their languages / directories / repos scope the
|
|
181
|
+
completeness), or the whole index. note=False: structured completeness only, no text note."""
|
|
182
|
+
_SCOPE.set({"ids": list(ids or []), "categories": categories, "whole": whole, "note": note, "unsupported": unsupported})
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _completeness(scope: dict | None) -> dict:
|
|
186
|
+
from .coverage import completeness_for
|
|
187
|
+
try:
|
|
188
|
+
st = _st()
|
|
189
|
+
if scope is None:
|
|
190
|
+
return completeness_for(st, whole=True)
|
|
191
|
+
kw = {} if scope["unsupported"] is None else {"unsupported": scope["unsupported"]}
|
|
192
|
+
return completeness_for(st, scope["ids"], categories=scope["categories"], whole=scope["whole"] or not scope["ids"], **kw)
|
|
193
|
+
except Exception: # noqa: BLE001 (no graph yet, older DB: completeness unknown, never a crash)
|
|
194
|
+
return {"complete": False, "recorded": False, "languages": {}}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _run(fn, args, kwargs) -> tuple[str, dict]:
|
|
198
|
+
"""Run a tool: text reply (repo-relative paths, coverage notes) + its completeness object."""
|
|
199
|
+
from .coverage import answer_note
|
|
200
|
+
from .plans import PlanError
|
|
201
|
+
token, ptoken, xtoken = _SCOPE.set(None), _PF.set(None), _EXTRA.set(None)
|
|
202
|
+
try:
|
|
203
|
+
try:
|
|
204
|
+
txt = fn(*args, **kwargs)
|
|
205
|
+
except PlanError as e: # missing plan, invalid YAML: a readable reply, not a bare tool error
|
|
206
|
+
txt = f"plan error: {e}"
|
|
207
|
+
except PlatformError as e:
|
|
208
|
+
txt = f"platform error: {e}"
|
|
209
|
+
scope, pf, extra = _SCOPE.get(), _PF.get(), _EXTRA.get()
|
|
210
|
+
finally:
|
|
211
|
+
_SCOPE.reset(token)
|
|
212
|
+
_PF.reset(ptoken)
|
|
213
|
+
_EXTRA.reset(xtoken)
|
|
214
|
+
comp = _completeness(scope)
|
|
215
|
+
if pf:
|
|
216
|
+
comp = {**comp, "_platform": pf}
|
|
217
|
+
if extra:
|
|
218
|
+
comp = {**comp, "_extra": extra}
|
|
219
|
+
if not isinstance(txt, str) or txt.startswith(("plan error:", "platform error:")):
|
|
220
|
+
return _rel_text(txt), comp
|
|
221
|
+
scoped = ""
|
|
222
|
+
if scope and scope["note"] and not comp.get("complete") and "coverage note:" not in txt:
|
|
223
|
+
scoped = answer_note(comp)
|
|
224
|
+
if scoped:
|
|
225
|
+
txt = txt.rstrip() + "\n" + scoped
|
|
226
|
+
if fn.__name__ != "coverage" and any(m in txt[:600] for m in EMPTY_MARKERS):
|
|
227
|
+
cn = _coverage_note()
|
|
228
|
+
claims_all = cn.startswith("coverage: every source file")
|
|
229
|
+
if cn and not ((scoped or "coverage note:" in txt) and claims_all):
|
|
230
|
+
txt = txt.rstrip() + "\n" + cn
|
|
231
|
+
return _rel_text(txt), comp
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def tool(fn):
|
|
235
|
+
"""Register an MCP tool. The text reply goes through _rel_text; empty / unknown-symbol replies get a coverage note,
|
|
236
|
+
partial answers a scoped `coverage note:` line; the structured content carries {result, completeness}."""
|
|
237
|
+
@functools.wraps(fn)
|
|
238
|
+
def wrapped(*args, **kwargs):
|
|
239
|
+
return _run(fn, args, kwargs)[0]
|
|
240
|
+
|
|
241
|
+
@functools.wraps(fn)
|
|
242
|
+
def served(*args, **kwargs):
|
|
243
|
+
txt, comp = _run(fn, args, kwargs)
|
|
244
|
+
pf, extra = comp.pop("_platform", None), comp.pop("_extra", None)
|
|
245
|
+
return CallToolResult(content=[TextContent(type="text", text=txt)],
|
|
246
|
+
structured_content={"result": txt, "completeness": _rel_obj(comp), **({"platform": pf} if pf else {}),
|
|
247
|
+
**_rel_obj(extra or {})})
|
|
248
|
+
served.__signature__ = inspect.signature(fn, eval_str=True).replace(return_annotation=Annotated[CallToolResult, ToolReply])
|
|
249
|
+
wrapped.completeness = lambda *a, **k: {k2: v for k2, v in _run(fn, a, k)[1].items() if k2 not in ("_platform", "_extra")}
|
|
250
|
+
wrapped.structured = lambda *a, **k: served(*a, **k).structured_content # tests / scripts: the full structured reply
|
|
251
|
+
server.tool()(served)
|
|
252
|
+
return wrapped
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _rel_obj(o):
|
|
256
|
+
if isinstance(o, str):
|
|
257
|
+
return _rel_text(o)
|
|
258
|
+
if isinstance(o, dict):
|
|
259
|
+
return {k: _rel_obj(v) for k, v in o.items()}
|
|
260
|
+
if isinstance(o, list):
|
|
261
|
+
return [_rel_obj(v) for v in o]
|
|
262
|
+
return o
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _st() -> GraphStore:
|
|
266
|
+
return GraphStore(STATE["db"])
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
CODE_PREFIXES = ("method:", "class:", "function:", "interface:", "trait:", "enum:", "struct:", "union:", "typedef:",
|
|
270
|
+
"type_alias:", "macro:", "global:", "ffi:", "field:", "const:", "static:")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def short(x: str | None) -> str:
|
|
274
|
+
"""Drop the node-kind prefix of code ids and the root 'App\\' namespace; other ids (column:, connection:...) stay."""
|
|
275
|
+
if not x:
|
|
276
|
+
return "?"
|
|
277
|
+
if x.startswith(CODE_PREFIXES + ("composable:", "store:", "module:")) and "#" in x:
|
|
278
|
+
f, _, q = x.split(":", 1)[1].partition("#")
|
|
279
|
+
return f"{q} ({os.path.basename(f)})"
|
|
280
|
+
if x.startswith(CODE_PREFIXES):
|
|
281
|
+
x = x.split(":", 1)[1]
|
|
282
|
+
return x[4:] if x.startswith("App\\") else x
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def at(s: str) -> str:
|
|
286
|
+
"""'app/Services/X.php:12' -> 'X.php:12' (directory is implied by the module/symbol)."""
|
|
287
|
+
if not s:
|
|
288
|
+
return "?"
|
|
289
|
+
f, _, ln = s.rpartition(":")
|
|
290
|
+
return f"{os.path.basename(f)}:{ln}"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def fmt_path(path: list[dict], limit=8) -> str:
|
|
294
|
+
if not path:
|
|
295
|
+
return "(target)"
|
|
296
|
+
hops = []
|
|
297
|
+
for p in path[:limit]:
|
|
298
|
+
g = f"!GATED({at(p.get('guard') or '')})" if p.get("gated") else ""
|
|
299
|
+
c = "" if p["confidence"] == "exact" else f"~{p['confidence'][0]}"
|
|
300
|
+
hops.append(f"{p['kind']}@{at(p['at'])}{c}{g}")
|
|
301
|
+
more = f" …+{len(path) - limit}" if len(path) > limit else ""
|
|
302
|
+
return " → ".join(hops) + more + f" → {short(path[-1]['to'])}"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def ek_str(ek: dict) -> str:
|
|
306
|
+
abbrev = {"http_route": "route", "websocket": "ws", "artisan_command": "cmd", "management_command": "cmd", "scheduled": "sched",
|
|
307
|
+
"queue_job": "job", "listener": "listener", "admin_panel": "admin", "observer": "observer", "ui_page": "page",
|
|
308
|
+
"ui_global": "ui-shell", "public_api": "api", "ffi_export": "ffi", "build_script": "build",
|
|
309
|
+
"message_handler": "msg", "cli_command": "cli", "channel_auth": "channel"}
|
|
310
|
+
return ",".join(f"{abbrev.get(k, k)}×{v}" for k, v in sorted(ek.items())) or "-"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _ekind(e: dict) -> str:
|
|
314
|
+
return e["kind"] if e["kind"] in Q.ENTRY_NODE_KINDS else (e.get("entry_kind") or e["kind"])
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _ename(e: dict) -> str:
|
|
318
|
+
"""Entry display name: native code entries (main, exported fns, tests) use the qualified name + file:line."""
|
|
319
|
+
if e["kind"] in Q.CODE_KINDS and Q.NATIVE_FILE_RE.search(e.get("file") or ""):
|
|
320
|
+
return f"{short(e.get('fqn') or e['id'])} @{at((e.get('file') or '?') + ':' + str(e.get('line')))}" + Q.generated_label(e)
|
|
321
|
+
return e["name"] + Q.generated_label(e)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@tool
|
|
325
|
+
def reaches(targets: list[str], min_confidence: str = "heuristic", group_by: str = "module",
|
|
326
|
+
include_gated: bool = True, max_per_group: int = 25, paths: bool = True, platform: str | None = None) -> str:
|
|
327
|
+
"""Reverse transitive dependents of one or more targets (union), e.g.
|
|
328
|
+
["orders.customer_id", "connection:warehouse", "connection:tenant_*"].
|
|
329
|
+
|
|
330
|
+
Groups: RUNTIME (live; reached from http routes/schedules/jobs/listeners, or main()/exported FFI symbols in
|
|
331
|
+
Rust/C/C++), LIBRARY (only via a library's public API), DEV (only tests/benches/examples/build scripts), OPERATOR (artisan commands /
|
|
332
|
+
admin panels only: one-off import & provisioning), GATED (dead under the indexed gate scenario, e.g.
|
|
333
|
+
code that only runs while a feature flag is off), NO-ENTRY. Inside each group items are grouped by
|
|
334
|
+
`group_by` = module | entry_kind | class. Each line: symbol, entry kinds, depth, shortest evidence path
|
|
335
|
+
(hops KIND@file:line; ~r = resolved, ~h = heuristic confidence). At most `max_per_group` lines per top-level
|
|
336
|
+
group (shallowest first). min_confidence: heuristic | resolved | exact.
|
|
337
|
+
platform: only code built for that target (windows, linux, macos, ios, android, web): code under a platform
|
|
338
|
+
condition that is false there (#[cfg], #if, Platform.OS / Platform.isX, kIsWeb, .ios.ts / .android.ts files,
|
|
339
|
+
Dart conditional imports) is left out; the reply's first line names the filter and how many conditions could not
|
|
340
|
+
be evaluated (those stay in)."""
|
|
341
|
+
st = _st()
|
|
342
|
+
pf, pline = _platform(st, platform)
|
|
343
|
+
res = Q.reaches(st, targets, min_conf=min_confidence, platform=pf)
|
|
344
|
+
items = [i for i in res["items"] if not i["is_target"]]
|
|
345
|
+
_scope([x for t in res["targets"].values() for x in t] + [i["id"] for i in items])
|
|
346
|
+
code = [i for i in items if i["kind"] in Q.CODE_KINDS]
|
|
347
|
+
entries = [i for i in items if i["kind"] in Q.ENTRY_NODE_KINDS or (i["kind"] in Q.CODE_KINDS and i.get("entry_kind"))]
|
|
348
|
+
groups = defaultdict(list)
|
|
349
|
+
for i in code:
|
|
350
|
+
g = "GATED" if i.get("gate_status", "live") != "live" and i["class"] != "no_entry" else {
|
|
351
|
+
"runtime": "RUNTIME", "library": "LIBRARY", "operator": "OPERATOR", "ui": "UI", "dev": "DEV",
|
|
352
|
+
"other_entry": "OBSERVER", "no_entry": "NO-ENTRY"}[i["class"]]
|
|
353
|
+
groups[g].append(i)
|
|
354
|
+
tl = ", ".join(f"{s}→{len(t)}" for s, t in res["targets"].items())
|
|
355
|
+
missing = [s for s, t in res["targets"].items() if not t]
|
|
356
|
+
if pf and res["platform"].get("targets_not_built"):
|
|
357
|
+
pline.append(f"not built for {pf}: {', '.join(short(t) for t in res['platform']['targets_not_built'][:6])}")
|
|
358
|
+
if missing and len(missing) == len(res["targets"]):
|
|
359
|
+
return "\n".join(pline + [f"targets: {tl}"]) + f"\nno node matches {', '.join(map(repr, missing))}; try search() with part of the name (spec forms are listed in the server instructions)"
|
|
360
|
+
if not items:
|
|
361
|
+
return "\n".join(pline) + ("\n" if pline else "") + (f"targets: {tl}\nnothing depends on the target(s) over dependency edges (min_confidence={min_confidence}). "
|
|
362
|
+
f"try: node() for its direct edges; downstream() for what it reaches; a lower min_confidence")
|
|
363
|
+
live_e = sum(1 for e in entries if e.get("gate_status", "live") == "live")
|
|
364
|
+
extra = Q.inherited_lines(res) + ([f"overrides followed (their dependents count, marked via override): "
|
|
365
|
+
f"{', '.join(short(x) for x in res['overrides_followed'][:8])}"]
|
|
366
|
+
if res.get("overrides_followed") else [])
|
|
367
|
+
out = pline + [f"targets: {tl} | gate={res.get('gate')} | conf>={min_confidence}", *extra,
|
|
368
|
+
f"dependents: {len(code)} code ({', '.join(f'{k.lower()} {len(v)}' for k, v in groups.items())}); "
|
|
369
|
+
f"entry points {len(entries)} ({live_e} live): " + ", ".join(f"{k}×{n}" for k, n in sorted(
|
|
370
|
+
defaultdict(int, {k: sum(1 for e in entries if _ekind(e) == k) for k in {_ekind(e) for e in entries}}).items()))]
|
|
371
|
+
for gname in ("RUNTIME", "LIBRARY", "OPERATOR", "UI", "DEV", "GATED", "OBSERVER", "NO-ENTRY"):
|
|
372
|
+
if gname == "GATED" and not include_gated:
|
|
373
|
+
continue
|
|
374
|
+
g = groups.get(gname)
|
|
375
|
+
if not g:
|
|
376
|
+
continue
|
|
377
|
+
out.append(f"\n## {gname} ({len(g)})")
|
|
378
|
+
keep = {id(i) for i in sorted(g, key=lambda x: (x["depth"], x.get("fqn") or ""))[:max_per_group]}
|
|
379
|
+
buckets = defaultdict(list)
|
|
380
|
+
for i in g:
|
|
381
|
+
if id(i) not in keep:
|
|
382
|
+
continue
|
|
383
|
+
if group_by == "entry_kind":
|
|
384
|
+
k = ek_str(i.get("live_entry_kinds") if gname != "GATED" and i.get("live_entry_kinds") else i["entry_kinds"]).split(",")[0].split("×")[0]
|
|
385
|
+
elif group_by == "class":
|
|
386
|
+
k = short((i.get("fqn") or i["id"]).split("::")[0])
|
|
387
|
+
else:
|
|
388
|
+
k = i.get("module") or "?"
|
|
389
|
+
buckets[k].append(i)
|
|
390
|
+
for k in sorted(buckets):
|
|
391
|
+
out.append(f"[{k}]")
|
|
392
|
+
for i in sorted(buckets[k], key=lambda x: (x["depth"], x.get("fqn") or "")):
|
|
393
|
+
line = f" {short(i.get('fqn') or i['id'])}{Q.platform_label(i)} {ek_str(i['entry_kinds'])} d{i['depth']}"
|
|
394
|
+
if i.get("via_override"):
|
|
395
|
+
line += f" (via override {short(i['via_override'])})"
|
|
396
|
+
if gname == "GATED":
|
|
397
|
+
ev = i.get("gate_evidence") or {}
|
|
398
|
+
line += f" {i['gate_status']} guard {at(ev.get('guard') or '')} hop {ev.get('kind')}@{at(ev.get('at') or '')}"
|
|
399
|
+
elif paths:
|
|
400
|
+
line += " " + fmt_path(i["path"])
|
|
401
|
+
out.append(line)
|
|
402
|
+
if len(g) > max_per_group:
|
|
403
|
+
out.append(f" … +{len(g) - max_per_group} more in {gname} (raise max_per_group)")
|
|
404
|
+
out.append(f"\n## ENTRY POINTS ({len(entries)})")
|
|
405
|
+
byk = defaultdict(list)
|
|
406
|
+
for e in entries:
|
|
407
|
+
byk[_ekind(e)].append(e)
|
|
408
|
+
for k in sorted(byk):
|
|
409
|
+
names = sorted(f"{_ename(e)}{'' if e.get('gate_status', 'live') == 'live' else ' [gated]'}" for e in byk[k])
|
|
410
|
+
out.append(f"{k} ({len(names)}): " + "; ".join(names[:40]) + (f"; …+{len(names) - 40}" if len(names) > 40 else ""))
|
|
411
|
+
return "\n".join(out)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@tool
|
|
415
|
+
def impact(method: str, min_confidence: str = "heuristic", max_items: int = 60, platform: str | None = None) -> str:
|
|
416
|
+
"""Reverse callers of a method (Class::method, short or FQN) up to entry points, with the shortest call path
|
|
417
|
+
from each entry point.
|
|
418
|
+
platform: only code built for that target (windows, linux, macos, ios, android, web): code under a platform
|
|
419
|
+
condition that is false there (#[cfg], #if, Platform.OS / Platform.isX, kIsWeb, .ios.ts / .android.ts files,
|
|
420
|
+
Dart conditional imports) is left out; the reply's first line names the filter and how many conditions could not
|
|
421
|
+
be evaluated (those stay in)."""
|
|
422
|
+
st = _st()
|
|
423
|
+
pf, pline = _platform(st, platform)
|
|
424
|
+
r = Q.impact(st, method, min_conf=min_confidence, platform=pf)
|
|
425
|
+
if not r["targets"]:
|
|
426
|
+
return f"no method matches {method!r}; try search() with part of the name"
|
|
427
|
+
_scope(list(r["targets"]) + [c["id"] for c in r["callers"]])
|
|
428
|
+
if pf and set(r["platform"]["targets_not_built"]) == set(r["targets"]):
|
|
429
|
+
return "\n".join(pline + [f"{method} is not built for {pf}: nothing calls it there; platform_divergence(target='{pf}') "
|
|
430
|
+
f"lists references to it that would not build"])
|
|
431
|
+
if r["overrides"] or r["overridden_by"]: # the relation, apart from the callers
|
|
432
|
+
_EXTRA.set({"overrides": {k: [{"id": x["id"], "fqn": x["fqn"], "of": x["of"], "edge": x["edge"]} for x in r[k]]
|
|
433
|
+
for k in ("overrides", "overridden_by")}})
|
|
434
|
+
out = pline + [f"targets: {', '.join(short(t) for t in r['targets'][:5])}", *Q.override_lines(r),
|
|
435
|
+
f"transitive callers: {len(r['callers'])}; entry points: {len(r['entry_points'])}"]
|
|
436
|
+
if not r["callers"] and not r["entry_points"]:
|
|
437
|
+
return "\n".join(out + [Q.explain_no_callers(st, method, r["targets"], min_confidence)])
|
|
438
|
+
byk = defaultdict(list)
|
|
439
|
+
for e in r["entry_points"]:
|
|
440
|
+
byk[e["entry_kind"]].append(e)
|
|
441
|
+
n = 0
|
|
442
|
+
for k in sorted(byk):
|
|
443
|
+
out.append(f"\n## {k} ({len(byk[k])})")
|
|
444
|
+
for e in byk[k]:
|
|
445
|
+
if n >= max_items:
|
|
446
|
+
break
|
|
447
|
+
n += 1
|
|
448
|
+
out.append(f" {_ename(e)}{Q.CANDIDATE_LABEL if e.get('candidate') else ''}{Q.platform_label(e)} {fmt_path(e['path'])}")
|
|
449
|
+
mods = defaultdict(int)
|
|
450
|
+
for c in r["callers"]:
|
|
451
|
+
mods[c.get("module") or "?"] += 1
|
|
452
|
+
out.append("\ncallers by module: " + ", ".join(f"{m}×{c}" for m, c in sorted(mods.items(), key=lambda x: -x[1])))
|
|
453
|
+
gen = [c for c in r["callers"] if c.get("generated")]
|
|
454
|
+
if gen: # indexed with --include-generated: callers in files a generator writes
|
|
455
|
+
out.append(f"in generated / copied files ({len(gen)}): " + ", ".join(f"{short(c['id'])} [{c['generated']}]" for c in gen[:8])
|
|
456
|
+
+ (f" …+{len(gen) - 8}" if len(gen) > 8 else ""))
|
|
457
|
+
refs = [c for c in r["callers"] if c.get("edge") == "REFERENCES_FN"]
|
|
458
|
+
if refs: # code that holds the function as a value (dispatch table, callback, decorator) rather than calling it
|
|
459
|
+
out.append(f"by reference ({len(refs)}): " + ", ".join(f"{short(c['id'])} ({c.get('how') or 'ref'})" for c in refs[:12])
|
|
460
|
+
+ (f" …+{len(refs) - 12}" if len(refs) > 12 else ""))
|
|
461
|
+
vo = [c for c in r["callers"] if c.get("via_override")]
|
|
462
|
+
if vo: # calls an override; the base API is reached through it
|
|
463
|
+
out.append(f"via override ({len(vo)}): " + ", ".join(f"{short(c['id'])} -> {', '.join(c['via_override'][:3])}"
|
|
464
|
+
+ (f" +{len(c['via_override']) - 3}" if len(c['via_override']) > 3 else "")
|
|
465
|
+
for c in vo[:8]) + (f" …+{len(vo) - 8}" if len(vo) > 8 else ""))
|
|
466
|
+
cand = [c for c in r["callers"] if c.get("candidate")]
|
|
467
|
+
if cand or any(e.get("candidate") for e in r["entry_points"]): # reached through a candidate call edge (#83)
|
|
468
|
+
out.append(f"candidate callers ({len(cand)}): " + ", ".join(short(c["id"]) for c in cand[:12])
|
|
469
|
+
+ (f" …+{len(cand) - 12}" if len(cand) > 12 else "") + "\n" + Q.CANDIDATE_NOTE)
|
|
470
|
+
vb = [c for c in r["callers"] if c.get("via_base")]
|
|
471
|
+
if vb: # calls the base declaration; the target is reached through the override
|
|
472
|
+
out.append(f"via base ({len(vb)}): " + ", ".join(f"{short(c['id'])} -> {c['via_base']}" for c in vb[:8])
|
|
473
|
+
+ (f" …+{len(vb) - 8}" if len(vb) > 8 else ""))
|
|
474
|
+
out += _snapshot_client_lines([e["id"] for e in r["entry_points"] if e["id"].startswith("route:")])
|
|
475
|
+
return "\n".join(out)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _snapshot_client_lines(route_ids) -> list[str]:
|
|
479
|
+
"""External (not indexed) client call sites from the snapshot files next to the plans that hit these routes."""
|
|
480
|
+
from . import plans as P
|
|
481
|
+
try:
|
|
482
|
+
hits = P.snapshot_clients(_st(), route_ids, _plans_dir())
|
|
483
|
+
except Exception: # noqa: BLE001 (a broken snapshot file must not break impact)
|
|
484
|
+
return []
|
|
485
|
+
if not hits:
|
|
486
|
+
return []
|
|
487
|
+
out = [f"\nexternal clients (snapshot, not indexed): {len(hits)}"]
|
|
488
|
+
for h in hits:
|
|
489
|
+
ev = f"{h['repo']}@{h['commit']}:{h['file']}" + (f":{h['line']}" if h.get("line") else "")
|
|
490
|
+
out.append(f" {h['method']} {h['path']} -> {h['route'].split(':', 1)[1]} @{ev}"
|
|
491
|
+
+ (f" sends {', '.join(h['sends'])}" if h["sends"] else "") + f" [{h['snapshot']}]")
|
|
492
|
+
return out
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
CALLER_KINDS = ("CALLS", "INSTANTIATES", "IMPLEMENTED_BY", "OVERRIDDEN_BY", "BOUND_TO", "ROUTES_TO", "USES_MIDDLEWARE",
|
|
496
|
+
"HANDLED_BY", "SCHEDULES", "DISPATCHES", "LISTENED_BY", "RENDERS", "USES_COMPOSABLE", "USES_STORE",
|
|
497
|
+
"REFERENCES_FN", "AUTHORIZES_CHANNEL")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@tool
|
|
501
|
+
def callers(symbol: str, min_confidence: str = "heuristic", limit: int = 60) -> str:
|
|
502
|
+
"""Direct callers of a function / method / class (one level: CALLS, INSTANTIATES, dispatch and framework edges
|
|
503
|
+
into it), each with the call site file:line and confidence. For the full chain up to entry points use impact()."""
|
|
504
|
+
st = _st()
|
|
505
|
+
ids = Q.resolve_targets(st, symbol)
|
|
506
|
+
if not ids:
|
|
507
|
+
return f"no symbol matches {symbol!r}; try search() with part of the name"
|
|
508
|
+
_scope(ids[:5])
|
|
509
|
+
rank = {"exact": 3, "resolved": 2, "heuristic": 1}
|
|
510
|
+
rows = [r for r in st.q(
|
|
511
|
+
f"SELECT src, dst, kind, file, line, confidence, attrs FROM edges WHERE dst IN ({','.join('?' * len(ids[:5]))}) "
|
|
512
|
+
f"AND kind IN ({','.join('?' * len(CALLER_KINDS))}) ORDER BY file, line", tuple(ids[:5]) + CALLER_KINDS)
|
|
513
|
+
if rank.get(r["confidence"], 1) >= rank.get(min_confidence, 1)]
|
|
514
|
+
# a container binding next to the override / implementation edge of the same pair is one relation: show it once
|
|
515
|
+
disp = {(r["src"], r["dst"]) for r in rows if r["kind"] in Q.DISPATCH_KINDS}
|
|
516
|
+
rows = [r for r in rows if not (r["kind"] == "BOUND_TO" and (r["src"], r["dst"]) in disp)]
|
|
517
|
+
head = f"targets: {', '.join(short(t) for t in ids[:5])}"
|
|
518
|
+
_scope(ids[:5] + [r["src"] for r in rows])
|
|
519
|
+
if not rows:
|
|
520
|
+
return head + f"\nno direct callers of {symbol!r} in indexed code (min_confidence={min_confidence}). try: impact() for " \
|
|
521
|
+
f"entry points, reaches() for every dependent over all edge kinds, node() for its other edges"
|
|
522
|
+
out = [head, f"direct callers: {len({r['src'] for r in rows})} ({len(rows)} sites)"]
|
|
523
|
+
for r in rows[:limit]:
|
|
524
|
+
c = "" if r["confidence"] == "exact" else f" ~{r['confidence'][0]}"
|
|
525
|
+
k = "ref" if r["kind"] == "REFERENCES_FN" else r["kind"]
|
|
526
|
+
cand = Q.CANDIDATE_LABEL if r["attrs"] and '"binding": "candidate"' in r["attrs"] else ""
|
|
527
|
+
out.append(f" {short(r['src'])} {k}@{at((r['file'] or '?') + ':' + str(r['line']))}{c}{cand}")
|
|
528
|
+
if len(rows) > limit:
|
|
529
|
+
out.append(f" … +{len(rows) - limit} more (raise limit)")
|
|
530
|
+
return "\n".join(out)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@tool
|
|
534
|
+
def siblings(symbol: str, limit: int = 15) -> str:
|
|
535
|
+
"""Code parallel to a symbol that often needs the same change: classes sharing its parent/interface/trait,
|
|
536
|
+
the same method in sibling classes, other code touching the same tables/columns/config/connections, and
|
|
537
|
+
co-callers (methods calling the same helpers; Jaccard on callees)."""
|
|
538
|
+
st = _st()
|
|
539
|
+
r = Q.siblings(st, symbol, limit=limit)
|
|
540
|
+
if not r["targets"]:
|
|
541
|
+
return f"no symbol matches {symbol!r}; try search() with part of the name"
|
|
542
|
+
out = [f"target: {short(r['targets'][0])}"]
|
|
543
|
+
if not any(r[k] for k in ("hierarchy", "same_method_in_siblings", "shared_resources", "co_callers")):
|
|
544
|
+
return "\n".join(out + [Q.explain_siblings(st, symbol, r)])
|
|
545
|
+
if r["hierarchy"]:
|
|
546
|
+
out.append("hierarchy: " + "; ".join(f"{h['kind']} {short(h['parent'])}: {short(h['sibling'])}" for h in r["hierarchy"][:limit]))
|
|
547
|
+
if r["same_method_in_siblings"]:
|
|
548
|
+
out.append("same method in siblings: " + "; ".join(f"{short(m['id'])} ({at(m['file'] + ':' + str(m['line']))})" for m in r["same_method_in_siblings"][:limit]))
|
|
549
|
+
if r["shared_resources"]:
|
|
550
|
+
out.append("shared resources:")
|
|
551
|
+
for s in r["shared_resources"][:limit]:
|
|
552
|
+
out.append(f" {short(s['node'])}: {', '.join(short(x) for x in s['shared'][:6])}{' …' if len(s['shared']) > 6 else ''}")
|
|
553
|
+
if r["co_callers"]:
|
|
554
|
+
out.append("co-callers:")
|
|
555
|
+
for s in r["co_callers"][:limit]:
|
|
556
|
+
out.append(f" {short(s['node'])} J={s['jaccard']}: {', '.join(short(x) for x in s['shared_callees'][:5])}")
|
|
557
|
+
return "\n".join(out)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
@tool
|
|
561
|
+
def resolutions(concept: str, within: str | None = None, client: bool = True, detail: bool = False,
|
|
562
|
+
max_chars: int = 40000) -> str:
|
|
563
|
+
"""Every place a concept (e.g. 'timezone', 'locale') is resolved, deterministically: assignment/return sites
|
|
564
|
+
whose value is a `??`/`?:` chain or an ordered sequence of early returns, expanded into a fallback chain of
|
|
565
|
+
request input keys (FormRequest rules / $request->input / $filters['x'] through helper calls), settings
|
|
566
|
+
(getSetting(key, default)), model columns, config/env and literal defaults. Sites are grouped into lettered
|
|
567
|
+
chains by signature; the divergence section shows pairwise where chains first differ; each chain lists the
|
|
568
|
+
routes that reach it. On a combined graph, also the frontend side: whether each matched client call sends the
|
|
569
|
+
key (builder keys + call-site argument keys) and client literal fallbacks (`x ?? 'UTC'`).
|
|
570
|
+
`within` filters owning functions by substring (e.g. 'Report'); `detail=True` lists every client call site and
|
|
571
|
+
the keys it passes (default: one line per client request with its verdict)."""
|
|
572
|
+
from .concepts import resolutions as R, render_resolutions
|
|
573
|
+
out = render_resolutions(R(_st(), concept, within=within, client=client), compact=not detail)
|
|
574
|
+
return out if len(out) <= max_chars else out[:max_chars] + f"\n… truncated ({len(out)} chars; narrow with `within`)"
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _prop_text(st, spec: str, what: str, rows: list, limit: int) -> str:
|
|
578
|
+
"""`readers` / `writers Type.prop` of a stored property (#88): one line per access site, tests last."""
|
|
579
|
+
if not rows:
|
|
580
|
+
return Q.explain_no_writers(st, spec, what)
|
|
581
|
+
nt = sum(1 for r in rows if r.get("test"))
|
|
582
|
+
out = [f"{spec}: {len(rows)} {'write' if what == 'writers' else 'read'} edges from {len({r['src'] for r in rows})} "
|
|
583
|
+
f"{what}" + (f" ({nt} from test code)" if nt else "")]
|
|
584
|
+
for r in rows[:limit]:
|
|
585
|
+
a = r.get("attrs") or {}
|
|
586
|
+
ex = " ".join(f"{k}={a[k]}" for k in ("receiver", "accessor", "storage") if k in a)
|
|
587
|
+
ek = ",".join(sorted(r["entry_kinds"])) or "-"
|
|
588
|
+
out.append(f" {'[test] ' if r.get('test') else ''}{r['fqn']} @{os.path.basename(r['file'] or '?')}:{r['line']}"
|
|
589
|
+
f" {ex} entries: {ek}")
|
|
590
|
+
if len(rows) > limit:
|
|
591
|
+
out.append(f" … +{len(rows) - limit} more")
|
|
592
|
+
return "\n".join(out)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
@tool
|
|
596
|
+
def roundtrip(prop: str, include_tests: bool = False) -> str:
|
|
597
|
+
"""Heuristic (#88): does a stored property `Type.prop` round-trip through a lossy transform? Each write site with
|
|
598
|
+
the lossy call it passes through (clamp, min/max, round, truncating casts, `fit*`, `.cg.yaml` `lossy:` names,
|
|
599
|
+
`@cg-lossy` functions; within the function plus one hop through direct callers), each read site that seeds UI
|
|
600
|
+
state (init, onAppear, remember, useState(initial), mounted), and un-narrowed ranges drawn next to such a read.
|
|
601
|
+
Findings are heuristic evidence with file:line, not proof."""
|
|
602
|
+
from . import roundtrip as RT
|
|
603
|
+
return RT.render(RT.roundtrip(_st(), prop, include_tests=include_tests))
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
@tool
|
|
607
|
+
def lint_async_state(include_tests: bool = False, limit: int = 80, rules: str = "") -> str:
|
|
608
|
+
"""Heuristic (#88 phase 3); `rules` is a comma-separated subset (default all). stale-async-result: a write of
|
|
609
|
+
stored / UI state inside Task / launch / useEffect / async function / async def code after an await, using the
|
|
610
|
+
awaited result of an input-dependent request, with no cancellation check and no comparison against a token / ID /
|
|
611
|
+
generation captured before the await. two-writers: state written with real (computed) values by lifecycle code
|
|
612
|
+
(init, onAppear, useEffect, LaunchedEffect) and by async code after an await or a completion callback.
|
|
613
|
+
incomplete-cache-key: a cache / memo store whose key leaves out a parameter or instance field the cached value
|
|
614
|
+
uses. echo-suppression: state that is written with a guard flag set (`isApplyingRemote`, `lastSent…`) so its
|
|
615
|
+
observer returns early, but is also written from async code without the guard. file:line evidence; not proof."""
|
|
616
|
+
from . import lint_async as LA
|
|
617
|
+
res = LA.lint(_st(), include_tests=include_tests, rules=[r.strip() for r in rules.split(",") if r.strip()] or None)
|
|
618
|
+
lines = LA.render(res).split("\n")
|
|
619
|
+
return "\n".join(lines[:1 + 3 * limit]) + (f"\n… +{len(res['findings']) - limit} more" if len(res["findings"]) > limit else "")
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@tool
|
|
623
|
+
def readers(prop: str, limit: int = 60) -> str:
|
|
624
|
+
"""Who reads a stored property `Type.prop` (READS_PROP edges: Swift, Kotlin, Python, TypeScript): each site with its receiver (`self`, a
|
|
625
|
+
typed variable), accessor and the entry-point kinds that reach it; test code's reads come last."""
|
|
626
|
+
st = _st()
|
|
627
|
+
return _prop_text(st, prop, "readers", Q.readers(st, prop), limit)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@tool
|
|
631
|
+
def writers(table: str, limit: int = 60) -> str:
|
|
632
|
+
"""Who writes a DB table (WRITES_TABLE / WRITES_COLUMN edges), grouped by module, with the columns written,
|
|
633
|
+
evidence lines and the entry-point kinds that reach each writer. `Type.prop` instead of a table: who writes
|
|
634
|
+
that stored property (WRITES_PROP edges: Swift, Kotlin, Python, TypeScript)."""
|
|
635
|
+
st = _st()
|
|
636
|
+
if Q.prop_fields(st, table):
|
|
637
|
+
return _prop_text(st, table, "writers", Q.writers(st, table), limit)
|
|
638
|
+
rows = Q.writers(st, table)
|
|
639
|
+
if not rows:
|
|
640
|
+
return Q.explain_no_writers(st, table)
|
|
641
|
+
by = defaultdict(lambda: {"cols": set(), "lines": set(), "ek": {}, "module": None, "conf": set()})
|
|
642
|
+
for r in rows:
|
|
643
|
+
b = by[r["src"]]
|
|
644
|
+
if r["kind"] == "WRITES_COLUMN":
|
|
645
|
+
b["cols"].add(r["dst"].split(".", 1)[1])
|
|
646
|
+
b["lines"].add(f"{os.path.basename(r['file'] or '?')}:{r['line']}")
|
|
647
|
+
b["ek"] = r["entry_kinds"]
|
|
648
|
+
b["module"] = r["module"]
|
|
649
|
+
b["conf"].add(r["confidence"])
|
|
650
|
+
out = [f"table {table}: {len(rows)} write edges from {len(by)} writers"]
|
|
651
|
+
mods = defaultdict(list)
|
|
652
|
+
for src, b in by.items():
|
|
653
|
+
mods[b["module"] or "?"].append((src, b))
|
|
654
|
+
n = 0
|
|
655
|
+
for m in sorted(mods):
|
|
656
|
+
out.append(f"[{m}]")
|
|
657
|
+
for src, b in sorted(mods[m]):
|
|
658
|
+
if n >= limit:
|
|
659
|
+
break
|
|
660
|
+
n += 1
|
|
661
|
+
cols = ",".join(sorted(b["cols"])[:10]) or "(row)"
|
|
662
|
+
out.append(f" {short(src)} {ek_str(b['ek'])} cols: {cols} @ {', '.join(sorted(b['lines'])[:4])} conf={'/'.join(sorted(b['conf']))}")
|
|
663
|
+
return "\n".join(out)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@tool
|
|
667
|
+
def channels(pattern: str | None = None, source: bool = True) -> str:
|
|
668
|
+
"""Broadcast channels (Laravel Broadcast::channel, events' broadcastOn, Echo / pusher-js subscriptions on a combined
|
|
669
|
+
graph). Without a pattern: one line per channel with its auth callback / checks, publishers and subscribers. With
|
|
670
|
+
a pattern (`orders.{id}`, a concrete name like `orders.42`, or a glob `orders.*`): WHO CAN JOIN (broadcasting auth
|
|
671
|
+
route + middleware, the callback with its source and every check it calls), PUBLISHED BY (events, the evaluated
|
|
672
|
+
channel name, dispatch sites and the entry points that reach them) and LISTENED TO BY (client code, pages, events
|
|
673
|
+
listened for). Flags private channels without a callback and subscriptions that match no backend channel."""
|
|
674
|
+
from .realtime import channels as _ch, render_channels
|
|
675
|
+
return render_channels(_ch(_st(), pattern, with_source=source))
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@tool
|
|
679
|
+
def bridges(pattern: str | None = None, protocol: str | None = None, unmatched: bool = False) -> str:
|
|
680
|
+
"""Web / native bridge calls: Capacitor plugins (registerPlugin / Plugins.X -> @CapacitorPlugin @PluginMethod,
|
|
681
|
+
CAPPlugin), React Native and Expo native modules (NativeModules / TurboModuleRegistry / requireNativeModule ->
|
|
682
|
+
@ReactMethod, Native*Spec overrides, RCT_EXPORT_METHOD / RCT_EXTERN_METHOD, Expo Function / AsyncFunction) and
|
|
683
|
+
Flutter platform channels (MethodChannel.invokeMethod / EventChannel -> setMethodCallHandler / setStreamHandler).
|
|
684
|
+
One endpoint per module method (endpoint:<protocol>:<module>#<method>) with its JS / Dart senders and the native
|
|
685
|
+
receivers per platform; flags methods missing on a target (missing_on), sent methods of a module implemented here
|
|
686
|
+
that no native code receives (no_receiver), native methods nothing sends (no_sender) and modules implemented outside
|
|
687
|
+
the repo (external). Desktop process boundaries too: Electron IPC channels (endpoint:electron-ipc:<channel>,
|
|
688
|
+
ipcRenderer.invoke / send / webContents.send -> ipcMain.handle / on, ipcRenderer.on), the context bridge
|
|
689
|
+
(endpoint:electron-preload:<key>#<member>) and Tauri commands (endpoint:tauri:<command>, invoke -> #[tauri::command]);
|
|
690
|
+
their receivers carry the process role (main / preload / renderer / webview / core). Native -> JS events
|
|
691
|
+
(react-native-event, capacitor-event) and Cordova actions (cordova) too; calls with a dynamic name are listed as
|
|
692
|
+
unresolved. pattern: endpoint name, substring or glob; protocol: capacitor | capacitor-event | cordova |
|
|
693
|
+
react-native | react-native-event | flutter | flutter-event | pigeon | electron-ipc | electron-preload | tauri;
|
|
694
|
+
unmatched: only endpoints with a check."""
|
|
695
|
+
from .bridges import bridges as _br, render_bridges
|
|
696
|
+
return render_bridges(_br(_st(), pattern, protocol=protocol, unmatched=unmatched))
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
@tool
|
|
700
|
+
def protocol_links(pattern: str | None = None, protocol: str | None = None, side: str | None = None,
|
|
701
|
+
unmatched: bool = False) -> str:
|
|
702
|
+
"""Every protocol endpoint in one view (#31 model): HTTP client endpoints and routes (http / ws / graphql), Pusher
|
|
703
|
+
broadcast channels and subscriptions, NestJS microservice messages (nest-rpc / nest-event / nest-ws / grpc), job
|
|
704
|
+
queues (bull, laravel-queue, celery), application events (laravel-event, nest-event-emitter, django-signal), web /
|
|
705
|
+
native bridges and desktop IPC (capacitor, react-native, flutter, electron-ipc, tauri ...) and the generic
|
|
706
|
+
endpoint:<protocol>:<name> nodes (code -SENDS_TO-> endpoint -RECEIVED_BY-> handler, MATCHES_ENDPOINT for
|
|
707
|
+
wildcard / template matches; MQTT, NATS, AMQP, Kafka, Redis pub/sub, Socket.IO). Without arguments: one line per
|
|
708
|
+
protocol (endpoints, send / receive sides, linked, check counts). With a pattern (name, id, substring or glob) /
|
|
709
|
+
protocol / side (send | receive) / unmatched: one block per endpoint with senders (and, for a few endpoints, the
|
|
710
|
+
entry points reaching them), receivers, guards, matches and checks: no_receiver, no_sender, test_sender_only,
|
|
711
|
+
ambiguous, schema_mismatch, unguarded, external (.cg.yaml protocols.external, third-party origins, bridge
|
|
712
|
+
packages). impact / reaches / path / downstream follow SENDS_TO / RECEIVED_BY / MATCHES_ENDPOINT as usual."""
|
|
713
|
+
from .protocols.view import protocols as _pr, render_protocols
|
|
714
|
+
return render_protocols(_pr(_st(), pattern, protocol=protocol, side=side, unmatched=unmatched))
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
@tool
|
|
718
|
+
def external_systems(pattern: str | None = None, protocol: str | None = None, source: str | None = None,
|
|
719
|
+
tls_off: bool = False) -> str:
|
|
720
|
+
"""External systems the code connects to (#40): databases, caches, brokers, mail relays, directories, file-transfer
|
|
721
|
+
hosts, object stores (external:<protocol>:<target>, target host:port when known from a DSN, .env.example or a
|
|
722
|
+
docker-compose service, else env:<KEY>) and third-party HTTP hosts; per system the code and logical connections
|
|
723
|
+
using it (CONNECTS_TO), the entry points reaching them, the address source and the credential source (location
|
|
724
|
+
only, never the value), TLS when known. protocol: postgres | mysql | redis | smtp | amqp | mongodb | ldap | ssh |
|
|
725
|
+
ftp | s3 | https ...; source: literal | env | env-example | compose | config; tls_off: only plaintext systems."""
|
|
726
|
+
from .external import external, render_external
|
|
727
|
+
return render_external(external(_st(), pattern, protocol=protocol, source=source, tls_off=tls_off))
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@tool
|
|
731
|
+
def llm_tools(pattern: str | None = None, framework: str | None = None, unmatched: bool = False, agent: str | None = None) -> str:
|
|
732
|
+
"""LLM tools and MCP primitives (#66): tools offered to a model (OpenAI / Anthropic schema literals, LangChain @tool /
|
|
733
|
+
StructuredTool / BaseTool, Agents SDK @function_tool, LlamaIndex FunctionTool, dict registries and if / match branches
|
|
734
|
+
of agent loops) and MCP server tools / resources / prompts (FastMCP / MCPServer, low-level call_tool) with their
|
|
735
|
+
handler, the tables the handler reaches, the agents offering them and the code calling them (MCP client call_tool /
|
|
736
|
+
read_resource / get_prompt). Checks: no_receiver, no_sender, name_collision; plus agents (model, tools, handoffs),
|
|
737
|
+
dynamic_dispatch (an agent loop picking the tool by a runtime name, not linked) and model calls. framework: mcp |
|
|
738
|
+
openai | anthropic | langchain | openai-agents | llamaindex | custom."""
|
|
739
|
+
from .aitools import render_tools, tools as _tools
|
|
740
|
+
return render_tools(_tools(_st(), pattern, framework=framework, unmatched=unmatched, agent=agent))
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
@tool
|
|
744
|
+
def tests_covering(target: str, min_confidence: str = "heuristic", paths: bool = True, max_depth: int = 3,
|
|
745
|
+
unit_only: bool = False, exclude_roots: list[str] | None = None, through_roots: bool = False) -> str:
|
|
746
|
+
"""Tests that exercise a symbol, route or table. DIRECT: the test code itself calls / instantiates it or sends an
|
|
747
|
+
HTTP request to the route ($this->getJson('/x'), Playwright request.get). TRANSITIVE: through application code
|
|
748
|
+
(test -> route -> controller -> service -> target). target: Class::method | Class | route:VERB /uri | `VERB /path`
|
|
749
|
+
or /path (matched against route URIs) | table.column | any node id. Tests are PHPUnit / Pest (tests/), Vitest / Jest
|
|
750
|
+
/ Playwright / Cypress spec files; they never count as callers in the other queries. A base / interface method
|
|
751
|
+
also lists the tests of its overrides, marked `via override X`; `Sub.method` for an inherited method resolves to
|
|
752
|
+
the definition it inherits (noted). Transitive tests are kept near the target: at most max_depth hops (0: any
|
|
753
|
+
depth), not through an app entry point (`@main` types and their members such as `App.body`, Android `*Activity`
|
|
754
|
+
classes, exclude_roots symbols; through_roots=true keeps them). UI / snapshot / screenshot tests (XCUITest,
|
|
755
|
+
Compose UI / Espresso, Playwright / Cypress, *UITests / androidTest folders) are listed apart, or left out
|
|
756
|
+
with unit_only=true. The summary line counts what was left out and why."""
|
|
757
|
+
res = Q.tests_covering(_st(), target, min_conf=min_confidence, near_depth=max_depth or None, unit_only=unit_only,
|
|
758
|
+
exclude_roots=exclude_roots, through_roots=through_roots)
|
|
759
|
+
_scope(res.get("targets") or [])
|
|
760
|
+
return Q.render_tests_covering(res, show_paths=paths)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
@tool
|
|
764
|
+
def node(id_or_symbol: str) -> str:
|
|
765
|
+
"""Details of one node: kind, FQN, file:line span, module, entry kinds, docblock (PHPDoc), and edge counts
|
|
766
|
+
by kind (in/out) with a few neighbours."""
|
|
767
|
+
st = _st()
|
|
768
|
+
n = st.node(id_or_symbol)
|
|
769
|
+
if not n:
|
|
770
|
+
ids = Q.resolve_targets(st, id_or_symbol)
|
|
771
|
+
n = st.node(ids[0]) if ids else None
|
|
772
|
+
if not n:
|
|
773
|
+
return f"not found: {id_or_symbol!r}; try search()"
|
|
774
|
+
out = [f"{n['id']}", f"kind={n['kind']} module={n['module']} at {n['file']}:{n['line']}-{n['end_line']}"]
|
|
775
|
+
ek = {r["entry_kind"]: r["entry_count"] for r in st.q("SELECT * FROM node_entry WHERE node_id=?", (n["id"],))}
|
|
776
|
+
if ek:
|
|
777
|
+
out.append(f"reached by: {ek_str(ek)}")
|
|
778
|
+
if n["attrs"]:
|
|
779
|
+
out.append(f"attrs: {n['attrs'][:300]}")
|
|
780
|
+
if n["doc"]:
|
|
781
|
+
out.append("doc:\n" + n["doc"].strip()[:1200])
|
|
782
|
+
for direction, col, other in (("out", "src", "dst"), ("in", "dst", "src")):
|
|
783
|
+
rows = st.q(f"SELECT kind, {other} AS o, file, line, confidence, gate FROM edges WHERE {col}=? ORDER BY kind, line", (n["id"],))
|
|
784
|
+
byk = defaultdict(list)
|
|
785
|
+
for r in rows:
|
|
786
|
+
byk[r["kind"]].append(r)
|
|
787
|
+
if byk:
|
|
788
|
+
out.append(f"{direction}: " + ", ".join(f"{k}×{len(v)}" for k, v in sorted(byk.items())))
|
|
789
|
+
for k, v in sorted(byk.items()):
|
|
790
|
+
if k == "CONTAINS":
|
|
791
|
+
continue
|
|
792
|
+
s = "; ".join(f"{short(r['o'])}@{r['line']}{'!g' if r['gate'] else ''}" for r in v[:6])
|
|
793
|
+
out.append(f" {k}: {s}{' …' if len(v) > 6 else ''}")
|
|
794
|
+
return "\n".join(out)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
@tool
|
|
798
|
+
def search(name: str, kind: str | None = None, limit: int = 20, platform: str | None = None) -> str:
|
|
799
|
+
"""Find nodes by name / FQN substring (case-insensitive), optionally filtered by kind
|
|
800
|
+
(class, method, route, command, table, column, connection, config, env, job, admin...). Also matches route
|
|
801
|
+
middleware, guards, auth and access checks (e.g. "auth" finds `auth:api`, `ApiKeyGuard`, `IsAuthenticated`) and
|
|
802
|
+
lists the routes that carry them. Platform-specific symbols carry their targets, e.g. `[ios, android]`.
|
|
803
|
+
platform: only code built for that target (windows, linux, macos, ios, android, web): code under a platform
|
|
804
|
+
condition that is false there (#[cfg], #if, Platform.OS / Platform.isX, kIsWeb, .ios.ts / .android.ts files,
|
|
805
|
+
Dart conditional imports) is left out; the reply's first line names the filter and how many conditions could not
|
|
806
|
+
be evaluated (those stay in)."""
|
|
807
|
+
st = _st()
|
|
808
|
+
pf, _ = _platform(st, platform)
|
|
809
|
+
return Q.render_search(Q.search(st, name, kind=kind, limit=limit, platform=pf))
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
@tool
|
|
813
|
+
def routes(writes: str | None = None, reaches: list[str] | None = None, missing: str | None = None,
|
|
814
|
+
unguarded: bool = False, auth_pattern: str | None = None, max_items: int = 40, paths: bool = True,
|
|
815
|
+
min_confidence: str = "heuristic", platform: str | None = None) -> str:
|
|
816
|
+
"""Routes with their middleware / guards / auth, in one call. Optional scope: writes="*" (routes that reach any
|
|
817
|
+
DB write) or writes="<table>", reaches=[specs] (routes that reach any of these nodes: table, column, connection,
|
|
818
|
+
method, env key...). Optional filters: missing="<name>" keeps routes with no guard whose name contains it (e.g.
|
|
819
|
+
"auth:api", "ApiKeyGuard"), unguarded=true keeps routes with no auth guard (the framework presets' auth guards and
|
|
820
|
+
the auth name pattern; extend with auth_pattern, a regex, or .cg.yaml auth.extra_patterns). Each route: guards, what it reaches with one evidence chain, and the frontend callers on a
|
|
821
|
+
combined graph. Guards come from Laravel middleware, Nest guards/interceptors, Express/Koa/Fastify/Hono
|
|
822
|
+
middleware, Next.js middleware.ts / handler wrappers, django-ninja auth= and Django/DRF view access checks.
|
|
823
|
+
min_confidence: keep the default (heuristic) for reviews: every edge still shows its own label, and a stricter
|
|
824
|
+
level names the routes it hides.
|
|
825
|
+
platform: only code built for that target (windows, linux, macos, ios, android, web): code under a platform
|
|
826
|
+
condition that is false there (#[cfg], #if, Platform.OS / Platform.isX, kIsWeb, .ios.ts / .android.ts files,
|
|
827
|
+
Dart conditional imports) is left out; the reply's first line names the filter and how many conditions could not
|
|
828
|
+
be evaluated (those stay in)."""
|
|
829
|
+
from . import routes as R
|
|
830
|
+
st = _st()
|
|
831
|
+
pf, _ = _platform(st, platform)
|
|
832
|
+
_scope(whole=True, categories=("route",), unsupported=False)
|
|
833
|
+
res = R.routes_report(st, writes=writes, reaches=reaches, missing=missing, unguarded=unguarded,
|
|
834
|
+
auth_pattern=auth_pattern, min_conf=min_confidence, platform=pf)
|
|
835
|
+
return R.render_routes(res, st, max_items=max_items, paths=paths, compact=True)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
@tool
|
|
839
|
+
def doctor(root: str | None = None, scip: list[str] | None = None, json_output: bool = False) -> str:
|
|
840
|
+
"""What this cg installation can index: versions of cg and the tools it uses, whether the Node / PHP / Dart
|
|
841
|
+
extractor dependencies are installed, and per language whether indexing runs in exact or heuristic mode, why, and
|
|
842
|
+
the command that installs what is missing. `root`: a project directory; also checks project conditions (a
|
|
843
|
+
compile_commands.json, `.cg.yaml` rust.targets) and lists only its languages. `scip`: SCIP index files to check
|
|
844
|
+
(documents, occurrences with a usable position, definitions; a warning when cg could not use them)."""
|
|
845
|
+
from .doctor import render, report
|
|
846
|
+
r = report(root, scip=scip)
|
|
847
|
+
return json.dumps(r, indent=2) if json_output else render(r)
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
@tool
|
|
851
|
+
def coverage(path: str | None = None, all_files: bool = False, json_output: bool = False) -> str:
|
|
852
|
+
"""Which languages and files this index covers. Per language: parser mode (exact, heuristic only when an
|
|
853
|
+
exact-mode indexer such as rust-analyzer or scip-clang is missing, skipped when the toolchain is missing, with the
|
|
854
|
+
install hint) and file completeness (discovered vs indexed, with parse failures, files over the size limit,
|
|
855
|
+
unmapped files outside the source roots, excluded files); unsupported source types by extension or shebang
|
|
856
|
+
(.go .java .kt .swift .qml .sh ...); blind spots: route / handler registrations cg does not model, with file:line.
|
|
857
|
+
path (optional): a file or directory; says whether cg has it in the graph. all_files: list every file per bucket
|
|
858
|
+
(default: the first 5). json_output: the completeness object as JSON. Not covered, heuristic or a blind spot
|
|
859
|
+
means: use your normal search and file reading for that part."""
|
|
860
|
+
from .coverage import for_graph, render, completeness, SUPPORTED, UNSUPPORTED, FALLBACK
|
|
861
|
+
st = _st()
|
|
862
|
+
covs = for_graph(st)
|
|
863
|
+
_scope(whole=True, note=False)
|
|
864
|
+
if json_output:
|
|
865
|
+
comp = completeness(covs)
|
|
866
|
+
roots = {r or "": [{k: v for k, v in e.items() if k in ("roots_mode", "source_roots", "roots_warnings", "roots_ambiguous",
|
|
867
|
+
"module_name_collisions")}
|
|
868
|
+
for e in (c or {}).get("languages", []) if e["language"] == "python" and e.get("source_roots")]
|
|
869
|
+
for r, c in covs.items()}
|
|
870
|
+
roots = {r: v[0] for r, v in roots.items() if v}
|
|
871
|
+
if roots:
|
|
872
|
+
comp["python_source_roots"] = next(iter(roots.values())) if len(covs) == 1 else roots
|
|
873
|
+
return json.dumps(comp, indent=1)
|
|
874
|
+
out = [render(covs, all_files=all_files)]
|
|
875
|
+
if path:
|
|
876
|
+
p = path.strip().removeprefix("./")
|
|
877
|
+
ext = os.path.splitext(p)[1].lower()
|
|
878
|
+
hit = st.q("SELECT file FROM nodes WHERE file = ? OR file LIKE ? OR file LIKE ? LIMIT 1", (p, f"%/{p}", f"{p}/%"))
|
|
879
|
+
lang = next((k for k, v in SUPPORTED.items() if ext in v), None) or UNSUPPORTED.get(ext)
|
|
880
|
+
status = None
|
|
881
|
+
for c in covs.values():
|
|
882
|
+
for e in (c or {}).get("languages", []):
|
|
883
|
+
if e["language"] == lang:
|
|
884
|
+
status = e["status"]
|
|
885
|
+
gen = None
|
|
886
|
+
for c in covs.values():
|
|
887
|
+
g = (c or {}).get("generated") or {}
|
|
888
|
+
for reason, ps in (g.get("paths") or {}).items():
|
|
889
|
+
if p in ps or any(x.startswith(p.rstrip("/") + "/") for x in ps):
|
|
890
|
+
gen = gen or reason
|
|
891
|
+
for cp in g.get("copies") or []:
|
|
892
|
+
if p == cp["target"] or p.startswith(cp["target"] + "/"):
|
|
893
|
+
gen = gen or (f"copy of {cp['source']}/" if cp.get("source") else "copied web assets")
|
|
894
|
+
if hit:
|
|
895
|
+
out.append(f"{path}: in the graph ({hit[0]['file']})" + (f"; {lang} {status}" if lang and status else "")
|
|
896
|
+
+ (f"; generated ({gen}), labelled attrs.generated" if gen else ""))
|
|
897
|
+
elif gen:
|
|
898
|
+
out.append(f"{path}: NOT in the graph: generated / copied file ({gen}), kept out of the graph by default; "
|
|
899
|
+
f"edit its source instead, or re-index with `cg index --include-generated` to see it.")
|
|
900
|
+
else:
|
|
901
|
+
why = (f"{lang} files are {status.replace('_', ' ')} in this index" if lang and status else
|
|
902
|
+
f"{lang} is not supported by cg" if lang else "no node of this graph comes from that path")
|
|
903
|
+
out.append(f"{path}: NOT in the graph ({why}); {FALLBACK}.")
|
|
904
|
+
return "\n".join(out)
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
@tool
|
|
908
|
+
def starters() -> str:
|
|
909
|
+
"""Starter queries derived from this graph, each with the tool call to run: the write route without an auth guard
|
|
910
|
+
that writes the most tables, the most-written / most-read table, the most-used DB connection and env key, the page
|
|
911
|
+
with the largest backend reach, the most-called functions. A good first call on an unfamiliar repository."""
|
|
912
|
+
from .starters import for_graph, render
|
|
913
|
+
_scope(whole=True, note=False)
|
|
914
|
+
return render(for_graph(_st()))
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
@tool
|
|
918
|
+
def stats() -> str:
|
|
919
|
+
"""Index metadata and node/edge counts by kind."""
|
|
920
|
+
st = _st()
|
|
921
|
+
m = st.meta()
|
|
922
|
+
nodes = st.q("SELECT kind, count(*) c FROM nodes GROUP BY kind ORDER BY c DESC")
|
|
923
|
+
edges = st.q("SELECT kind, count(*) c, sum(gate IS NOT NULL) g FROM edges GROUP BY kind ORDER BY c DESC")
|
|
924
|
+
s = m.get("stats", {})
|
|
925
|
+
out = [f"project={m.get('project')} root={_display(m.get('root'))} indexed_at={m.get('indexed_at')} index_seconds={s.get('index_seconds')}",
|
|
926
|
+
"nodes: " + ", ".join(f"{r['kind']}×{r['c']}" for r in nodes),
|
|
927
|
+
"edges: " + ", ".join(f"{r['kind']}×{r['c']}" + (f"(gated {r['g']})" if r["g"] else "") for r in edges)]
|
|
928
|
+
gp = st.q("SELECT scenario, method, value FROM gate_predicates")
|
|
929
|
+
if gp:
|
|
930
|
+
out.append("gate predicates: " + "; ".join(f"[{r['scenario']}] {short(r['method'])}={r['value']}" for r in gp))
|
|
931
|
+
from .coverage import for_graph, summary_line
|
|
932
|
+
for repo, cov in for_graph(st).items():
|
|
933
|
+
out.append(summary_line(cov, repo or None))
|
|
934
|
+
return "\n".join(out)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
@tool
|
|
938
|
+
def downstream(target: str, max_per_kind: int = 25, paths: bool = True, min_confidence: str = "heuristic",
|
|
939
|
+
platform: str | None = None) -> str:
|
|
940
|
+
"""Forward dependencies of a node: what it ends up calling/reading. On a combined graph a frontend page goes
|
|
941
|
+
page -> composables/components -> HTTP endpoints -> backend routes -> controllers/services -> tables.
|
|
942
|
+
Lists backend routes, tables touched (directly or via columns), columns, connections, config/env keys, each
|
|
943
|
+
with one shortest evidence path. target: page:/reports/:id | app/pages/x.vue | Class::method | ...
|
|
944
|
+
platform: only code built for that target (windows, linux, macos, ios, android, web): code under a platform
|
|
945
|
+
condition that is false there (#[cfg], #if, Platform.OS / Platform.isX, kIsWeb, .ios.ts / .android.ts files,
|
|
946
|
+
Dart conditional imports) is left out; the reply's first line names the filter and how many conditions could not
|
|
947
|
+
be evaluated (those stay in)."""
|
|
948
|
+
st = _st()
|
|
949
|
+
pf, pline = _platform(st, platform)
|
|
950
|
+
r = Q.downstream(st, target, min_conf=min_confidence, platform=pf)
|
|
951
|
+
if not r["targets"]:
|
|
952
|
+
return f"no node matches {target!r}; try search()"
|
|
953
|
+
out = pline + [f"targets: {', '.join(short(t) for t in r['targets'][:4])} | reached {r['reached']} nodes | gate={r.get('gate')}"]
|
|
954
|
+
if not r["sinks"]:
|
|
955
|
+
out.append(f"no routes, tables, columns, connections, config/env keys or jobs reachable from {target}: it calls nothing "
|
|
956
|
+
f"that touches data or crosses a boundary (or only through edges below min_confidence={min_confidence}). "
|
|
957
|
+
f"try: reaches(['{target}']) for what depends on it; node('{target}') for its direct edges.")
|
|
958
|
+
tt = r.get("tables_touched") or []
|
|
959
|
+
if tt:
|
|
960
|
+
out.append(f"tables touched ({len(tt)}): " + ", ".join(f"{t['table']}{'' if t['live'] else '[gated-only]'}" for t in tt))
|
|
961
|
+
for k in ("route", "table", "connection", "config", "env", "job", "column", "unsafe", "ffi", "feature", "cfg", "define"):
|
|
962
|
+
items = r["sinks"].get(k) or []
|
|
963
|
+
if not items:
|
|
964
|
+
continue
|
|
965
|
+
out.append(f"\n## {k} ({len(items)})")
|
|
966
|
+
for i in items[:max_per_kind]:
|
|
967
|
+
line = f" {short(i['id'])} d{i['depth']}{'' if i['live'] else ' [gated-only]'}"
|
|
968
|
+
if paths and k != "column":
|
|
969
|
+
line += " " + fmt_path(i["path"], limit=10)
|
|
970
|
+
out.append(line)
|
|
971
|
+
if len(items) > max_per_kind:
|
|
972
|
+
out.append(f" … +{len(items) - max_per_kind} more")
|
|
973
|
+
return "\n".join(out)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
@tool
|
|
977
|
+
def path(source: str, target: str, min_confidence: str = "heuristic", platform: str | None = None) -> str:
|
|
978
|
+
"""Shortest forward evidence chain from source to target (e.g. page:/reports/:id ->
|
|
979
|
+
SalesReportService::report), one hop per line with file:line and confidence.
|
|
980
|
+
platform: only code built for that target (windows, linux, macos, ios, android, web): code under a platform
|
|
981
|
+
condition that is false there (#[cfg], #if, Platform.OS / Platform.isX, kIsWeb, .ios.ts / .android.ts files,
|
|
982
|
+
Dart conditional imports) is left out; the reply's first line names the filter and how many conditions could not
|
|
983
|
+
be evaluated (those stay in)."""
|
|
984
|
+
st = _st()
|
|
985
|
+
pf, pline = _platform(st, platform)
|
|
986
|
+
p = Q.path_between(st, source, target, min_conf=min_confidence, platform=pf)
|
|
987
|
+
if not p:
|
|
988
|
+
return "\n".join(pline + [(f"on {pf}: " if pf else "") + Q.explain_no_path(st, source, target, min_confidence)])
|
|
989
|
+
out = pline + [short(p[0]["from"])]
|
|
990
|
+
for h in p:
|
|
991
|
+
pl = f" only on {', '.join(h['platforms']) or 'no known target'}" if h.get("platforms") is not None else ""
|
|
992
|
+
out.append(f" -{h['kind']}[{h['confidence']} @ {h['at']}{pl}]-> {short(h['to'])}")
|
|
993
|
+
out += [f"note: {n}" for n in Q.path_notes(st, p)]
|
|
994
|
+
return "\n".join(out)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
@tool
|
|
998
|
+
def platforms() -> str:
|
|
999
|
+
"""Platform-specific code in this graph: the targets (declared in .cg.yaml platforms.targets, or detected from
|
|
1000
|
+
Flutter platform folders, Expo app.json, React Native, Electron / Tauri, or the conditions themselves), how many
|
|
1001
|
+
conditions (#[cfg], cfg!, #if, Platform.OS / Platform.select, Platform.isX / kIsWeb, .ios.ts / .android.ts /
|
|
1002
|
+
.native.ts files, Dart conditional imports) were found and evaluated, and files / symbols per target. Pass
|
|
1003
|
+
`platform` to reaches / impact / downstream / path / routes / search to see one target's build."""
|
|
1004
|
+
from .platforms import render_summary, summary
|
|
1005
|
+
_scope(whole=True, note=False)
|
|
1006
|
+
return render_summary(summary(_st()))
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
@tool
|
|
1010
|
+
def platform_divergence(target: str | None = None, kind: str | None = None, max_items: int = 40) -> str:
|
|
1011
|
+
"""Where per-platform implementations diverge: VARIANTS (a symbol implemented per platform, by platform files,
|
|
1012
|
+
conditional imports or #[cfg] / #if alternatives, with the declared targets no variant covers), API SURFACE (a
|
|
1013
|
+
variant lacks a public symbol its siblings define) and REFERENCED WHERE THE CALLEE IS NOT BUILT (a call or
|
|
1014
|
+
import that is live on a target where the callee does not exist: a build or runtime failure there). Each finding
|
|
1015
|
+
has file:line evidence. target: only findings that affect this target. kind: variants | api_surface |
|
|
1016
|
+
missing_callee."""
|
|
1017
|
+
from .platforms import divergence, render_divergence, resolve_platform
|
|
1018
|
+
st = _st()
|
|
1019
|
+
_scope(whole=True, note=False)
|
|
1020
|
+
t = None
|
|
1021
|
+
if target:
|
|
1022
|
+
try:
|
|
1023
|
+
t = resolve_platform(target)
|
|
1024
|
+
except ValueError as e:
|
|
1025
|
+
raise PlatformError(str(e)) from None
|
|
1026
|
+
if kind and kind not in ("variants", "api_surface", "missing_callee", "missing_callee_tests"):
|
|
1027
|
+
return f"unknown kind {kind!r}: use variants, api_surface, missing_callee or missing_callee_tests"
|
|
1028
|
+
return render_divergence(divergence(st, kind=kind, target=t), limit=max_items)
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
@tool
|
|
1032
|
+
def api_calls(filter: str = "all", max_items: int = 60) -> str:
|
|
1033
|
+
"""Frontend HTTP calls (method + path template) with call sites, request keys and the matched backend
|
|
1034
|
+
route + controller (combined graph). filter: all | unmatched | any substring (endpoint, route, caller, file) |
|
|
1035
|
+
a `*` glob (`GET /v1/*/orders*`, `*/staff/*`, `*useOrders*`) on the endpoint, its path, route, controller,
|
|
1036
|
+
caller or call-site file."""
|
|
1037
|
+
st = _st()
|
|
1038
|
+
rows = Q.api_calls(st, filter)
|
|
1039
|
+
gaps = defaultdict(list)
|
|
1040
|
+
for g in Q.forwarding_gaps(st):
|
|
1041
|
+
gaps[g["endpoint"]].append(g)
|
|
1042
|
+
if not rows:
|
|
1043
|
+
total = st.q("SELECT count(*) c FROM nodes WHERE kind='http'")[0]["c"]
|
|
1044
|
+
return (f"no client endpoints match {filter!r} ({total} in the graph). " + (
|
|
1045
|
+
"This graph has no frontend HTTP calls: link a frontend DB to a backend DB with `cg link` and point the server at "
|
|
1046
|
+
"the combined DB." if not total else "try filter='all', 'unmatched' or a shorter substring of the path."))
|
|
1047
|
+
out = [f"{len(rows)} client endpoints ({sum(1 for r in rows if r['routes'])} matched)"]
|
|
1048
|
+
for r in rows[:max_items]:
|
|
1049
|
+
m = "; ".join(f"{short(x['route'])} [{x['confidence']}] → {', '.join(short(c) for c in x['controller']) or '?'}" for x in r["routes"]) or "UNMATCHED"
|
|
1050
|
+
out.append(f"{r['endpoint'][5:]} ⇒ {m}")
|
|
1051
|
+
for c in r["calls"][:3]:
|
|
1052
|
+
h = f" via {short(c['via_helper']['fn'])}" if c.get("via_helper") else ""
|
|
1053
|
+
out.append(f" ← {short(c['caller'])} @ {at(c['at'])}{h}")
|
|
1054
|
+
for g in gaps.get(r["endpoint"], [])[:3]:
|
|
1055
|
+
out.append(f" ! {short(g['caller'])} @ {at(g['call_at'])} passes {', '.join(g['dropped'])}: sent but not forwarded "
|
|
1056
|
+
f"(request sends {', '.join(g['request_keys'])})")
|
|
1057
|
+
if len(rows) > max_items:
|
|
1058
|
+
out.append(f"… +{len(rows) - max_items} more")
|
|
1059
|
+
return "\n".join(out)
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _plans_dir() -> str | None:
|
|
1063
|
+
"""--plans, else plans.dir of the indexed project's .cg.yaml, else the default plans/."""
|
|
1064
|
+
if STATE["plans"]:
|
|
1065
|
+
return STATE["plans"]
|
|
1066
|
+
from .plans import resolve_plans_dir
|
|
1067
|
+
return resolve_plans_dir(None, STATE["db"])
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _plan(name: str):
|
|
1071
|
+
from . import plans as P
|
|
1072
|
+
return P, P.load_plan(name, _plans_dir())
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
@tool
|
|
1076
|
+
def plan_list() -> str:
|
|
1077
|
+
"""List planned-change files (plans/*.yaml): name, status, title, counts, schema errors."""
|
|
1078
|
+
from . import plans as P
|
|
1079
|
+
rows = P.list_plans(_plans_dir())
|
|
1080
|
+
out = []
|
|
1081
|
+
for r in rows:
|
|
1082
|
+
if r.get("error"):
|
|
1083
|
+
out.append(f"{r['name']}: ERROR {r['error']}")
|
|
1084
|
+
continue
|
|
1085
|
+
c = r["counts"]
|
|
1086
|
+
out.append(f"{r['name']} [{r['status']}] {r['title']} | +{c['add_nodes']} nodes ~{c['modify']} modified +{c['add_edges']} edges "
|
|
1087
|
+
f"{c['forbid']} forbidden {c['require']} required | issues {', '.join(r['issues']) or '-'} | schema errors {r['schema_errors']}")
|
|
1088
|
+
return "\n".join(out) or "no plans"
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
@tool
|
|
1092
|
+
def plan_load(name: str) -> str:
|
|
1093
|
+
"""Show one plan (name or path): planned nodes (+), modified targets with intent (~), planned edges, forbidden
|
|
1094
|
+
paths (x), requirements (!), covers/out_of_scope, precedents, linked issues, schema errors."""
|
|
1095
|
+
P, plan = _plan(name)
|
|
1096
|
+
return P.render_load(plan)
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
@tool
|
|
1100
|
+
def plan_validate(name: str) -> str:
|
|
1101
|
+
"""Schema check + does every referenced existing node resolve in the graph (unresolved / ambiguous specs with
|
|
1102
|
+
candidates); planned nodes that already exist are flagged."""
|
|
1103
|
+
P, plan = _plan(name)
|
|
1104
|
+
return P.render_validate(P.validate(_st(), plan))
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
@tool
|
|
1108
|
+
def plan_check(name: str, verify: bool = False, details: bool = False, max_items: int = 5, review: bool = True,
|
|
1109
|
+
max_chars: int = 30000) -> str:
|
|
1110
|
+
"""Deterministic check of a plan against the real graph. Default reply: a compact summary (counts per section and
|
|
1111
|
+
per check, the top `max_items` missing items, failed requirements, middleware gaps, forbidden paths, open findings,
|
|
1112
|
+
verify counts); details=true gives the full report (every item with file:line evidence and call chains, up to
|
|
1113
|
+
max_items per list).
|
|
1114
|
+
plan mode: 1 RESOLVE (references, route middleware requirements, middleware differences between entry routes of
|
|
1115
|
+
the same modified code), 2 MISSING FROM PLAN (writers/readers of changed tables, Filament/API resources, $fillable,
|
|
1116
|
+
form requests, entry points + callers of modified methods, frontend pages and external client snapshots calling
|
|
1117
|
+
affected routes, parallel/legacy mirrors, precedents), 3 CONFLICTS (forbidden paths still in the code with their
|
|
1118
|
+
call chain, open findings touching the same nodes, linked or not), plus entry call chains.
|
|
1119
|
+
verify=true (after implementation + re-index): also 4 VERIFY: planned nodes/edges exist now (with evidence),
|
|
1120
|
+
modified targets changed vs baseline, forbidden paths gone or guarded, requirements met."""
|
|
1121
|
+
P, plan = _plan(name)
|
|
1122
|
+
res = P.check(_st(), plan, verify=verify, baseline=P.load_baseline(plan) if verify else None)
|
|
1123
|
+
res["file"] = _display(res.get("file"))
|
|
1124
|
+
_scope(list(res.get("modified") or []) + [i["node"] for i in res.get("items") or [] if i.get("node")])
|
|
1125
|
+
if not review:
|
|
1126
|
+
res["items"] = [i for i in res["items"] if i["severity"] != "review"]
|
|
1127
|
+
if not details:
|
|
1128
|
+
return P.render_check_summary(res, max_items=max_items)
|
|
1129
|
+
txt = P.render_check(res, max_items=max(max_items, 1))
|
|
1130
|
+
return txt if len(txt) <= max_chars else txt[:max_chars] + f"\n… truncated ({len(txt)} chars; lower max_items or review=false)"
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
@tool
|
|
1134
|
+
def plan_baseline(name: str) -> str:
|
|
1135
|
+
"""Fingerprint (sha1 of source lines) every modified target of a plan before implementing it, so
|
|
1136
|
+
plan_check(verify=true) can report changed/UNCHANGED. Writes plans/<name>.baseline.json (only file it writes)."""
|
|
1137
|
+
P, plan = _plan(name)
|
|
1138
|
+
b = P.make_baseline(_st(), plan)
|
|
1139
|
+
P.baseline_path(plan).write_text(json.dumps(b, indent=1) + "\n")
|
|
1140
|
+
return f"baseline: {len(b['targets'])} modified targets fingerprinted -> {_display(P.baseline_path(plan))}"
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _relink() -> dict:
|
|
1144
|
+
from .link import link
|
|
1145
|
+
m = _st().meta()
|
|
1146
|
+
src = m["sources"]
|
|
1147
|
+
be, fe = m["repos"]
|
|
1148
|
+
tmp = STATE["db"] + ".tmp"
|
|
1149
|
+
res = link(src[be], src[fe], tmp, be, fe)
|
|
1150
|
+
os.replace(tmp, STATE["db"])
|
|
1151
|
+
return res["stats"]
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _under(p: Path, base: Path) -> bool:
|
|
1155
|
+
try:
|
|
1156
|
+
p.relative_to(base)
|
|
1157
|
+
return True
|
|
1158
|
+
except ValueError:
|
|
1159
|
+
return False
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _pick_repos(meta: dict, root: str | None, repo: str | None) -> tuple[list[tuple[str, str]], str | None]:
|
|
1163
|
+
"""Which repo slots of a combined graph to re-index, each with the root to index it from: (slots, error).
|
|
1164
|
+
repo picks one slot; root is matched against the recorded repo roots (equal, inside one, or containing some);
|
|
1165
|
+
neither re-indexes every repo from its recorded root. A root never lands in a slot it does not belong to."""
|
|
1166
|
+
repos = list(meta["repos"])
|
|
1167
|
+
roots = {}
|
|
1168
|
+
for r in repos:
|
|
1169
|
+
try:
|
|
1170
|
+
rr = GraphStore(meta["sources"][r]).meta().get("root")
|
|
1171
|
+
except Exception:
|
|
1172
|
+
rr = None
|
|
1173
|
+
roots[r] = Path(rr).resolve() if rr else None
|
|
1174
|
+
known = ", ".join(f"{r} ({_display(str(roots[r])) if roots[r] else 'no recorded root'})" for r in repos)
|
|
1175
|
+
if repo is not None and repo not in repos:
|
|
1176
|
+
return [], f"unknown repo '{repo}'; this combined graph has: {known}"
|
|
1177
|
+
if root is None:
|
|
1178
|
+
picked = [repo] if repo else repos
|
|
1179
|
+
missing = [r for r in picked if roots[r] is None]
|
|
1180
|
+
if missing:
|
|
1181
|
+
return [], f"no recorded root for {', '.join(missing)}; pass root (one of the repo directories)"
|
|
1182
|
+
return [(r, str(roots[r])) for r in picked], None
|
|
1183
|
+
want = Path(root).resolve()
|
|
1184
|
+
if repo is not None:
|
|
1185
|
+
rr = roots[repo]
|
|
1186
|
+
if rr is None or want == rr or _under(want, rr):
|
|
1187
|
+
return [(repo, str(rr or want))], None
|
|
1188
|
+
return [], (f"root {_display(str(want))} is not the recorded root of repo '{repo}' ({_display(str(rr))}); "
|
|
1189
|
+
f"omit root to re-index {repo} from its recorded root")
|
|
1190
|
+
exact = [r for r in repos if roots[r] is not None and (want == roots[r] or _under(want, roots[r]))]
|
|
1191
|
+
if exact: # the repo directory itself or a path inside it
|
|
1192
|
+
return [(r, str(roots[r])) for r in exact[:1]], None
|
|
1193
|
+
inside = [r for r in repos if roots[r] is not None and _under(roots[r], want)]
|
|
1194
|
+
if inside: # a parent directory: every repo below it
|
|
1195
|
+
return [(r, str(roots[r])) for r in inside], None
|
|
1196
|
+
return [], (f"root {_display(str(want))} matches no repo of this combined graph; repos: {known}. "
|
|
1197
|
+
f"Pass repo=<name>, or a root inside one of those directories")
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _flag_roots(db: str) -> list[str] | None:
|
|
1201
|
+
"""`cg index --python-root` values recorded in a graph DB, so a re-index keeps them (.cg.yaml is re-read anyway)."""
|
|
1202
|
+
try:
|
|
1203
|
+
return (GraphStore(db).meta().get("stats") or {}).get("python_roots_flag")
|
|
1204
|
+
except Exception: # noqa: BLE001 (missing / older DB: detection or .cg.yaml applies)
|
|
1205
|
+
return None
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def _flag_generated(db: str) -> bool:
|
|
1209
|
+
"""`cg index --include-generated` recorded in a graph DB, so a re-index keeps it."""
|
|
1210
|
+
try:
|
|
1211
|
+
return bool((GraphStore(db).meta().get("stats") or {}).get("include_generated_flag"))
|
|
1212
|
+
except Exception: # noqa: BLE001
|
|
1213
|
+
return False
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _index(root: str | None = None, gates: str | None = None, repo: str | None = None) -> str:
|
|
1217
|
+
"""Implementation of index(); see its docstring."""
|
|
1218
|
+
from .indexer import index_project
|
|
1219
|
+
try:
|
|
1220
|
+
meta = _st().meta()
|
|
1221
|
+
except sqlite3.OperationalError:
|
|
1222
|
+
meta = {} # empty / not yet indexed DB
|
|
1223
|
+
if meta.get("repos"):
|
|
1224
|
+
slots, err = _pick_repos(meta, root, repo)
|
|
1225
|
+
if err:
|
|
1226
|
+
return "index refused: " + err
|
|
1227
|
+
done, tmps = [], []
|
|
1228
|
+
with STATE["lock"]:
|
|
1229
|
+
try:
|
|
1230
|
+
for r, r_root in slots:
|
|
1231
|
+
src_db = meta["sources"][r]
|
|
1232
|
+
tmp = src_db + ".tmp"
|
|
1233
|
+
tmps.append(tmp)
|
|
1234
|
+
g = gates or (STATE["gates"] if r == meta["repos"][0] else None)
|
|
1235
|
+
st = index_project(r_root, tmp, r, gates=g, python_roots=_flag_roots(src_db),
|
|
1236
|
+
include_generated=_flag_generated(src_db))
|
|
1237
|
+
if not st.get("nodes"):
|
|
1238
|
+
return (f"index refused: re-indexing {r} from {_display(r_root)} produced 0 nodes; "
|
|
1239
|
+
f"the graph is unchanged (is that the right directory?)")
|
|
1240
|
+
done.append((r, r_root, src_db, tmp, st))
|
|
1241
|
+
for r, r_root, src_db, tmp, st in done:
|
|
1242
|
+
os.replace(tmp, src_db)
|
|
1243
|
+
ls = _relink()
|
|
1244
|
+
finally:
|
|
1245
|
+
for tmp in tmps:
|
|
1246
|
+
if os.path.exists(tmp):
|
|
1247
|
+
os.remove(tmp)
|
|
1248
|
+
parts = "; ".join(f"re-indexed {r} ({_display(rr)}): {st['nodes']} nodes, {st['edges']} edges in "
|
|
1249
|
+
f"{st['index_seconds']}s" for r, rr, _, _, st in done)
|
|
1250
|
+
return f"{parts}; relinked: {ls['call_sites_matched']}/{ls['call_sites']} call sites matched"
|
|
1251
|
+
root = root or STATE["root"] or meta.get("root")
|
|
1252
|
+
gates = gates or STATE["gates"]
|
|
1253
|
+
if not root:
|
|
1254
|
+
return "no project root known; pass root"
|
|
1255
|
+
with STATE["lock"]:
|
|
1256
|
+
tmp = STATE["db"] + ".tmp"
|
|
1257
|
+
same_root = meta.get("root") and Path(meta["root"]).resolve() == Path(root).resolve()
|
|
1258
|
+
flag_roots = (meta.get("stats") or {}).get("python_roots_flag") if same_root else None
|
|
1259
|
+
flag_gen = bool((meta.get("stats") or {}).get("include_generated_flag")) if same_root else False
|
|
1260
|
+
st = index_project(root, tmp, Path(root).name, gates=gates, python_roots=flag_roots, include_generated=flag_gen)
|
|
1261
|
+
if not st.get("nodes"):
|
|
1262
|
+
os.remove(tmp)
|
|
1263
|
+
return f"index refused: {_display(root)} produced 0 nodes; the graph is unchanged (is that the right directory?)"
|
|
1264
|
+
os.replace(tmp, STATE["db"])
|
|
1265
|
+
return (f"indexed {_display(root)} -> {_display(STATE['db'])}: {st['nodes']} nodes, {st['edges']} edges in {st['index_seconds']}s; "
|
|
1266
|
+
f"gated edges {st.get('gated_edges')}")
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
@tool
|
|
1270
|
+
def index(root: str | None = None, gates: str | None = None, repo: str | None = None) -> str:
|
|
1271
|
+
"""Re-index after editing (static analysis only: never boots the app or touches a database).
|
|
1272
|
+
On a combined graph (backend + frontend): with no arguments every repo is re-indexed from its recorded root and the
|
|
1273
|
+
cross-repo link is rebuilt; repo (a name used at link time) re-indexes just that repo; root is matched to the
|
|
1274
|
+
recorded repo roots (a repo directory, a path inside one, or a parent of several). A root that matches no repo, or
|
|
1275
|
+
a result with 0 nodes, is refused and the graph is left unchanged.
|
|
1276
|
+
On a single-repo graph root defaults to the indexed root; gates is a gate-scenario JSON path (defaults to the
|
|
1277
|
+
server's --gates). The project config file (.cg.yaml at the root) is read on every
|
|
1278
|
+
re-index; Python source roots given with `cg index --python-root` and `--include-generated` are kept.
|
|
1279
|
+
Generated, copied and vendored files stay out of the graph (the coverage tool lists them) unless the graph was
|
|
1280
|
+
indexed with --include-generated or .cg.yaml sets generated.include."""
|
|
1281
|
+
from .config import ConfigError
|
|
1282
|
+
try:
|
|
1283
|
+
return _index(root, gates, repo)
|
|
1284
|
+
except ConfigError as ex:
|
|
1285
|
+
return f"index refused: {ex}; the graph is unchanged"
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def main(argv=None):
|
|
1289
|
+
ap = argparse.ArgumentParser(prog="codegraph-mcp")
|
|
1290
|
+
ap.add_argument("--db", default=STATE["db"])
|
|
1291
|
+
ap.add_argument("--root")
|
|
1292
|
+
ap.add_argument("--gates")
|
|
1293
|
+
ap.add_argument("--plans", help="plans directory (default: plans.dir of the project's .cg.yaml, else <repo>/plans)")
|
|
1294
|
+
a = ap.parse_args(argv)
|
|
1295
|
+
STATE["plans"] = str(Path(a.plans).resolve()) if a.plans else None
|
|
1296
|
+
STATE["db"] = str(Path(a.db).resolve())
|
|
1297
|
+
STATE["root"] = a.root
|
|
1298
|
+
STATE["gates"] = str(Path(a.gates).resolve()) if a.gates else None
|
|
1299
|
+
server.run("stdio")
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
if __name__ == "__main__":
|
|
1303
|
+
main()
|