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.
Files changed (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
@@ -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()