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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- 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
|