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/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
+ }