graphite-code 0.3.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.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/query.py ADDED
@@ -0,0 +1,646 @@
1
+ """Simple query engine over the graph."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from collections import deque
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable
8
+
9
+ import networkx as nx
10
+
11
+ from .graph import edge_relations
12
+
13
+ from .answer_contract import GRADE_INCONCLUSIVE, build_answer_block, languages_for_nodes
14
+ from .health import resolution_health
15
+ from .query_plan import DEFAULT_MAX_DEPTH, DEFAULT_MAX_RESULTS, make_plan, plan_error
16
+
17
+
18
+ _CALL_RELATIONS: frozenset[str] = frozenset({"calls", "references"})
19
+
20
+
21
+ def _attach_resolution(result: dict[str, Any], g: nx.DiGraph) -> dict[str, Any]:
22
+ """Trust signal + honest-empty marker for relation listings (spec 2026-07-25)."""
23
+ health = resolution_health(g)
24
+ result["resolution_health"] = health
25
+ result["inconclusive"] = result.get("total", 0) == 0 and not health["healthy"]
26
+ return result
27
+
28
+
29
+ def _capped_edge_listing(
30
+ g: nx.DiGraph, token: str, options: dict[str, Any], *, key: str, incoming: bool
31
+ ) -> dict[str, Any]:
32
+ """Call/reference in- or out-edge listing, bounded by max_results."""
33
+ cap = int(options.get("max_results", DEFAULT_MAX_RESULTS))
34
+ detail = _find_node_detail(g, token)
35
+ if not detail:
36
+ return _not_found(g, token)
37
+ node_id = detail[0]
38
+ if incoming:
39
+ full = [
40
+ p for p in sorted(g.predecessors(node_id))
41
+ if any(r in _CALL_RELATIONS for r in edge_relations(g[p][node_id]))
42
+ ]
43
+ else:
44
+ full = [
45
+ s for s in sorted(g.successors(node_id))
46
+ if any(r in _CALL_RELATIONS for r in edge_relations(g[node_id][s]))
47
+ ]
48
+ shown = full[:cap]
49
+ return _attach_resolution({
50
+ "node": node_id,
51
+ "match": _match_meta(token, detail),
52
+ "count": len(shown),
53
+ "total": len(full),
54
+ "truncated": len(full) > cap,
55
+ "limits": {"max_results": cap},
56
+ key: [_node_view(g, n) for n in shown],
57
+ }, g)
58
+
59
+
60
+ def _verb_callers(g: nx.DiGraph, inputs: list[str], options: dict[str, Any]) -> dict[str, Any]:
61
+ return _capped_edge_listing(g, inputs[0], options, key="callers", incoming=True)
62
+
63
+
64
+ def _verb_calls(g: nx.DiGraph, inputs: list[str], options: dict[str, Any]) -> dict[str, Any]:
65
+ return _capped_edge_listing(g, inputs[0], options, key="calls", incoming=False)
66
+
67
+
68
+ def _verb_reaches(g: nx.DiGraph, inputs: list[str], options: dict[str, Any]) -> dict[str, Any]:
69
+ max_depth = int(options.get("max_depth", DEFAULT_MAX_DEPTH))
70
+ a, b = inputs
71
+ src_detail = _find_node_detail(g, a)
72
+ dst_detail = _find_node_detail(g, b)
73
+ if not src_detail:
74
+ return _not_found(g, a, label="source")
75
+ if not dst_detail:
76
+ return _not_found(g, b, label="target")
77
+ src, dst = src_detail[0], dst_detail[0]
78
+ p, limited = _bounded_bfs_path(g, src, dst, max_depth, relations=_CALL_RELATIONS)
79
+ if p is None:
80
+ return {
81
+ "error": f"no call path from {src} to {dst}",
82
+ "error_code": "no_path",
83
+ "truncated": limited,
84
+ "limits": {"max_depth": max_depth},
85
+ }
86
+ return {
87
+ "source": src,
88
+ "target": dst,
89
+ "match": {"source": _match_meta(a, src_detail), "target": _match_meta(b, dst_detail)},
90
+ "length": len(p) - 1,
91
+ "path": [_node_view(g, n) for n in p],
92
+ "truncated": False,
93
+ "limits": {"max_depth": max_depth},
94
+ }
95
+
96
+
97
+ def _verb_stats(g: nx.DiGraph, inputs: list[str], options: dict[str, Any]) -> dict[str, Any]:
98
+ del inputs, options
99
+ kinds: dict[str, int] = {}
100
+ for _n, data in g.nodes(data=True):
101
+ kind = data.get("kind", "unknown")
102
+ kinds[kind] = kinds.get(kind, 0) + 1
103
+ relations: dict[str, int] = {}
104
+ for _u, _v, data in g.edges(data=True):
105
+ relation = data.get("relation", "unknown")
106
+ relations[relation] = relations.get(relation, 0) + 1
107
+ communities = {
108
+ data.get("community") for _n, data in g.nodes(data=True) if data.get("community") is not None
109
+ }
110
+ top_in = sorted(g.in_degree(), key=lambda item: item[1], reverse=True)[:5]
111
+ top_out = sorted(g.out_degree(), key=lambda item: item[1], reverse=True)[:5]
112
+ return {
113
+ "node_count": g.number_of_nodes(),
114
+ "edge_count": g.number_of_edges(),
115
+ "density": nx.density(g),
116
+ "community_count": len(communities),
117
+ "nodes_by_kind": dict(sorted(kinds.items(), key=lambda item: item[1], reverse=True)),
118
+ "edges_by_relation": dict(sorted(relations.items(), key=lambda item: item[1], reverse=True)),
119
+ "top_incoming": [{**_node_view(g, n), "in_degree": d} for n, d in top_in],
120
+ "top_outgoing": [{**_node_view(g, n), "out_degree": d} for n, d in top_out],
121
+ "resolution_health": resolution_health(g),
122
+ }
123
+
124
+
125
+ def _neighbor_listing(
126
+ g: nx.DiGraph, token: str, options: dict[str, Any], *, key: str, incoming: bool
127
+ ) -> dict[str, Any]:
128
+ cap = int(options.get("max_results", DEFAULT_MAX_RESULTS))
129
+ detail = _find_node_detail(g, token)
130
+ if not detail:
131
+ return _not_found(g, token)
132
+ node_id = detail[0]
133
+ neighbors = sorted(g.predecessors(node_id) if incoming else g.successors(node_id))
134
+ shown = neighbors[:cap]
135
+ return _attach_resolution({
136
+ "node": node_id,
137
+ "match": _match_meta(token, detail),
138
+ "count": len(shown),
139
+ "total": len(neighbors),
140
+ "truncated": len(neighbors) > cap,
141
+ "limits": {"max_results": cap},
142
+ key: [_node_view(g, n) for n in shown],
143
+ }, g)
144
+
145
+
146
+ def _verb_depends_on(g: nx.DiGraph, inputs: list[str], options: dict[str, Any]) -> dict[str, Any]:
147
+ return _neighbor_listing(g, inputs[0], options, key="depends_on", incoming=False)
148
+
149
+
150
+ def _verb_imported_by(g: nx.DiGraph, inputs: list[str], options: dict[str, Any]) -> dict[str, Any]:
151
+ return _neighbor_listing(g, inputs[0], options, key="imported_by", incoming=True)
152
+
153
+
154
+ def _verb_path(g: nx.DiGraph, inputs: list[str], options: dict[str, Any]) -> dict[str, Any]:
155
+ max_depth = int(options.get("max_depth", DEFAULT_MAX_DEPTH))
156
+ a, b = inputs
157
+ src_detail = _find_node_detail(g, a)
158
+ dst_detail = _find_node_detail(g, b)
159
+ if not src_detail:
160
+ return _not_found(g, a, label="source")
161
+ if not dst_detail:
162
+ return _not_found(g, b, label="target")
163
+ src, dst = src_detail[0], dst_detail[0]
164
+ p, limited = _bounded_bfs_path(g, src, dst, max_depth, relations=None)
165
+ if p is None:
166
+ return {
167
+ "error": f"no path from {src} to {dst}",
168
+ "error_code": "no_path",
169
+ "truncated": limited,
170
+ "limits": {"max_depth": max_depth},
171
+ }
172
+ return {
173
+ "source": src,
174
+ "target": dst,
175
+ "match": {"source": _match_meta(a, src_detail), "target": _match_meta(b, dst_detail)},
176
+ "length": len(p) - 1,
177
+ "path": [
178
+ {"id": n, "name": g.nodes[n].get("name", n), "kind": g.nodes[n].get("kind", "unknown")}
179
+ for n in p
180
+ ],
181
+ "truncated": False,
182
+ "limits": {"max_depth": max_depth},
183
+ }
184
+
185
+
186
+ def _verb_community_of(g: nx.DiGraph, inputs: list[str], options: dict[str, Any]) -> dict[str, Any]:
187
+ del options
188
+ token = inputs[0]
189
+ detail = _find_node_detail(g, token)
190
+ if not detail:
191
+ return _not_found(g, token)
192
+ node_id = detail[0]
193
+ return {
194
+ "node": node_id,
195
+ "match": _match_meta(token, detail),
196
+ "community": g.nodes[node_id].get("community"),
197
+ "name": g.nodes[node_id].get("name", node_id),
198
+ }
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class QueryVerb:
203
+ """One dispatchable query verb; the registry drives dispatch, help, plans, and capabilities."""
204
+
205
+ name: str
206
+ aliases: tuple[str, ...]
207
+ arguments: str
208
+ description: str
209
+ handler: Callable[[nx.DiGraph, list[str], dict[str, Any]], dict[str, Any]]
210
+ roles: tuple[str, ...]
211
+ limits: tuple[tuple[str, int], ...] = ()
212
+ relations: tuple[str, ...] = ()
213
+ empty_meaning: str = ""
214
+
215
+
216
+ QUERY_VERBS: tuple[QueryVerb, ...] = (
217
+ QueryVerb(
218
+ "callers", ("called-by", "called_by"), "<symbol>",
219
+ "Functions that call <symbol> (calls/references in-edges)", _verb_callers,
220
+ ("node",), (("max_results", DEFAULT_MAX_RESULTS),),
221
+ relations=("calls",), empty_meaning="no bound callers found",
222
+ ),
223
+ QueryVerb(
224
+ "calls", ("callees",), "<symbol>",
225
+ "Functions/symbols that <symbol> calls (calls/references out-edges)", _verb_calls,
226
+ ("node",), (("max_results", DEFAULT_MAX_RESULTS),),
227
+ relations=("calls",), empty_meaning="no bound callees found",
228
+ ),
229
+ QueryVerb(
230
+ "reaches", (), "<a> -> <b>",
231
+ "Directed path from a to b over call/reference edges only", _verb_reaches,
232
+ ("source", "target"), (("max_depth", DEFAULT_MAX_DEPTH),),
233
+ relations=("calls",), empty_meaning="no call path found within depth",
234
+ ),
235
+ QueryVerb(
236
+ "path", (), "<a> -> <b>",
237
+ "Shortest directed path from a to b over all edges", _verb_path,
238
+ ("source", "target"), (("max_depth", DEFAULT_MAX_DEPTH),),
239
+ relations=("calls", "imports"), empty_meaning="no path found within depth",
240
+ ),
241
+ QueryVerb(
242
+ "depends-on", ("depends_on", "out"), "<node>",
243
+ "Nodes that <node> directly depends on (out-edges)", _verb_depends_on,
244
+ ("node",), (("max_results", DEFAULT_MAX_RESULTS),),
245
+ relations=("calls", "imports"), empty_meaning="no bound dependencies found",
246
+ ),
247
+ QueryVerb(
248
+ "imported-by", ("imported_by", "in"), "<node>",
249
+ "Nodes that directly point to <node> (in-edges)", _verb_imported_by,
250
+ ("node",), (("max_results", DEFAULT_MAX_RESULTS),),
251
+ relations=("imports",), empty_meaning="no bound importers found",
252
+ ),
253
+ QueryVerb(
254
+ "community-of", ("community_of",), "<node>",
255
+ "Cluster/community label for a node", _verb_community_of,
256
+ ("node",),
257
+ relations=("calls", "imports"), empty_meaning="no community assigned",
258
+ ),
259
+ QueryVerb("stats", (), "", "Basic graph statistics", _verb_stats, ()),
260
+ )
261
+
262
+ _VERB_INDEX: dict[str, QueryVerb] = {
263
+ alias: spec for spec in QUERY_VERBS for alias in (spec.name, *spec.aliases)
264
+ }
265
+
266
+ _EXPECTED_ROLES: dict[str, tuple[str, ...]] = {spec.name: spec.roles for spec in QUERY_VERBS}
267
+
268
+
269
+ def verb_catalog() -> list[dict[str, Any]]:
270
+ """Machine-readable listing of every dispatchable query verb."""
271
+ return [
272
+ {
273
+ "name": spec.name,
274
+ "aliases": list(spec.aliases),
275
+ "arguments": spec.arguments,
276
+ "description": spec.description,
277
+ "targets": list(spec.roles),
278
+ "limits": dict(spec.limits),
279
+ }
280
+ for spec in QUERY_VERBS
281
+ ]
282
+
283
+
284
+ def _format_error(spec: QueryVerb) -> dict[str, Any]:
285
+ return {
286
+ "error": f"{spec.name} query format: {spec.name} {spec.arguments}",
287
+ "error_code": "invalid_query_format",
288
+ }
289
+
290
+
291
+ def build_plan(q: str) -> dict[str, Any]:
292
+ """Deterministically translate a query string into a plan document.
293
+
294
+ Returns either a plan (see query_plan.PLAN_SCHEMA) or an error dict with a
295
+ stable error_code; plans never carry an "error" key, so callers distinguish
296
+ the two by that key alone.
297
+ """
298
+ tokens = q.strip().lower().split()
299
+ if not tokens:
300
+ return {"error": "empty query", "error_code": "empty_query"}
301
+
302
+ verb = tokens[0]
303
+ spec = _VERB_INDEX.get(verb)
304
+ if spec is None:
305
+ return {
306
+ "error": f"unknown query verb: {verb}",
307
+ "error_code": "unknown_query_verb",
308
+ "suggestions": [
309
+ 'Use `graphite search "<symbol, path, or concept>"` to locate nodes',
310
+ "Run `graphite capabilities --json` to list supported operations",
311
+ f'Supported verbs: {", ".join(v.name for v in QUERY_VERBS)}',
312
+ ],
313
+ }
314
+ rest = tokens[1:]
315
+ if spec.roles == ():
316
+ targets: list[tuple[str, str]] = []
317
+ elif spec.roles == ("node",):
318
+ token = " ".join(rest)
319
+ if not token:
320
+ return _format_error(spec)
321
+ targets = [("node", token)]
322
+ else:
323
+ try:
324
+ arrow = rest.index("->")
325
+ except ValueError:
326
+ return _format_error(spec)
327
+ a, b = " ".join(rest[:arrow]), " ".join(rest[arrow + 1 :])
328
+ if not a or not b:
329
+ return _format_error(spec)
330
+ targets = [("source", a), ("target", b)]
331
+ return make_plan(spec.name, targets, dict(spec.limits))
332
+
333
+
334
+ RESULT_SCHEMA_VERSION = 1
335
+
336
+
337
+ def _resolution(spec: QueryVerb, result: dict[str, Any]) -> list[dict[str, Any]]:
338
+ """Uniform per-target resolution listing, aligned with the plan's target roles."""
339
+ if spec.roles == ("node",):
340
+ return [{"role": "node", **result["match"]}]
341
+ if spec.roles == ("source", "target"):
342
+ return [
343
+ {"role": "source", **result["match"]["source"]},
344
+ {"role": "target", **result["match"]["target"]},
345
+ ]
346
+ return []
347
+
348
+
349
+ def _is_empty(spec: QueryVerb, result: dict[str, Any]) -> bool:
350
+ if result.get("error_code") == "no_path":
351
+ return True
352
+ if "total" in result:
353
+ return result["total"] == 0
354
+ if spec.name == "community-of":
355
+ return result.get("community") is None
356
+ return False
357
+
358
+
359
+ def execute_plan(g: nx.DiGraph, plan: object) -> dict[str, Any]:
360
+ """Validate a plan against schema v1 and the verb registry, then run it."""
361
+ reason = plan_error(plan, _EXPECTED_ROLES)
362
+ if reason is not None:
363
+ return {
364
+ "schema_version": RESULT_SCHEMA_VERSION,
365
+ "error": f"invalid query plan: {reason}",
366
+ "error_code": "invalid_plan",
367
+ }
368
+ assert isinstance(plan, dict) # narrowed by plan_error
369
+ spec = _VERB_INDEX[plan["operation"]]
370
+ inputs = [target["input"] for target in plan["targets"]]
371
+ result = spec.handler(g, inputs, plan["options"])
372
+ envelope = {"schema_version": RESULT_SCHEMA_VERSION, **result}
373
+ is_error = "error" in result
374
+ if not is_error:
375
+ envelope["resolution"] = _resolution(spec, result)
376
+ if spec.relations and (not is_error or result.get("error_code") == "no_path"):
377
+ # Fail-open (spec R6): computation performed only to build the
378
+ # `answer` block — seed derivation, language lookup, the block
379
+ # itself — must never be able to error the query. On any failure
380
+ # here the envelope is left exactly as it was before this block.
381
+ try:
382
+ seeds = [entry.get("node") for entry in envelope.get("resolution", [])]
383
+ matched_languages = languages_for_nodes(g, seeds)
384
+ block = build_answer_block(
385
+ g,
386
+ relations=spec.relations,
387
+ languages=matched_languages,
388
+ total=0 if _is_empty(spec, result) else 1,
389
+ empty_meaning=spec.empty_meaning or None,
390
+ )
391
+ if block is not None:
392
+ envelope["answer"] = block
393
+ if "inconclusive" in envelope:
394
+ envelope["inconclusive"] = block["grade"] == GRADE_INCONCLUSIVE
395
+ elif seeds and not matched_languages and "inconclusive" in envelope:
396
+ # Matched real nodes, but none have an applicable code
397
+ # language -- nothing to grade, not a resolution gap.
398
+ envelope["inconclusive"] = False
399
+ except Exception:
400
+ pass
401
+ return envelope
402
+
403
+
404
+ def plan_preview(q: str) -> dict[str, Any]:
405
+ """Validated plan document for a query string, or an error dict.
406
+
407
+ Needs no graph: this is the `query --plan-only` path, letting agents check
408
+ query syntax offline before paying for a graph load.
409
+ """
410
+ plan = build_plan(q)
411
+ if "error" in plan:
412
+ return {"schema_version": RESULT_SCHEMA_VERSION, **plan}
413
+ reason = plan_error(plan, _EXPECTED_ROLES)
414
+ if reason is not None: # defensive: internally built plans always validate
415
+ return {
416
+ "schema_version": RESULT_SCHEMA_VERSION,
417
+ "error": f"invalid query plan: {reason}",
418
+ "error_code": "invalid_plan",
419
+ }
420
+ return plan
421
+
422
+
423
+ def query(g: nx.DiGraph, q: str) -> dict[str, Any]:
424
+ """Answer a simple query string; see QUERY_VERBS for the supported patterns.
425
+
426
+ Every query is first translated into a canonical plan (build_plan) and then
427
+ executed (execute_plan); errors at either stage carry a stable error_code,
428
+ and every response carries schema_version (successes also carry a uniform
429
+ per-target resolution listing).
430
+ """
431
+ plan = build_plan(q)
432
+ if "error" in plan:
433
+ return {"schema_version": RESULT_SCHEMA_VERSION, **plan}
434
+ return execute_plan(g, plan)
435
+
436
+
437
+ DEFAULT_SEARCH_LIMIT = 20
438
+ MAX_SEARCH_LIMIT = 100
439
+ # Deterministic precedence tiers; index = rank, lower is better.
440
+ _SEARCH_MATCH_TYPES = ("exact-id", "name", "path-suffix", "id-substring", "name-substring", "tokens")
441
+
442
+
443
+ def search_graph(g: nx.DiGraph, text: str, limit: int = DEFAULT_SEARCH_LIMIT) -> dict[str, Any]:
444
+ """Deterministic ranked node search by id, name, path, or concept tokens."""
445
+ raw = text.strip()
446
+ token = raw.lower().strip("`")
447
+ if not token:
448
+ return {
449
+ "ok": False,
450
+ "schema_version": 1,
451
+ "error": "empty search",
452
+ "error_code": "empty_search",
453
+ }
454
+ limit = max(1, min(int(limit), MAX_SEARCH_LIMIT))
455
+ normalized_path = token.replace("\\", "/")
456
+ parts = [p for p in re.split(r"[^a-z0-9]+", token) if len(p) >= 3]
457
+ best: dict[str, tuple[int, float]] = {}
458
+
459
+ def offer(node: str, tier: int, score: float) -> None:
460
+ current = best.get(node)
461
+ if current is None or (tier, -score) < (current[0], -current[1]):
462
+ best[node] = (tier, score)
463
+
464
+ for n, data in g.nodes(data=True):
465
+ node_lower = n.lower()
466
+ name = data.get("name", "").lower()
467
+ source = data.get("source_file", "").lower().replace("\\", "/")
468
+ if node_lower == token:
469
+ offer(n, 0, 1.0)
470
+ if name and name == token:
471
+ offer(n, 1, 1.0)
472
+ if source and source.endswith(normalized_path):
473
+ offer(n, 2, 1.0 if data.get("kind") == "file" else 0.9)
474
+ if token in node_lower and node_lower != token:
475
+ offer(n, 3, round(len(token) / len(n), 3))
476
+ if name and token in name and name != token:
477
+ offer(n, 4, round(len(token) / len(name), 3))
478
+ if parts:
479
+ haystack = f"{node_lower} {name} {source}"
480
+ hits = sum(1 for p in parts if p in haystack)
481
+ if hits:
482
+ offer(n, 5, round(hits / len(parts), 3))
483
+
484
+ ordered = sorted(best.items(), key=lambda item: (item[1][0], -item[1][1], item[0]))
485
+ results = [
486
+ {**_node_view(g, node), "match_type": _SEARCH_MATCH_TYPES[tier], "score": score}
487
+ for node, (tier, score) in ordered[:limit]
488
+ ]
489
+ return {
490
+ "ok": True,
491
+ "schema_version": 1,
492
+ "query": raw,
493
+ "count": len(results),
494
+ "total_matches": len(ordered),
495
+ "truncated": len(ordered) > limit,
496
+ "results": results,
497
+ }
498
+
499
+
500
+ def _not_found(g: nx.DiGraph, token: str, *, label: str = "node") -> dict[str, Any]:
501
+ """Not-found error with close candidates so agents can self-correct."""
502
+ return {
503
+ "error": f"{label} not found: {token}",
504
+ "error_code": "node_not_found",
505
+ "candidates": _candidates(g, token),
506
+ }
507
+
508
+
509
+ def _candidates(g: nx.DiGraph, token: str, limit: int = 5) -> list[dict[str, Any]]:
510
+ """Nodes whose id, name, or source file loosely matches any part of token."""
511
+ token = token.strip().lower().strip("`")
512
+ parts = [p for p in re.split(r"[^a-z0-9]+", token) if len(p) >= 3]
513
+ if not parts:
514
+ return []
515
+ scored: list[tuple[int, str]] = []
516
+ for n in g.nodes():
517
+ haystack = " ".join(
518
+ (n, g.nodes[n].get("name", "") or "", g.nodes[n].get("source_file", "") or "")
519
+ ).lower().replace("\\", "/")
520
+ score = sum(1 for p in parts if p in haystack)
521
+ if score:
522
+ scored.append((score, n))
523
+ scored.sort(key=lambda item: (-item[0], item[1]))
524
+ return [_node_view(g, n) for _score, n in scored[:limit]]
525
+
526
+
527
+ def _path_depth(g: nx.DiGraph, node_id: str) -> int:
528
+ """Number of directory segments in a node's source file (0 = repo root)."""
529
+ source_file = g.nodes[node_id].get("source_file") or ""
530
+ normalized = source_file.replace("\\", "/").strip("/")
531
+ return normalized.count("/") if normalized else 0
532
+
533
+
534
+ def _find_node_detail(g: nx.DiGraph, token: str) -> tuple[str, str, list[str]] | None:
535
+ """Match a node and report HOW it matched.
536
+
537
+ Returns (node_id, match_type, alternates) where match_type is one of
538
+ "exact-id", "name", "path-suffix", or "fuzzy", and alternates lists other
539
+ nodes that matched equally well (so a silently-wrong pick is visible to the
540
+ caller). Ties are broken deterministically instead of by insertion order.
541
+ """
542
+ token = token.strip().lower().strip("`")
543
+ if token in g:
544
+ return token, "exact-id", []
545
+
546
+ # Multiple files can share a basename (README.md at root and under
547
+ # hooks/, policy/, etc.) -- prefer the shallowest path, since a bare
548
+ # basename query with no path segments almost always means the
549
+ # repo-root file, not whichever id happened to sort first alphabetically
550
+ # (found via operation-firewall dogfooding, 2026-07-31: `README.md`
551
+ # matched `hooks/README.md` over the root file purely because
552
+ # "hooks_readme" < "readme" as strings).
553
+ name_hits = sorted(
554
+ (n for n in g.nodes() if g.nodes[n].get("name", "").lower() == token),
555
+ key=lambda n: (_path_depth(g, n), n),
556
+ )
557
+ if name_hits:
558
+ return name_hits[0], "name", name_hits[1:4]
559
+
560
+ normalized = token.replace("\\", "/")
561
+ path_hits = sorted(
562
+ n
563
+ for n in g.nodes()
564
+ if (sf := g.nodes[n].get("source_file", "")) and sf.lower().replace("\\", "/").endswith(normalized)
565
+ )
566
+ if path_hits:
567
+ # Prefer the file node itself over symbols defined in the file.
568
+ file_hits = [n for n in path_hits if g.nodes[n].get("kind") == "file"]
569
+ chosen = file_hits[0] if file_hits else path_hits[0]
570
+ return chosen, "path-suffix", [n for n in path_hits if n != chosen][:4]
571
+
572
+ fuzzy_hits = sorted((n for n in g.nodes() if token in n), key=lambda n: (len(n), n))
573
+ if fuzzy_hits:
574
+ return fuzzy_hits[0], "fuzzy", fuzzy_hits[1:4]
575
+ return None
576
+
577
+
578
+ def _find_node(g: nx.DiGraph, token: str) -> str | None:
579
+ """Match a node by exact id, name, or file path."""
580
+ detail = _find_node_detail(g, token)
581
+ return detail[0] if detail else None
582
+
583
+
584
+ def _match_meta(token: str, detail: tuple[str, str, list[str]]) -> dict[str, Any]:
585
+ """Query-response metadata describing how an input token was matched."""
586
+ node_id, match_type, alternates = detail
587
+ meta: dict[str, Any] = {"input": token, "node": node_id, "type": match_type}
588
+ if alternates:
589
+ meta["alternates"] = alternates
590
+ return meta
591
+
592
+
593
+ def _node_view(g: nx.DiGraph, n: str) -> dict[str, Any]:
594
+ """Compact node descriptor for call-graph results."""
595
+ data = g.nodes[n]
596
+ return {
597
+ "id": n,
598
+ "name": data.get("name", n),
599
+ "kind": data.get("kind", "unknown"),
600
+ "source_file": data.get("source_file", ""),
601
+ }
602
+
603
+
604
+ def _bounded_bfs_path(
605
+ g: nx.DiGraph, src: str, dst: str, max_depth: int, *, relations: frozenset[str] | None
606
+ ) -> tuple[list[str] | None, bool]:
607
+ """Shortest directed path from src to dst within max_depth edges.
608
+
609
+ Successors are visited in sorted order, so equal-length ties break
610
+ deterministically. The second value reports whether the depth bound pruned
611
+ any expansion — True means a longer path may exist beyond the bound, False
612
+ means absence is proven.
613
+ """
614
+ if src == dst:
615
+ return [src], False
616
+ prev: dict[str, str | None] = {src: None}
617
+ depth: dict[str, int] = {src: 0}
618
+ queue: deque[str] = deque([src])
619
+ limited = False
620
+ while queue:
621
+ u = queue.popleft()
622
+ for v in sorted(g.successors(u)):
623
+ if relations is not None and not any(r in relations for r in edge_relations(g[u][v])):
624
+ continue
625
+ if v in prev:
626
+ continue
627
+ if depth[u] >= max_depth:
628
+ limited = True
629
+ continue
630
+ prev[v] = u
631
+ depth[v] = depth[u] + 1
632
+ if v == dst:
633
+ path = [v]
634
+ while prev[path[-1]] is not None:
635
+ path.append(prev[path[-1]]) # type: ignore[arg-type]
636
+ path.reverse()
637
+ return path, False
638
+ queue.append(v)
639
+ return None, limited
640
+
641
+
642
+ def annotate_communities(g: nx.DiGraph, partition: dict[str, int]) -> None:
643
+ """Write community id into each graph node attribute."""
644
+ for n, comm in partition.items():
645
+ if n in g.nodes:
646
+ g.nodes[n]["community"] = comm