witan-code 0.2.0__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.
- witan_code/__init__.py +0 -0
- witan_code/__main__.py +4 -0
- witan_code/_detach.py +27 -0
- witan_code/bridge.py +392 -0
- witan_code/bridge_extractors.py +642 -0
- witan_code/cli.py +722 -0
- witan_code/config.py +65 -0
- witan_code/context.py +98 -0
- witan_code/edges.py +171 -0
- witan_code/elicit.py +92 -0
- witan_code/extensions/pi/codegraph.ts +121 -0
- witan_code/graph.py +271 -0
- witan_code/hooks.py +116 -0
- witan_code/indexer.py +1042 -0
- witan_code/maintenance.py +130 -0
- witan_code/package_map.py +74 -0
- witan_code/queries/bridge.gq +193 -0
- witan_code/queries/code_mutations.gq +81 -0
- witan_code/queries/code_read.gq +154 -0
- witan_code/queries/delete.gq +16 -0
- witan_code/queries_ts/bash.scm +9 -0
- witan_code/queries_ts/hcl.scm +16 -0
- witan_code/queries_ts/python.scm +29 -0
- witan_code/queries_ts/sql.scm +20 -0
- witan_code/queries_ts/typescript.scm +51 -0
- witan_code/queries_ts/yaml.scm +5 -0
- witan_code/repo.py +282 -0
- witan_code/schema/bridge-schema.pg +82 -0
- witan_code/schema/code-schema.pg +51 -0
- witan_code/server.py +859 -0
- witan_code/setup.py +230 -0
- witan_code/skills/witan-code/SKILL.md +97 -0
- witan_code/stitch.py +177 -0
- witan_code/store.py +116 -0
- witan_code/visualize.py +347 -0
- witan_code-0.2.0.dist-info/METADATA +476 -0
- witan_code-0.2.0.dist-info/RECORD +39 -0
- witan_code-0.2.0.dist-info/WHEEL +4 -0
- witan_code-0.2.0.dist-info/entry_points.txt +2 -0
witan_code/server.py
ADDED
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from fastmcp import Context, FastMCP
|
|
8
|
+
|
|
9
|
+
from . import bridge_extractors
|
|
10
|
+
from . import config as cfg_module
|
|
11
|
+
from . import elicit
|
|
12
|
+
from . import indexer
|
|
13
|
+
from . import repo as repo_module
|
|
14
|
+
from . import stitch
|
|
15
|
+
from . import store as store_module
|
|
16
|
+
from .graph import OmnigraphClient
|
|
17
|
+
|
|
18
|
+
# ── Startup ───────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
cfg = cfg_module.load()
|
|
21
|
+
|
|
22
|
+
mcp = FastMCP(
|
|
23
|
+
"witan-code",
|
|
24
|
+
instructions=(
|
|
25
|
+
"Tree-sitter code graph across one or many repositories: resolve symbol "
|
|
26
|
+
"definitions, callers, references, and change-impact, and trace cross-repo "
|
|
27
|
+
"contracts (env vars, endpoints, packages, services).\n\n"
|
|
28
|
+
"Symbol ids have the form `<repo>#<path/to/file.py>::<QualifiedName>` "
|
|
29
|
+
"(a whole module is `<repo>#<path/to/file.py>::<module>`). Name-based "
|
|
30
|
+
"tools return this id in the `symbol_id` field; pass it straight back to "
|
|
31
|
+
"the id-routed tools (code_find_references / code_callers / code_impact / "
|
|
32
|
+
"code_cross_repo_impact). Symbol ids compose with witan memory via soft "
|
|
33
|
+
"references.\n\n"
|
|
34
|
+
"Repo scoping: tools auto-detect the current repo from `.git/config`. Pass "
|
|
35
|
+
"`repo` (a canonical URI like `https://github.com/mitodl/ol-django`) to "
|
|
36
|
+
"scope to one repo, or omit it when outside any repo to fan out across "
|
|
37
|
+
"every indexed repo.\n\n"
|
|
38
|
+
"Branch semantics: name-routed tools (code_find_definition, "
|
|
39
|
+
"code_search_symbol, code_symbols_in_file) accept `branch`. The default "
|
|
40
|
+
"follows the current checkout's branch ONLY when querying the current "
|
|
41
|
+
"detected repo; when you pass `repo` for a different repo and omit "
|
|
42
|
+
"`branch`, you read that store's default (main) view — pass `branch` "
|
|
43
|
+
"explicitly to target another view. Id-routed tools (code_find_references "
|
|
44
|
+
"/ callers / impact / cross_repo_impact) take no `branch`: `symbol_id` "
|
|
45
|
+
"does not encode a branch, so they read the default view of the id's "
|
|
46
|
+
"repo store.\n\n"
|
|
47
|
+
"min_precision (`heuristic` default | `precise`) on the interface and "
|
|
48
|
+
"cross-repo tools: `precise` keeps only edges also confirmed by a "
|
|
49
|
+
"canonical-symbol join, suppressing false positives.\n\n"
|
|
50
|
+
"Calls/References edges are heuristic (syntactic name resolution), not a "
|
|
51
|
+
"precise call graph; code_find_references includes code_callers."
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Client cache keyed by "store path|branch" ("" = main).
|
|
56
|
+
_clients: dict[str, OmnigraphClient] = {}
|
|
57
|
+
|
|
58
|
+
# Known branch sets per store, with a short TTL: a cache miss re-lists (so a
|
|
59
|
+
# branch created after server start appears immediately) and expiry bounds how
|
|
60
|
+
# long a deleted branch (e.g. `branches --prune`) keeps routing reads before
|
|
61
|
+
# they degrade back to main.
|
|
62
|
+
_store_branches: dict[str, tuple[float, frozenset[str]]] = {}
|
|
63
|
+
_BRANCH_CACHE_TTL = 30.0
|
|
64
|
+
|
|
65
|
+
# repo_module.detect()/store_branch() spawn git subprocesses, and every
|
|
66
|
+
# per-repo AND bridge read that follows "the current checkout" calls them.
|
|
67
|
+
# A short TTL amortizes a burst of tool calls within one agent turn (the
|
|
68
|
+
# common case) while still picking up a branch switch within a couple
|
|
69
|
+
# seconds — long-lived enough to matter for latency, short enough that no
|
|
70
|
+
# test needs to know about it (git state changes mid-test would otherwise
|
|
71
|
+
# read stale for the TTL window; tests that switch branches mid-test must
|
|
72
|
+
# call ``_git_context.clear()``).
|
|
73
|
+
_git_context: dict[str, tuple[float, str | None]] = {}
|
|
74
|
+
_GIT_CONTEXT_TTL = 2.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _cached_git(key: str, fn) -> str | None:
|
|
78
|
+
cached = _git_context.get(key)
|
|
79
|
+
if cached is not None:
|
|
80
|
+
stamp, val = cached
|
|
81
|
+
if time.monotonic() - stamp < _GIT_CONTEXT_TTL:
|
|
82
|
+
return val
|
|
83
|
+
val = fn()
|
|
84
|
+
_git_context[key] = (time.monotonic(), val)
|
|
85
|
+
return val
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cached_detect() -> str | None:
|
|
89
|
+
return _cached_git("detect", repo_module.detect)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cached_store_branch() -> str | None:
|
|
93
|
+
return _cached_git("store_branch", repo_module.store_branch)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _client_for_path(path, branch: str | None = None) -> OmnigraphClient:
|
|
97
|
+
key = f"{path}|{branch or ''}"
|
|
98
|
+
if key not in _clients:
|
|
99
|
+
_clients[key] = OmnigraphClient(str(path), cfg.queries_dir, branch=branch)
|
|
100
|
+
return _clients[key]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _branch_in_store(path, branch: str) -> bool:
|
|
104
|
+
key = str(path)
|
|
105
|
+
cached = _store_branches.get(key)
|
|
106
|
+
if cached is not None:
|
|
107
|
+
stamp, branches = cached
|
|
108
|
+
if time.monotonic() - stamp < _BRANCH_CACHE_TTL and branch in branches:
|
|
109
|
+
return True
|
|
110
|
+
try:
|
|
111
|
+
branches = frozenset(_client_for_path(path).list_branches())
|
|
112
|
+
except Exception: # noqa: BLE001 — degrade to main on any listing failure
|
|
113
|
+
return False
|
|
114
|
+
_store_branches[key] = (time.monotonic(), branches)
|
|
115
|
+
return branch in branches
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _resolve_branch(store, repo: str, requested: str | None) -> str | None:
|
|
119
|
+
"""Effective omnigraph branch for a read: None = the store's main branch.
|
|
120
|
+
|
|
121
|
+
An explicit ``requested`` branch is used when it exists in the store
|
|
122
|
+
(else main — degrade, don't error). With no request, a query against the
|
|
123
|
+
*current* repo follows the checkout's branch so an agent working on a
|
|
124
|
+
feature branch sees its own in-flight view by default.
|
|
125
|
+
"""
|
|
126
|
+
if requested:
|
|
127
|
+
# Same git→store mapping as indexing, so a request for branch "main"
|
|
128
|
+
# in a master-default repo routes to its "_main" store branch rather
|
|
129
|
+
# than the store's default view.
|
|
130
|
+
b = repo_module.branch_store_name(requested)
|
|
131
|
+
return b if _branch_in_store(store, b) else None
|
|
132
|
+
if repo == _cached_detect():
|
|
133
|
+
b = _cached_store_branch()
|
|
134
|
+
if b and _branch_in_store(store, b):
|
|
135
|
+
return b
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _client_for_repo(repo: str, branch: str | None = None) -> OmnigraphClient | None:
|
|
140
|
+
"""Client for a specific repo's store (origin scoping), or None if absent."""
|
|
141
|
+
store = store_module.store_for_repo(repo, cfg)
|
|
142
|
+
if not store.exists():
|
|
143
|
+
return None
|
|
144
|
+
return _client_for_path(store, _resolve_branch(store, repo, branch))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _client_for_symbol(symbol_id: str) -> OmnigraphClient | None:
|
|
148
|
+
"""Route a `repo#path::Name` id to the store of its repo prefix."""
|
|
149
|
+
return _client_for_repo(symbol_id.split("#", 1)[0])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _all_clients() -> list[OmnigraphClient]:
|
|
153
|
+
"""A client per indexed per-repo store (excludes the shared bridge store)."""
|
|
154
|
+
if not cfg.code_dir.exists():
|
|
155
|
+
return []
|
|
156
|
+
bridge = store_module.bridge_store(cfg).name
|
|
157
|
+
return [
|
|
158
|
+
_client_for_path(p)
|
|
159
|
+
for p in sorted(cfg.code_dir.glob("*.omni"))
|
|
160
|
+
if p.name != bridge
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _resolve_clients(
|
|
165
|
+
repo: str | None, branch: str | None = None
|
|
166
|
+
) -> list[OmnigraphClient]:
|
|
167
|
+
"""Stores to query: the named repo → the current repo → all repos (fan-out).
|
|
168
|
+
|
|
169
|
+
Rows already carry their `repo`, so fan-out results are self-tagging.
|
|
170
|
+
``branch`` scopes the single-repo cases; fan-out always reads main.
|
|
171
|
+
"""
|
|
172
|
+
if repo:
|
|
173
|
+
client = _client_for_repo(repo, branch)
|
|
174
|
+
return [client] if client else []
|
|
175
|
+
slug = _cached_detect()
|
|
176
|
+
if slug:
|
|
177
|
+
client = _client_for_repo(slug, branch)
|
|
178
|
+
if client is not None:
|
|
179
|
+
return [client]
|
|
180
|
+
return _all_clients()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _bridge_branch_name(repo: str, branch: str) -> str:
|
|
184
|
+
return f"{cfg_module.sanitize_slug(repo)}/{branch}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _resolve_bridge_branch(store, repo: str | None) -> str | None:
|
|
188
|
+
"""Effective bridge branch for reads scoped to ``repo``: None = bridge main.
|
|
189
|
+
|
|
190
|
+
Mirrors ``_resolve_branch``'s "current repo only" rule: the overlay is a
|
|
191
|
+
*specific* repo's in-flight bindings on top of everyone else's main
|
|
192
|
+
(docs/BRANCH_INDEXING.md § Bridge store), so it only applies
|
|
193
|
+
automatically when ``repo`` is the checkout's own detected repo — an
|
|
194
|
+
agent asking about some other repo's symbol while sitting elsewhere
|
|
195
|
+
should not silently pick up an unrelated overlay branch.
|
|
196
|
+
"""
|
|
197
|
+
if repo is None or repo != _cached_detect():
|
|
198
|
+
return None
|
|
199
|
+
branch = _cached_store_branch()
|
|
200
|
+
if not branch:
|
|
201
|
+
return None
|
|
202
|
+
bridge_branch = _bridge_branch_name(repo, branch)
|
|
203
|
+
return bridge_branch if _branch_in_store(store, bridge_branch) else None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _bridge_client(repo: str | None = None) -> OmnigraphClient | None:
|
|
207
|
+
"""Resolve the shared cross-repo bridge client, or None if not built yet.
|
|
208
|
+
|
|
209
|
+
When ``repo`` is omitted, auto-detects the current checkout's repo. Reads
|
|
210
|
+
are scoped to that repo's bridge branch overlay when it's on a
|
|
211
|
+
non-default git branch that has already been written to the bridge;
|
|
212
|
+
otherwise (no repo context, on the default branch, or nothing written to
|
|
213
|
+
that branch's overlay yet) reads see bridge main.
|
|
214
|
+
"""
|
|
215
|
+
store = store_module.bridge_store(cfg)
|
|
216
|
+
if not store.exists():
|
|
217
|
+
return None
|
|
218
|
+
if repo is None:
|
|
219
|
+
repo = _cached_detect()
|
|
220
|
+
return _client_for_path(store, _resolve_bridge_branch(store, repo))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def _confirm_and_reindex(
|
|
224
|
+
ctx: Context | None, repo: str
|
|
225
|
+
) -> OmnigraphClient | None:
|
|
226
|
+
"""Offer to index ``repo`` now if its store is missing AND it's the repo
|
|
227
|
+
we're actually sitting in (code_reindex has no way to index anything
|
|
228
|
+
else). Returns a fresh client on an accepted+successful index, else None
|
|
229
|
+
— callers must fall back to their existing shaped-empty return exactly as
|
|
230
|
+
before elicitation existed.
|
|
231
|
+
"""
|
|
232
|
+
if repo != _cached_detect():
|
|
233
|
+
return None
|
|
234
|
+
ok = await elicit.confirm(
|
|
235
|
+
ctx,
|
|
236
|
+
f"No code graph indexed yet for {repo}. Index it now? "
|
|
237
|
+
"(may take a while on a large repo)",
|
|
238
|
+
default_when_unsupported=False,
|
|
239
|
+
)
|
|
240
|
+
if not ok:
|
|
241
|
+
return None
|
|
242
|
+
target = repo_module.root() or Path.cwd()
|
|
243
|
+
try:
|
|
244
|
+
await asyncio.to_thread(indexer.index_path, target, force=False, config=cfg)
|
|
245
|
+
except Exception: # noqa: BLE001 — indexing failure degrades like a missing store
|
|
246
|
+
return None
|
|
247
|
+
return _client_for_repo(repo)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def _confirm_and_reindex_bridge(ctx: Context | None) -> OmnigraphClient | None:
|
|
251
|
+
"""Offer to index the CURRENT repo when the shared cross-repo bridge store
|
|
252
|
+
doesn't exist at all yet (indexing any repo creates/populates it). Same
|
|
253
|
+
additive fallback contract as ``_confirm_and_reindex``.
|
|
254
|
+
"""
|
|
255
|
+
repo = _cached_detect()
|
|
256
|
+
if repo is None:
|
|
257
|
+
return None
|
|
258
|
+
ok = await elicit.confirm(
|
|
259
|
+
ctx,
|
|
260
|
+
f"No cross-repo graph indexed yet. Index the current repo ({repo}) "
|
|
261
|
+
"now to start building it?",
|
|
262
|
+
default_when_unsupported=False,
|
|
263
|
+
)
|
|
264
|
+
if not ok:
|
|
265
|
+
return None
|
|
266
|
+
target = repo_module.root() or Path.cwd()
|
|
267
|
+
try:
|
|
268
|
+
await asyncio.to_thread(indexer.index_path, target, force=False, config=cfg)
|
|
269
|
+
except Exception: # noqa: BLE001 — indexing failure degrades like a missing store
|
|
270
|
+
return None
|
|
271
|
+
return _bridge_client()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _fan_out(clients: list[OmnigraphClient], fn) -> list[dict]:
|
|
275
|
+
"""Run ``fn(client) -> list[dict]`` across clients in parallel threads.
|
|
276
|
+
|
|
277
|
+
Falls back to a simple loop for 0–1 clients to avoid thread-pool overhead
|
|
278
|
+
on the common single-repo case.
|
|
279
|
+
"""
|
|
280
|
+
if len(clients) <= 1:
|
|
281
|
+
return [row for c in clients for row in fn(c)]
|
|
282
|
+
out: list[dict] = []
|
|
283
|
+
errors: list[Exception] = []
|
|
284
|
+
with ThreadPoolExecutor(max_workers=min(len(clients), 8)) as pool:
|
|
285
|
+
futures = {pool.submit(fn, c): c for c in clients}
|
|
286
|
+
for f in as_completed(futures):
|
|
287
|
+
try:
|
|
288
|
+
out.extend(f.result())
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
errors.append(exc)
|
|
291
|
+
if errors and not out:
|
|
292
|
+
raise errors[0]
|
|
293
|
+
return out
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _as_symbol_id(rows: list[dict]) -> list[dict]:
|
|
297
|
+
"""Rename the identifier field `slug` → `symbol_id` on returned Symbol rows.
|
|
298
|
+
|
|
299
|
+
Queries project the Symbol's identifier as `slug`, but every tool that
|
|
300
|
+
consumes an id names its parameter `symbol_id`. Renaming at the output
|
|
301
|
+
boundary makes the value round-trip under one name (definition → references)
|
|
302
|
+
without changing the stored field or the internal traversal code.
|
|
303
|
+
"""
|
|
304
|
+
return [{"symbol_id" if k == "slug" else k: v for k, v in r.items()} for r in rows]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _file_id(path: str, repo: str) -> str:
|
|
308
|
+
"""Build the CodeFile id (`repo#relpath`) for a repo-relative or abs path."""
|
|
309
|
+
base = repo_module.root() or Path.cwd()
|
|
310
|
+
p = Path(path)
|
|
311
|
+
try:
|
|
312
|
+
rel = (
|
|
313
|
+
p.resolve().relative_to(base.resolve()).as_posix()
|
|
314
|
+
if p.is_absolute() or p.exists()
|
|
315
|
+
else p.as_posix()
|
|
316
|
+
)
|
|
317
|
+
except ValueError:
|
|
318
|
+
rel = p.as_posix()
|
|
319
|
+
return f"{repo}#{rel}"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ── Tools ─────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@mcp.tool
|
|
326
|
+
async def code_find_definition(
|
|
327
|
+
name: str,
|
|
328
|
+
repo: str | None = None,
|
|
329
|
+
branch: str | None = None,
|
|
330
|
+
ctx: Context | None = None,
|
|
331
|
+
) -> list[dict]:
|
|
332
|
+
"""
|
|
333
|
+
Find symbol definitions whose name or qualified name matches ``name``.
|
|
334
|
+
|
|
335
|
+
Returns matching symbols (function, method, class, module, …) with their
|
|
336
|
+
``symbol_id``, file, line range, signature, docstring, and ``repo`` of
|
|
337
|
+
origin. Feed a returned ``symbol_id`` to code_find_references / code_callers
|
|
338
|
+
/ code_impact.
|
|
339
|
+
|
|
340
|
+
Parameters
|
|
341
|
+
----------
|
|
342
|
+
name:
|
|
343
|
+
Bare name (``run``) or qualified name (``Service.run``).
|
|
344
|
+
branch:
|
|
345
|
+
Git branch whose indexed view to query (e.g. another agent's in-flight
|
|
346
|
+
branch). Defaults to the checkout's branch when querying the current
|
|
347
|
+
repo; when ``repo`` names a different repo and ``branch`` is omitted,
|
|
348
|
+
reads that store's default (main) view.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
def _query(client: OmnigraphClient) -> list[dict]:
|
|
352
|
+
rows = client.read(
|
|
353
|
+
"code_read.gq", "find_by_qualified_name", {"qualified_name": name}
|
|
354
|
+
)
|
|
355
|
+
return rows or client.read("code_read.gq", "find_by_name", {"name": name})
|
|
356
|
+
|
|
357
|
+
matches = _as_symbol_id(_fan_out(_resolve_clients(repo, branch), _query))
|
|
358
|
+
|
|
359
|
+
if repo is None:
|
|
360
|
+
repos = sorted({m["repo"] for m in matches if m.get("repo")})
|
|
361
|
+
if len(repos) > 1:
|
|
362
|
+
chosen = await elicit.choose_repo(
|
|
363
|
+
ctx,
|
|
364
|
+
f"'{name}' matches in {len(repos)} repos: {', '.join(repos)}. "
|
|
365
|
+
"Reply with one repo URI to narrow the results, or leave blank "
|
|
366
|
+
"to see every match.",
|
|
367
|
+
repos,
|
|
368
|
+
)
|
|
369
|
+
if chosen is not None:
|
|
370
|
+
matches = [m for m in matches if m.get("repo") == chosen]
|
|
371
|
+
|
|
372
|
+
return matches
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@mcp.tool
|
|
376
|
+
async def code_find_references(
|
|
377
|
+
symbol_id: str, ctx: Context | None = None
|
|
378
|
+
) -> list[dict]:
|
|
379
|
+
"""
|
|
380
|
+
Find symbols that reference or call ``symbol_id`` (incoming edges).
|
|
381
|
+
|
|
382
|
+
Supersets code_callers (references includes calls); use code_callers when
|
|
383
|
+
you want calls only. Heuristic — may miss or over-report.
|
|
384
|
+
"""
|
|
385
|
+
client = _client_for_symbol(symbol_id)
|
|
386
|
+
if client is None:
|
|
387
|
+
client = await _confirm_and_reindex(ctx, symbol_id.split("#", 1)[0])
|
|
388
|
+
if client is None:
|
|
389
|
+
return []
|
|
390
|
+
refs = client.read("code_read.gq", "referencers", {"id": symbol_id})
|
|
391
|
+
callers = client.read("code_read.gq", "callers", {"id": symbol_id})
|
|
392
|
+
seen = {r["slug"] for r in refs}
|
|
393
|
+
return _as_symbol_id(refs + [c for c in callers if c["slug"] not in seen])
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@mcp.tool
|
|
397
|
+
async def code_callers(symbol_id: str, ctx: Context | None = None) -> list[dict]:
|
|
398
|
+
"""
|
|
399
|
+
Find symbols that call ``symbol_id`` (incoming Calls edges).
|
|
400
|
+
|
|
401
|
+
Heuristic name-resolution based; not a precise call graph. For calls plus
|
|
402
|
+
other references use code_find_references.
|
|
403
|
+
"""
|
|
404
|
+
client = _client_for_symbol(symbol_id)
|
|
405
|
+
if client is None:
|
|
406
|
+
client = await _confirm_and_reindex(ctx, symbol_id.split("#", 1)[0])
|
|
407
|
+
if client is None:
|
|
408
|
+
return []
|
|
409
|
+
return _as_symbol_id(client.read("code_read.gq", "callers", {"id": symbol_id}))
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@mcp.tool
|
|
413
|
+
async def code_impact(
|
|
414
|
+
symbol_id: str,
|
|
415
|
+
max_depth: int = 5,
|
|
416
|
+
max_nodes: int = 200,
|
|
417
|
+
ctx: Context | None = None,
|
|
418
|
+
) -> dict:
|
|
419
|
+
"""
|
|
420
|
+
Estimate change impact: the transitive set of callers of ``symbol_id``.
|
|
421
|
+
|
|
422
|
+
Performs a breadth-first traversal over Calls edges, capping at ``max_depth``
|
|
423
|
+
levels and ``max_nodes`` total. Returns the reached symbols and whether the
|
|
424
|
+
traversal was truncated. Heuristic — see code_callers.
|
|
425
|
+
|
|
426
|
+
Parameters
|
|
427
|
+
----------
|
|
428
|
+
max_depth:
|
|
429
|
+
Maximum BFS depth (default 5).
|
|
430
|
+
max_nodes:
|
|
431
|
+
Cap on total symbols returned (default 200).
|
|
432
|
+
"""
|
|
433
|
+
if _client_for_symbol(symbol_id) is None:
|
|
434
|
+
if await _confirm_and_reindex(ctx, symbol_id.split("#", 1)[0]) is None:
|
|
435
|
+
return {"root": symbol_id, "impacted": [], "truncated": False}
|
|
436
|
+
|
|
437
|
+
visited: dict[str, dict] = {}
|
|
438
|
+
frontier = [symbol_id]
|
|
439
|
+
truncated = False
|
|
440
|
+
depth = 0
|
|
441
|
+
|
|
442
|
+
while frontier and depth < max_depth:
|
|
443
|
+
next_frontier: list[str] = []
|
|
444
|
+
for sid in frontier:
|
|
445
|
+
# Route each node to its repo's store, so impact can cross repos.
|
|
446
|
+
client = _client_for_symbol(sid)
|
|
447
|
+
if client is None:
|
|
448
|
+
continue
|
|
449
|
+
for caller in client.read("code_read.gq", "callers", {"id": sid}):
|
|
450
|
+
cid = caller["slug"]
|
|
451
|
+
if cid in visited or cid == symbol_id:
|
|
452
|
+
continue
|
|
453
|
+
if len(visited) >= max_nodes:
|
|
454
|
+
truncated = True
|
|
455
|
+
break
|
|
456
|
+
caller["depth"] = depth + 1
|
|
457
|
+
visited[cid] = caller
|
|
458
|
+
next_frontier.append(cid)
|
|
459
|
+
if truncated:
|
|
460
|
+
break
|
|
461
|
+
if truncated:
|
|
462
|
+
break
|
|
463
|
+
frontier = next_frontier
|
|
464
|
+
depth += 1
|
|
465
|
+
|
|
466
|
+
if frontier and depth >= max_depth:
|
|
467
|
+
truncated = True
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
"root": symbol_id,
|
|
471
|
+
"impacted": _as_symbol_id(list(visited.values())),
|
|
472
|
+
"truncated": truncated,
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@mcp.tool
|
|
477
|
+
async def code_symbols_in_file(
|
|
478
|
+
path: str, repo: str | None = None, ctx: Context | None = None
|
|
479
|
+
) -> list[dict]:
|
|
480
|
+
"""
|
|
481
|
+
List symbols defined in ``path`` (repo-relative or absolute).
|
|
482
|
+
|
|
483
|
+
A file is inherently repo-local, so this resolves a single repo (no fan-out):
|
|
484
|
+
the given ``repo`` or, if omitted, the current repo.
|
|
485
|
+
|
|
486
|
+
Parameters
|
|
487
|
+
----------
|
|
488
|
+
path:
|
|
489
|
+
Source file path.
|
|
490
|
+
repo:
|
|
491
|
+
Canonical repo URI the file belongs to. Defaults to the current repo.
|
|
492
|
+
"""
|
|
493
|
+
slug = repo or _cached_detect()
|
|
494
|
+
if slug is None:
|
|
495
|
+
slug = await elicit.text(
|
|
496
|
+
ctx,
|
|
497
|
+
"No repo detected for this file (not in a git repo, or no remote). "
|
|
498
|
+
"Enter its canonical repo URI (e.g. https://github.com/org/name):",
|
|
499
|
+
default="",
|
|
500
|
+
)
|
|
501
|
+
if not slug:
|
|
502
|
+
return []
|
|
503
|
+
client = _client_for_repo(slug)
|
|
504
|
+
if client is None:
|
|
505
|
+
client = await _confirm_and_reindex(ctx, slug)
|
|
506
|
+
if client is None:
|
|
507
|
+
return []
|
|
508
|
+
return _as_symbol_id(
|
|
509
|
+
client.read(
|
|
510
|
+
"code_read.gq", "symbols_in_file", {"file_id": _file_id(path, slug)}
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
SymbolKind = Literal[
|
|
516
|
+
"function",
|
|
517
|
+
"method",
|
|
518
|
+
"class",
|
|
519
|
+
"module",
|
|
520
|
+
"variable",
|
|
521
|
+
"interface",
|
|
522
|
+
"type",
|
|
523
|
+
"enum",
|
|
524
|
+
"key",
|
|
525
|
+
"table",
|
|
526
|
+
"cte",
|
|
527
|
+
"block",
|
|
528
|
+
]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@mcp.tool
|
|
532
|
+
def code_search_symbol(
|
|
533
|
+
query: str,
|
|
534
|
+
kind: SymbolKind | None = None,
|
|
535
|
+
repo: str | None = None,
|
|
536
|
+
branch: str | None = None,
|
|
537
|
+
) -> list[dict]:
|
|
538
|
+
"""
|
|
539
|
+
Full-text/substring search over symbol qualified names (BM25-ranked).
|
|
540
|
+
|
|
541
|
+
Use to locate a symbol when you only remember part of its name. Each result
|
|
542
|
+
carries its ``symbol_id`` and ``repo`` of origin. BM25 ranking is per-store;
|
|
543
|
+
cross-repo fan-out concatenates each store's ranked results.
|
|
544
|
+
|
|
545
|
+
Parameters
|
|
546
|
+
----------
|
|
547
|
+
query:
|
|
548
|
+
Search terms matched against ``qualified_name``.
|
|
549
|
+
kind:
|
|
550
|
+
Optional filter to a single symbol kind: ``function``, ``method``,
|
|
551
|
+
``class``, ``module``, ``variable``, ``interface``, ``type``, ``enum``,
|
|
552
|
+
``key``, ``table``, ``cte``, or ``block``. Pass e.g. ``kind="function"``
|
|
553
|
+
to exclude the many YAML ``key`` symbols when searching for code.
|
|
554
|
+
branch:
|
|
555
|
+
Git branch whose indexed view to query. Defaults to the checkout's
|
|
556
|
+
branch when querying the current repo; when ``repo`` names a different
|
|
557
|
+
repo and ``branch`` is omitted, reads that store's default (main) view.
|
|
558
|
+
"""
|
|
559
|
+
|
|
560
|
+
def _query(client: OmnigraphClient) -> list[dict]:
|
|
561
|
+
if kind:
|
|
562
|
+
return client.read(
|
|
563
|
+
"code_read.gq", "search_symbols_by_kind", {"query": query, "kind": kind}
|
|
564
|
+
)
|
|
565
|
+
return client.read("code_read.gq", "search_symbols", {"query": query})
|
|
566
|
+
|
|
567
|
+
return _as_symbol_id(_fan_out(_resolve_clients(repo, branch), _query))
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
BindingKind = Literal["env_var", "package", "service", "endpoint"]
|
|
571
|
+
PrecisionTier = Literal["precise", "heuristic", "fuzzy"]
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _precise_pairs(repo: str | None = None) -> frozenset:
|
|
575
|
+
"""(consumer_repo, provider_repo, kind, key_norm) covered by a Stage-2 join."""
|
|
576
|
+
client = _bridge_client(repo=repo)
|
|
577
|
+
if client is None:
|
|
578
|
+
return frozenset()
|
|
579
|
+
from . import edges as edges_module
|
|
580
|
+
|
|
581
|
+
rows = client.read("bridge.gq", "all_repo_symbols", {})
|
|
582
|
+
return edges_module.precise_pairs(rows)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _filter_by_precision(rows: list[dict], min_precision: str) -> list[dict]:
|
|
586
|
+
if not rows or min_precision != "precise":
|
|
587
|
+
return rows
|
|
588
|
+
key_norms = {(kind, key_norm) for _, _, kind, key_norm in _precise_pairs()}
|
|
589
|
+
return [r for r in rows if (r.get("kind"), r.get("key_norm")) in key_norms]
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@mcp.tool
|
|
593
|
+
async def code_interface_providers(
|
|
594
|
+
kind: BindingKind,
|
|
595
|
+
key: str,
|
|
596
|
+
min_precision: PrecisionTier = "heuristic",
|
|
597
|
+
ctx: Context | None = None,
|
|
598
|
+
) -> list[dict]:
|
|
599
|
+
"""
|
|
600
|
+
Find repos that PROVIDE a cross-repo contract ``key`` of ``kind``.
|
|
601
|
+
|
|
602
|
+
Spans every indexed repo via the shared bridge store. Examples:
|
|
603
|
+
``code_interface_providers("env_var", "MITOL_APP_BASE_URL")`` returns the
|
|
604
|
+
ol-infrastructure binding that sets it; ``("endpoint", "/api/v1/courses/")``
|
|
605
|
+
returns the Django/OpenAPI route that serves it.
|
|
606
|
+
|
|
607
|
+
Parameters
|
|
608
|
+
----------
|
|
609
|
+
kind:
|
|
610
|
+
``env_var``, ``package``, ``service``, or ``endpoint``.
|
|
611
|
+
key:
|
|
612
|
+
The contract value. For ``endpoint`` a raw path is accepted and
|
|
613
|
+
normalized (path params collapse to ``{}``).
|
|
614
|
+
min_precision:
|
|
615
|
+
``heuristic`` (default) | ``precise`` — see server instructions.
|
|
616
|
+
"""
|
|
617
|
+
return await _bindings_by_role(kind, key, "provider", min_precision, ctx)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
@mcp.tool
|
|
621
|
+
async def code_interface_consumers(
|
|
622
|
+
kind: BindingKind,
|
|
623
|
+
key: str,
|
|
624
|
+
min_precision: PrecisionTier = "heuristic",
|
|
625
|
+
ctx: Context | None = None,
|
|
626
|
+
) -> list[dict]:
|
|
627
|
+
"""
|
|
628
|
+
Find repos that CONSUME a cross-repo contract ``key`` of ``kind``.
|
|
629
|
+
|
|
630
|
+
The mirror of ``code_interface_providers`` — e.g. which repos read an env var
|
|
631
|
+
or call an API endpoint. Spans every indexed repo.
|
|
632
|
+
|
|
633
|
+
Parameters
|
|
634
|
+
----------
|
|
635
|
+
kind:
|
|
636
|
+
``env_var``, ``package``, ``service``, or ``endpoint``.
|
|
637
|
+
key:
|
|
638
|
+
The contract value (``endpoint`` paths are normalized).
|
|
639
|
+
min_precision:
|
|
640
|
+
``heuristic`` (default) | ``precise`` — see server instructions.
|
|
641
|
+
"""
|
|
642
|
+
return await _bindings_by_role(kind, key, "consumer", min_precision, ctx)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
async def _bindings_by_role(
|
|
646
|
+
kind: str,
|
|
647
|
+
key: str,
|
|
648
|
+
role: str,
|
|
649
|
+
min_precision: str = "heuristic",
|
|
650
|
+
ctx: Context | None = None,
|
|
651
|
+
) -> list[dict]:
|
|
652
|
+
client = _bridge_client()
|
|
653
|
+
if client is None:
|
|
654
|
+
client = await _confirm_and_reindex_bridge(ctx)
|
|
655
|
+
if client is None:
|
|
656
|
+
return []
|
|
657
|
+
key_norm = bridge_extractors.normalize_key(kind, key)
|
|
658
|
+
rows = client.read(
|
|
659
|
+
"bridge.gq",
|
|
660
|
+
"bindings_by_key_role",
|
|
661
|
+
{"kind": kind, "key_norm": key_norm, "role": role},
|
|
662
|
+
)
|
|
663
|
+
return _filter_by_precision(rows, min_precision)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@mcp.tool
|
|
667
|
+
async def code_cross_repo_impact(
|
|
668
|
+
symbol_id: str,
|
|
669
|
+
min_precision: PrecisionTier = "heuristic",
|
|
670
|
+
ctx: Context | None = None,
|
|
671
|
+
) -> dict:
|
|
672
|
+
"""
|
|
673
|
+
Find the cross-repo surface of a symbol.
|
|
674
|
+
|
|
675
|
+
Looks up the interface bindings attributed to ``symbol_id`` (env vars it
|
|
676
|
+
reads, packages it imports, endpoints it serves/calls), then returns every
|
|
677
|
+
binding for those same contracts in OTHER repos — i.e. who else is coupled to
|
|
678
|
+
this symbol across the SOA. Returns a shaped empty result when the symbol has
|
|
679
|
+
no cross-repo surface or the bridge store does not exist yet.
|
|
680
|
+
|
|
681
|
+
Generic env vars (DEBUG, PORT, …) are flagged ``generic`` and excluded from
|
|
682
|
+
the cross-repo fan-out so a trivial edit doesn't appear to touch every repo.
|
|
683
|
+
|
|
684
|
+
Parameters
|
|
685
|
+
----------
|
|
686
|
+
min_precision:
|
|
687
|
+
``heuristic`` (default) | ``precise`` — see server instructions.
|
|
688
|
+
"""
|
|
689
|
+
empty = {"symbol_id": symbol_id, "bindings": [], "cross_repo": []}
|
|
690
|
+
own_repo = symbol_id.split("#", 1)[0]
|
|
691
|
+
# Scope the bridge read to the SYMBOL's own repo, not blindly the cwd:
|
|
692
|
+
# _bridge_client only applies an overlay when repo matches the checkout
|
|
693
|
+
# (it can't discover another repo's branch state from here regardless),
|
|
694
|
+
# so this just avoids picking up an unrelated overlay from the caller's
|
|
695
|
+
# own cwd when asking about a symbol in some other repo.
|
|
696
|
+
client = _bridge_client(repo=own_repo)
|
|
697
|
+
if client is None:
|
|
698
|
+
client = await _confirm_and_reindex_bridge(ctx)
|
|
699
|
+
if client is None:
|
|
700
|
+
return empty
|
|
701
|
+
|
|
702
|
+
own = client.read("bridge.gq", "bindings_for_symbol", {"symbol_id": symbol_id})
|
|
703
|
+
if not own:
|
|
704
|
+
return empty
|
|
705
|
+
|
|
706
|
+
pairs = _precise_pairs(repo=own_repo) if min_precision == "precise" else None
|
|
707
|
+
seen: set[str] = set()
|
|
708
|
+
cross: list[dict] = []
|
|
709
|
+
for b in own:
|
|
710
|
+
if b.get("generic"):
|
|
711
|
+
continue
|
|
712
|
+
for other in client.read(
|
|
713
|
+
"bridge.gq",
|
|
714
|
+
"bindings_by_key",
|
|
715
|
+
{"kind": b["kind"], "key_norm": b["key_norm"]},
|
|
716
|
+
):
|
|
717
|
+
if other["repo"] == own_repo or other["slug"] in seen:
|
|
718
|
+
continue
|
|
719
|
+
if pairs is not None and not (
|
|
720
|
+
(own_repo, other["repo"], b["kind"], b["key_norm"]) in pairs
|
|
721
|
+
or (other["repo"], own_repo, b["kind"], b["key_norm"]) in pairs
|
|
722
|
+
):
|
|
723
|
+
continue
|
|
724
|
+
seen.add(other["slug"])
|
|
725
|
+
cross.append(other)
|
|
726
|
+
return {"symbol_id": symbol_id, "bindings": own, "cross_repo": cross}
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
@mcp.tool
|
|
730
|
+
async def code_precise_edges(
|
|
731
|
+
repo: str | None = None, ctx: Context | None = None
|
|
732
|
+
) -> list[dict]:
|
|
733
|
+
"""
|
|
734
|
+
Precise cross-repo edges resolved by canonical symbol string.
|
|
735
|
+
|
|
736
|
+
Joins every repo's unresolved external-symbol references against other
|
|
737
|
+
repos' exported symbols — a read-time join, distinct from the coarser
|
|
738
|
+
(kind, key_norm) heuristic grouping ``code_interface_*`` use. Each edge
|
|
739
|
+
carries ``match_count`` (how many providers a reference joined to) and
|
|
740
|
+
``ambiguous_version`` (true when more than one provider survives version
|
|
741
|
+
disambiguation); filter to ``preferred`` edges to narrow a fan-out to its
|
|
742
|
+
best candidate(s). A reference with no precise match shows up in
|
|
743
|
+
``code_unresolved_symbols`` instead.
|
|
744
|
+
|
|
745
|
+
Parameters
|
|
746
|
+
----------
|
|
747
|
+
repo:
|
|
748
|
+
Keep only edges whose consumer OR provider is this repo. Omit to see
|
|
749
|
+
every precise edge in the bridge store.
|
|
750
|
+
"""
|
|
751
|
+
client = _bridge_client(repo=repo)
|
|
752
|
+
if client is None:
|
|
753
|
+
client = await _confirm_and_reindex_bridge(ctx)
|
|
754
|
+
if client is None:
|
|
755
|
+
return []
|
|
756
|
+
rows = client.read("bridge.gq", "all_repo_symbols", {})
|
|
757
|
+
edges, _ = stitch.resolve(rows)
|
|
758
|
+
return [
|
|
759
|
+
e.as_dict()
|
|
760
|
+
for e in edges
|
|
761
|
+
if repo is None or repo in (e.consumer_repo, e.provider_repo)
|
|
762
|
+
]
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
@mcp.tool
|
|
766
|
+
async def code_unresolved_symbols(
|
|
767
|
+
repo: str | None = None, ctx: Context | None = None
|
|
768
|
+
) -> list[dict]:
|
|
769
|
+
"""
|
|
770
|
+
External symbol references with no precise cross-repo match.
|
|
771
|
+
|
|
772
|
+
Surfaces indexing-coverage gaps: a repo consumes a contract (env var,
|
|
773
|
+
package, endpoint, service) that no indexed repo currently exports —
|
|
774
|
+
either the provider repo isn't indexed yet, or the reference genuinely has
|
|
775
|
+
no provider in this SOA. These still get a heuristic-tier chance via
|
|
776
|
+
``code_interface_consumers``/``_providers``; this tool finds what's NOT
|
|
777
|
+
precisely resolved.
|
|
778
|
+
|
|
779
|
+
Parameters
|
|
780
|
+
----------
|
|
781
|
+
repo:
|
|
782
|
+
Keep only unresolved references from this consumer repo. Omit to see
|
|
783
|
+
every unresolved reference in the bridge store.
|
|
784
|
+
"""
|
|
785
|
+
client = _bridge_client(repo=repo)
|
|
786
|
+
if client is None:
|
|
787
|
+
client = await _confirm_and_reindex_bridge(ctx)
|
|
788
|
+
if client is None:
|
|
789
|
+
return []
|
|
790
|
+
rows = client.read("bridge.gq", "all_repo_symbols", {})
|
|
791
|
+
_, unresolved = stitch.resolve(rows)
|
|
792
|
+
return [r for r in unresolved if repo is None or r["repo"] == repo]
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
@mcp.tool
|
|
796
|
+
async def code_interface_search(
|
|
797
|
+
query: str,
|
|
798
|
+
kind: BindingKind | None = None,
|
|
799
|
+
min_precision: PrecisionTier = "heuristic",
|
|
800
|
+
ctx: Context | None = None,
|
|
801
|
+
) -> list[dict]:
|
|
802
|
+
"""
|
|
803
|
+
BM25 search over cross-repo interface bindings (by normalized key).
|
|
804
|
+
|
|
805
|
+
Use to discover contract keys when you only remember part of a name —
|
|
806
|
+
e.g. ``code_interface_search("BASE_URL")`` or ``("courses", kind="endpoint")``.
|
|
807
|
+
|
|
808
|
+
Parameters
|
|
809
|
+
----------
|
|
810
|
+
query:
|
|
811
|
+
Search terms matched against the normalized key.
|
|
812
|
+
kind:
|
|
813
|
+
Optional filter to one kind.
|
|
814
|
+
min_precision:
|
|
815
|
+
``heuristic`` (default) | ``precise`` — see server instructions.
|
|
816
|
+
"""
|
|
817
|
+
client = _bridge_client()
|
|
818
|
+
if client is None:
|
|
819
|
+
client = await _confirm_and_reindex_bridge(ctx)
|
|
820
|
+
if client is None:
|
|
821
|
+
return []
|
|
822
|
+
if kind:
|
|
823
|
+
rows = client.read(
|
|
824
|
+
"bridge.gq", "search_bindings_by_kind", {"query": query, "kind": kind}
|
|
825
|
+
)
|
|
826
|
+
else:
|
|
827
|
+
rows = client.read("bridge.gq", "search_bindings", {"query": query})
|
|
828
|
+
return _filter_by_precision(rows, min_precision)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
@mcp.tool
|
|
832
|
+
def code_reindex(path: str | None = None, force: bool = False) -> dict:
|
|
833
|
+
"""
|
|
834
|
+
Index (or re-index) the current repo, or a subpath of it.
|
|
835
|
+
|
|
836
|
+
Incremental by default — unchanged files (matching content hash) are
|
|
837
|
+
skipped. Lazily creates the per-repo store on first run. Returns a summary of
|
|
838
|
+
files scanned/indexed/skipped and symbols/edges written.
|
|
839
|
+
|
|
840
|
+
Parameters
|
|
841
|
+
----------
|
|
842
|
+
path:
|
|
843
|
+
Optional file or directory under the repo. Defaults to the repo root
|
|
844
|
+
(or cwd if not in a git repo).
|
|
845
|
+
force:
|
|
846
|
+
Re-index every file regardless of content hash.
|
|
847
|
+
"""
|
|
848
|
+
target = Path(path) if path else (repo_module.root() or Path.cwd())
|
|
849
|
+
stats = indexer.index_path(target, force=force, config=cfg)
|
|
850
|
+
return {
|
|
851
|
+
"path": str(target),
|
|
852
|
+
"scanned": stats.scanned,
|
|
853
|
+
"indexed": stats.indexed,
|
|
854
|
+
"skipped": stats.skipped,
|
|
855
|
+
"symbols": stats.symbols,
|
|
856
|
+
"edges": stats.edges,
|
|
857
|
+
"bindings": stats.bindings,
|
|
858
|
+
"errors": stats.errors,
|
|
859
|
+
}
|