codmap 0.0.3__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.
- codemap/__init__.py +10 -0
- codemap/apidiff.py +208 -0
- codemap/arch.py +190 -0
- codemap/cli.py +718 -0
- codemap/diagnostics.py +256 -0
- codemap/extract/__init__.py +10 -0
- codemap/extract/attrflow.py +230 -0
- codemap/extract/behavior.py +771 -0
- codemap/extract/dataflow.py +97 -0
- codemap/extract/dispatch.py +248 -0
- codemap/extract/griffe_extractor.py +496 -0
- codemap/extract/gsource.py +83 -0
- codemap/extract/roots.py +427 -0
- codemap/freshness.py +94 -0
- codemap/incremental.py +195 -0
- codemap/integrations/__init__.py +51 -0
- codemap/integrations/base.py +196 -0
- codemap/integrations/cocoindex.py +78 -0
- codemap/integrations/gate.py +58 -0
- codemap/integrations/gitnexus.py +93 -0
- codemap/integrations/registry.py +69 -0
- codemap/integrations/transport.py +46 -0
- codemap/model.py +178 -0
- codemap/provenance.py +248 -0
- codemap/query.py +1164 -0
- codemap/scope.py +212 -0
- codemap/serve/__init__.py +26 -0
- codemap/serve/_scip_pb2.py +100 -0
- codemap/serve/api_surface.py +60 -0
- codemap/serve/apidiff.py +83 -0
- codemap/serve/architecture.py +101 -0
- codemap/serve/audit.py +176 -0
- codemap/serve/check.py +80 -0
- codemap/serve/ctags.py +203 -0
- codemap/serve/impact.py +84 -0
- codemap/serve/livingdocs.py +174 -0
- codemap/serve/mcp_server.py +278 -0
- codemap/serve/mermaid.py +120 -0
- codemap/serve/pack.py +93 -0
- codemap/serve/rag.py +142 -0
- codemap/serve/review.py +197 -0
- codemap/serve/scip.py +183 -0
- codemap/serve/semantic.py +71 -0
- codemap/serve/server.py +43 -0
- codemap/serve/session.py +482 -0
- codemap/serve/subsystems.py +85 -0
- codemap/serve/vault.py +156 -0
- codemap/store.py +28 -0
- codemap/tomlio.py +59 -0
- codmap-0.0.3.dist-info/METADATA +245 -0
- codmap-0.0.3.dist-info/RECORD +55 -0
- codmap-0.0.3.dist-info/WHEEL +5 -0
- codmap-0.0.3.dist-info/entry_points.txt +2 -0
- codmap-0.0.3.dist-info/licenses/LICENSE +21 -0
- codmap-0.0.3.dist-info/top_level.txt +1 -0
codemap/serve/session.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Warm serve session — graph held in memory, many queries, zero per-call startup.
|
|
2
|
+
|
|
3
|
+
DESIGN §14.4 (M3.1): the AI-hot path is served by a *resident process* holding the
|
|
4
|
+
graph in memory, not by rebuilding on every call. A ``Session`` loads the graph
|
|
5
|
+
once and answers request dicts (``{op, args}``) by dispatching to the existing
|
|
6
|
+
Serve/Query services — no new logic, just the warm surface.
|
|
7
|
+
|
|
8
|
+
The protocol is deliberately transport-neutral JSON (``handle`` takes/returns
|
|
9
|
+
dicts): ``server.serve_stdio`` wraps it as a line-delimited stdio loop, and a thin
|
|
10
|
+
MCP adapter can wrap the same ``handle`` later (each ``op`` maps to one MCP tool).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections import Counter
|
|
16
|
+
|
|
17
|
+
from codemap.model import SCHEMA_VERSION, Graph
|
|
18
|
+
from codemap.query import Query
|
|
19
|
+
from codemap.serve.api_surface import render_api_surface
|
|
20
|
+
from codemap.serve.architecture import build_architecture, render_architecture
|
|
21
|
+
from codemap.serve.audit import render_behavior, render_dead_code, render_dependencies
|
|
22
|
+
from codemap.serve.impact import render_impact
|
|
23
|
+
from codemap.serve.mermaid import render_mermaid
|
|
24
|
+
from codemap.serve.rag import build_chunks
|
|
25
|
+
from codemap.serve.review import build_review, render_review
|
|
26
|
+
from codemap.serve.vault import build_vault
|
|
27
|
+
|
|
28
|
+
_REPORTS = {
|
|
29
|
+
"api-surface": lambda q: render_api_surface(q.graph),
|
|
30
|
+
"dependencies": render_dependencies,
|
|
31
|
+
"dead-code": render_dead_code,
|
|
32
|
+
"behavior": render_behavior,
|
|
33
|
+
"architecture": render_architecture,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# R1-C13: ops whose answer leans on the partial static call graph (Python call
|
|
37
|
+
# resolution is incomplete — gaps/ CM-09) → the answer is a lower bound. We stamp
|
|
38
|
+
# them with a machine-readable epistemic label (the structured twin of the prose
|
|
39
|
+
# disclaimers). Absence of the label = structural/complete (imports, contains,
|
|
40
|
+
# inherits, exports — exact). One label per answer (no per-edge confidence: edges
|
|
41
|
+
# already carry `resolution`). From the GitNexus разбор, built natively.
|
|
42
|
+
_PARTIAL_OPS = frozenset({"callers", "callees", "impact", "flows", "call_contract",
|
|
43
|
+
"tests", "covers"})
|
|
44
|
+
_EPISTEMIC_PARTIAL = {
|
|
45
|
+
"epistemic": "partial",
|
|
46
|
+
"reason": "leans on static call resolution (partial for Python) — a lower "
|
|
47
|
+
"bound; pair with grep/tests before acting.",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_query_result(q: Query, name: str) -> dict:
|
|
52
|
+
"""The full symbol dossier — shared by ``codemap query`` and warm serve."""
|
|
53
|
+
matches = q.find(name)
|
|
54
|
+
result: dict = {
|
|
55
|
+
"name": name,
|
|
56
|
+
"defined_at": q.where_defined(name),
|
|
57
|
+
# F12: carry file:line so an agent can jump to source; the id here is the
|
|
58
|
+
# canonical node (not a re-export), so it also chains into relational ops.
|
|
59
|
+
"matches": [{"id": n.id, "kind": n.kind, "file": n.file,
|
|
60
|
+
"lines": [n.lineno, n.endlineno]} for n in matches],
|
|
61
|
+
}
|
|
62
|
+
modules = [n.id for n in matches if n.kind == "module"]
|
|
63
|
+
if modules:
|
|
64
|
+
result["modules"] = {
|
|
65
|
+
m: {"dependencies": q.dependencies(m), "dependents": q.dependents(m)}
|
|
66
|
+
for m in modules
|
|
67
|
+
}
|
|
68
|
+
classes = [n.id for n in matches if n.kind == "class"]
|
|
69
|
+
if classes:
|
|
70
|
+
result["classes"] = {
|
|
71
|
+
c: {"bases": q.bases(c), "subclasses": q.subclasses(c),
|
|
72
|
+
"implements": q.implements(c), "implementers": q.implementers(c),
|
|
73
|
+
"family": q.family_siblings(c),
|
|
74
|
+
# F10: how to register a sibling — the extension recipe.
|
|
75
|
+
"registered_as": q.graph.nodes[c].extras.get("registry")}
|
|
76
|
+
for c in classes
|
|
77
|
+
}
|
|
78
|
+
funcs = [n.id for n in matches if n.kind == "function"]
|
|
79
|
+
if funcs:
|
|
80
|
+
result["functions"] = {
|
|
81
|
+
f: {"callers": q.callers(f), "callees": q.callees(f),
|
|
82
|
+
# F11: which columns this function reads/writes (reverse dataflow).
|
|
83
|
+
"columns": _nonempty(q.columns_of(f)),
|
|
84
|
+
# R1-C4: per-symbol complexity (cc / mi / volume / sloc), when known.
|
|
85
|
+
"complexity": q.graph.nodes[f].extras.get("complexity")}
|
|
86
|
+
for f in funcs
|
|
87
|
+
}
|
|
88
|
+
attrs = [n.id for n in matches if n.kind == "attribute"]
|
|
89
|
+
if attrs:
|
|
90
|
+
# R1-C20: standing on a field, who reads/writes it (accesses edges, issue #1).
|
|
91
|
+
accessed = {
|
|
92
|
+
a: acc for a in attrs
|
|
93
|
+
if (acc := _nonempty({"reads": q.readers(a), "writes": q.writers(a)}))
|
|
94
|
+
}
|
|
95
|
+
if accessed:
|
|
96
|
+
result["attributes"] = accessed
|
|
97
|
+
if matches:
|
|
98
|
+
used_by = {}
|
|
99
|
+
for n in matches:
|
|
100
|
+
if n.kind in ("class", "function"):
|
|
101
|
+
by_root: dict[str, int] = {}
|
|
102
|
+
for ref in q.references_to(n.id):
|
|
103
|
+
by_root[ref["root"]] = by_root.get(ref["root"], 0) + 1
|
|
104
|
+
if by_root:
|
|
105
|
+
used_by[n.id] = by_root
|
|
106
|
+
if used_by:
|
|
107
|
+
result["used_by"] = used_by
|
|
108
|
+
col = q.column(name)
|
|
109
|
+
if col and (col["writes"] or col["reads"]):
|
|
110
|
+
result["column"] = col
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _nonempty(d: dict) -> dict | None:
|
|
115
|
+
"""Drop a reads/writes dict that has nothing on either side."""
|
|
116
|
+
return d if (d.get("reads") or d.get("writes")) else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Session:
|
|
120
|
+
"""A warm, in-memory query surface over one graph."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, graph: Graph, source_root: str | None = None,
|
|
123
|
+
graph_path: str | None = None) -> None:
|
|
124
|
+
self.graph = graph
|
|
125
|
+
self.query = Query(graph)
|
|
126
|
+
# F12: base dir to resolve node.file for the `source` op (node paths are
|
|
127
|
+
# repo-relative, e.g. `bquant/…`). Defaults to cwd; best-effort.
|
|
128
|
+
self.source_root = source_root
|
|
129
|
+
# M18: path of the loaded graph file, if any — lets `stats` report the map's
|
|
130
|
+
# age so a caller knows it may be stale. None for an in-memory graph.
|
|
131
|
+
self.graph_path = graph_path
|
|
132
|
+
# #3: the mtime of the artifact WHEN WE LOADED IT — so `stats` describes the
|
|
133
|
+
# graph actually being served, not the file on disk (which may have been
|
|
134
|
+
# rebuilt out from under a long-lived server). None for an in-memory graph.
|
|
135
|
+
self._served_mtime = self._current_mtime()
|
|
136
|
+
|
|
137
|
+
def _current_mtime(self) -> float | None:
|
|
138
|
+
import os
|
|
139
|
+
if not self.graph_path:
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
return os.path.getmtime(self.graph_path)
|
|
143
|
+
except OSError:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def _canon(self, name_or_id: str) -> str:
|
|
147
|
+
"""Resolve a name / re-export id to the canonical node id (F13).
|
|
148
|
+
|
|
149
|
+
Records the resolution (F14) so ``handle`` can surface an ``ambiguous``
|
|
150
|
+
warning when a bare short name resolved to one of many defs arbitrarily.
|
|
151
|
+
"""
|
|
152
|
+
info = self.query.canonical_info(name_or_id)
|
|
153
|
+
self._resolution = info
|
|
154
|
+
return info["id"] if info else name_or_id
|
|
155
|
+
|
|
156
|
+
# -- dispatch ------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
def handle(self, request: dict) -> dict:
|
|
159
|
+
"""Route one ``{op, args}`` request to a service; never raises.
|
|
160
|
+
|
|
161
|
+
When an op resolved its input through ``_canon`` and that resolution either
|
|
162
|
+
was **ambiguous** (M14/F14 — arbitrary pick among equals) or rewrote the
|
|
163
|
+
input (F13 — re-export → canonical), the envelope carries a ``resolved``
|
|
164
|
+
block so a caller never acts on a silently-wrong symbol.
|
|
165
|
+
"""
|
|
166
|
+
op = request.get("op")
|
|
167
|
+
args = request.get("args") or {}
|
|
168
|
+
fn = _OPS.get(op)
|
|
169
|
+
if fn is None:
|
|
170
|
+
return {"ok": False, "error": f"unknown op: {op!r}",
|
|
171
|
+
"ops": sorted(_OPS)}
|
|
172
|
+
self._resolution = None
|
|
173
|
+
try:
|
|
174
|
+
env = {"ok": True, "result": fn(self, args)}
|
|
175
|
+
except Exception as exc: # a bad arg must not kill the resident process
|
|
176
|
+
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
|
|
177
|
+
r = self._resolution
|
|
178
|
+
if r and (r["ambiguous"] or r["input"] != r["id"]):
|
|
179
|
+
env["resolved"] = r
|
|
180
|
+
if op in _PARTIAL_OPS: # R1-C13: machine-readable "this is a lower bound"
|
|
181
|
+
env["epistemic"] = _EPISTEMIC_PARTIAL
|
|
182
|
+
return env
|
|
183
|
+
|
|
184
|
+
# -- ops (each takes an args dict) ---------------------------------------
|
|
185
|
+
|
|
186
|
+
def _op_ping(self, args) -> str:
|
|
187
|
+
return "pong"
|
|
188
|
+
|
|
189
|
+
def _op_stats(self, args) -> dict:
|
|
190
|
+
out = {
|
|
191
|
+
"target": self.graph.target,
|
|
192
|
+
# R1-C25: two different facts that used to share one field. `schema` was the
|
|
193
|
+
# *running tool's* version reported over a graph that might declare another.
|
|
194
|
+
"schema": self.graph.loaded_schema or SCHEMA_VERSION,
|
|
195
|
+
"tool_schema": SCHEMA_VERSION,
|
|
196
|
+
"provenance": self.graph.provenance or None,
|
|
197
|
+
"nodes": len(self.graph.nodes),
|
|
198
|
+
"edges": len(self.graph.edges),
|
|
199
|
+
"node_kinds": dict(Counter(n.kind for n in self.graph.nodes.values())),
|
|
200
|
+
"edge_types": dict(Counter(e.type for e in self.graph.edges)),
|
|
201
|
+
}
|
|
202
|
+
# M18 + #3: age of the graph WE SERVE (not the on-disk file), with an explicit
|
|
203
|
+
# stale flag when the artifact was rebuilt after we loaded it.
|
|
204
|
+
from codemap.freshness import freshness
|
|
205
|
+
fr = freshness(self.graph_path, served_mtime=self._served_mtime)
|
|
206
|
+
if fr is not None:
|
|
207
|
+
out["freshness"] = fr
|
|
208
|
+
# R1-C21: same spirit as freshness — say when the graph may be *vacuous* (0 import
|
|
209
|
+
# edges from a layout the extractor didn't understand), rather than letting every
|
|
210
|
+
# downstream conclusion silently inherit the emptiness.
|
|
211
|
+
from codemap.diagnostics import diagnostics
|
|
212
|
+
diags = diagnostics(self.graph)
|
|
213
|
+
if diags:
|
|
214
|
+
out["diagnostics"] = diags
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
def _op_reload(self, args) -> dict:
|
|
218
|
+
"""Reload the on-disk artifact into the served graph, without a restart (#3).
|
|
219
|
+
|
|
220
|
+
Picks up an external rebuild (e.g. ``codemap build --incremental``) so the
|
|
221
|
+
server stops answering from its startup snapshot. Returns what changed and the
|
|
222
|
+
refreshed freshness. A no-op-with-reason when the server has no on-disk graph
|
|
223
|
+
(started from ``--build``) — restart to refresh those.
|
|
224
|
+
"""
|
|
225
|
+
from codemap.freshness import freshness
|
|
226
|
+
from codemap import store
|
|
227
|
+
if not self.graph_path:
|
|
228
|
+
return {"reloaded": False,
|
|
229
|
+
"reason": "server was started from an in-memory build; "
|
|
230
|
+
"restart to refresh"}
|
|
231
|
+
before = {"nodes": len(self.graph.nodes), "edges": len(self.graph.edges)}
|
|
232
|
+
try:
|
|
233
|
+
graph = store.load(self.graph_path)
|
|
234
|
+
except (OSError, ValueError) as exc:
|
|
235
|
+
return {"reloaded": False,
|
|
236
|
+
"reason": f"could not read {self.graph_path}: "
|
|
237
|
+
f"{type(exc).__name__}: {exc}"}
|
|
238
|
+
self.graph = graph
|
|
239
|
+
self.query = Query(graph)
|
|
240
|
+
self._served_mtime = self._current_mtime()
|
|
241
|
+
after = {"nodes": len(graph.nodes), "edges": len(graph.edges)}
|
|
242
|
+
return {"reloaded": True, "before": before, "after": after,
|
|
243
|
+
"changed": before != after,
|
|
244
|
+
"freshness": freshness(self.graph_path,
|
|
245
|
+
served_mtime=self._served_mtime)}
|
|
246
|
+
|
|
247
|
+
def _op_query(self, args) -> dict:
|
|
248
|
+
return build_query_result(self.query, args["name"])
|
|
249
|
+
|
|
250
|
+
def _op_impact(self, args) -> dict:
|
|
251
|
+
sym = args["symbol"]
|
|
252
|
+
depth = int(args.get("depth", 2))
|
|
253
|
+
ids = self.query.impact_targets(sym) # F23: accept full id / re-export too
|
|
254
|
+
return {
|
|
255
|
+
"symbol": sym,
|
|
256
|
+
"impact": [self.query.impact(sid, depth=depth) for sid in ids],
|
|
257
|
+
"markdown": render_impact(self.query, sym, depth=depth),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
def _op_resolve(self, args) -> dict | None:
|
|
261
|
+
# F14: full resolution — {input, id, ambiguous, alternatives} — so a cold
|
|
262
|
+
# agent can check for ambiguity before chaining into a relational op.
|
|
263
|
+
return self.query.canonical_info(args["name"])
|
|
264
|
+
|
|
265
|
+
def _op_search(self, args) -> list:
|
|
266
|
+
return self.query.search(args["term"], kind=args.get("kind"),
|
|
267
|
+
limit=int(args.get("limit", 50)))
|
|
268
|
+
|
|
269
|
+
def _op_families(self, args) -> list:
|
|
270
|
+
return self.query.families()
|
|
271
|
+
|
|
272
|
+
def _op_column(self, args) -> dict | None:
|
|
273
|
+
return self.query.column(args["name"])
|
|
274
|
+
|
|
275
|
+
def _op_columns(self, args) -> list:
|
|
276
|
+
# F15: default to subscript-accessed keys (the real column-like set);
|
|
277
|
+
# pass all=true for the full over-set incl. dict-literal payload keys.
|
|
278
|
+
return self.query.columns(subscripted_only=not args.get("all", False))
|
|
279
|
+
|
|
280
|
+
def _op_columns_of(self, args) -> dict:
|
|
281
|
+
return self.query.columns_of(self._canon(args["symbol"]))
|
|
282
|
+
|
|
283
|
+
def _op_accessors(self, args) -> dict:
|
|
284
|
+
# R1-C20: who reads/writes a class attribute (accesses edges, issue #1).
|
|
285
|
+
aid = self._canon(args["attribute"])
|
|
286
|
+
return {"reads": self.query.readers(aid), "writes": self.query.writers(aid)}
|
|
287
|
+
|
|
288
|
+
def _op_tests(self, args) -> dict:
|
|
289
|
+
"""R1-C24: which tests exercise a symbol, nearest band first, honestly labelled."""
|
|
290
|
+
return self.query.tests_for(
|
|
291
|
+
self._canon(args["symbol"]),
|
|
292
|
+
depth=int(args.get("depth", 3)), cap=int(args.get("cap", 25)))
|
|
293
|
+
|
|
294
|
+
def _op_covers(self, args) -> dict:
|
|
295
|
+
"""The inverse — what a test actually reaches (same index, read forward)."""
|
|
296
|
+
return self.query.covers(
|
|
297
|
+
self._canon(args["test"]),
|
|
298
|
+
depth=int(args.get("depth", 3)), cap=int(args.get("cap", 25)))
|
|
299
|
+
|
|
300
|
+
def _op_callers(self, args) -> list:
|
|
301
|
+
return self.query.callers(self._canon(args["symbol"]))
|
|
302
|
+
|
|
303
|
+
def _op_callees(self, args) -> list:
|
|
304
|
+
return self.query.callees(self._canon(args["symbol"]))
|
|
305
|
+
|
|
306
|
+
def _op_implementers(self, args) -> list:
|
|
307
|
+
return self.query.implementers(self._canon(args["protocol"]))
|
|
308
|
+
|
|
309
|
+
def _op_family(self, args) -> dict:
|
|
310
|
+
cid = self._canon(args["class"])
|
|
311
|
+
return {"implements": self.query.implements(cid),
|
|
312
|
+
"siblings": self.query.family_siblings(cid)}
|
|
313
|
+
|
|
314
|
+
def _op_call_contract(self, args) -> list:
|
|
315
|
+
return self.query.call_contract(self._canon(args["symbol"]))
|
|
316
|
+
|
|
317
|
+
def _op_locate(self, args) -> dict:
|
|
318
|
+
"""F16: (file, line) or (file, lines:[start,end]) → containing symbol(s)."""
|
|
319
|
+
file = args["file"]
|
|
320
|
+
if "line" in args:
|
|
321
|
+
return {"file": file, "line": int(args["line"]),
|
|
322
|
+
"symbol": self.query.symbol_at(file, int(args["line"]))}
|
|
323
|
+
lo, hi = args["lines"]
|
|
324
|
+
return {"file": file, "lines": [int(lo), int(hi)],
|
|
325
|
+
"symbols": self.query.symbols_in_range(file, int(lo), int(hi))}
|
|
326
|
+
|
|
327
|
+
def _op_review(self, args) -> dict:
|
|
328
|
+
"""F17: change-set review from diff hunks and/or explicit symbols."""
|
|
329
|
+
return build_review(self.query, hunks=args.get("hunks"),
|
|
330
|
+
symbols=args.get("symbols"))
|
|
331
|
+
|
|
332
|
+
def _op_architecture(self, args) -> dict:
|
|
333
|
+
"""F21: whole-system shape — cycles + layers + coupling + hotspots."""
|
|
334
|
+
return build_architecture(self.query)
|
|
335
|
+
|
|
336
|
+
def _op_diff(self, args) -> dict:
|
|
337
|
+
"""R1-C5: API diff a baseline graph → this session's graph.
|
|
338
|
+
|
|
339
|
+
``base`` is the path to the 'before' graph.json; the session graph is the
|
|
340
|
+
'after'. Returns {ok, added, removed, changes, summary} — breaking-change
|
|
341
|
+
classification on the public API surface.
|
|
342
|
+
"""
|
|
343
|
+
from codemap import store
|
|
344
|
+
from codemap.serve.apidiff import build_apidiff
|
|
345
|
+
base_path = args.get("base")
|
|
346
|
+
if not base_path:
|
|
347
|
+
return {"error": "diff needs args.base = path to the baseline graph.json"}
|
|
348
|
+
return build_apidiff(store.load(base_path), self.graph)
|
|
349
|
+
|
|
350
|
+
def _op_check(self, args) -> dict:
|
|
351
|
+
"""R1-C3: evaluate the [architecture] contract → {ok, violations}.
|
|
352
|
+
|
|
353
|
+
The 'what did I break' surface: an agent (or CI) asks whether the current
|
|
354
|
+
graph still satisfies the declared architecture. The contract is read from
|
|
355
|
+
codemap.toml under ``root`` (arg, else ``source_root``, else cwd).
|
|
356
|
+
"""
|
|
357
|
+
from codemap.arch import check_contract, load_contract
|
|
358
|
+
from codemap.serve.check import build_check
|
|
359
|
+
root = args.get("root") or self.source_root or "."
|
|
360
|
+
contract = load_contract(root)
|
|
361
|
+
return build_check(self.query, contract, check_contract(self.query, contract))
|
|
362
|
+
|
|
363
|
+
def _op_communities(self, args) -> list:
|
|
364
|
+
"""R1-C18: data-driven module subsystems (greedy modularity)."""
|
|
365
|
+
return self.query.communities()
|
|
366
|
+
|
|
367
|
+
def _op_flows(self, args) -> dict:
|
|
368
|
+
"""R1-C18: forward call-flow from a symbol, or entry points if none given."""
|
|
369
|
+
sym = args.get("symbol")
|
|
370
|
+
if not sym:
|
|
371
|
+
return {"entry_points": self.query.entry_points()}
|
|
372
|
+
return self.query.flow(self._canon(sym), max_depth=int(args.get("depth", 5)))
|
|
373
|
+
|
|
374
|
+
def _op_semantic(self, args) -> dict:
|
|
375
|
+
"""R1-C16: semantic search via an opt-in adapter, enriched to codemap symbols.
|
|
376
|
+
|
|
377
|
+
Resolves an ADAPTER providing ``semantic-search`` (opt-in via codemap.toml +
|
|
378
|
+
installed), asks it for fuzzy hits, and resolves each to the exact symbol via
|
|
379
|
+
the graph. ``root`` (arg, else ``source_root``, else cwd) is both the repo the
|
|
380
|
+
tool's index lives in and where file paths resolve. No adapter → empty hits.
|
|
381
|
+
"""
|
|
382
|
+
from codemap.serve.semantic import semantic_search
|
|
383
|
+
root = args.get("root") or self.source_root or "."
|
|
384
|
+
return semantic_search(self.query, args["query"], root=root,
|
|
385
|
+
limit=int(args.get("limit", 10)))
|
|
386
|
+
|
|
387
|
+
def _op_pack(self, args) -> dict:
|
|
388
|
+
"""R1-C6: token-budgeted context pack — most relevant graph slice under N tokens."""
|
|
389
|
+
from codemap.serve.pack import build_pack
|
|
390
|
+
return build_pack(self.query, budget=int(args.get("budget", 2000)),
|
|
391
|
+
seeds=tuple(args.get("seeds") or ()))
|
|
392
|
+
|
|
393
|
+
def _op_source(self, args) -> dict:
|
|
394
|
+
"""Return the source span of a symbol (F12): {file, lines, code?}.
|
|
395
|
+
|
|
396
|
+
``code`` is included when the file is readable under ``source_root`` (or
|
|
397
|
+
cwd); otherwise only the location is returned so the caller can read it.
|
|
398
|
+
"""
|
|
399
|
+
from pathlib import Path
|
|
400
|
+
cid = self._canon(args["symbol"])
|
|
401
|
+
node = self.graph.nodes.get(cid)
|
|
402
|
+
if node is None:
|
|
403
|
+
return {"error": f"unknown symbol: {args['symbol']!r}"}
|
|
404
|
+
loc = {"id": cid, "file": node.file, "lines": [node.lineno, node.endlineno]}
|
|
405
|
+
if not node.file or node.lineno is None:
|
|
406
|
+
return {**loc, "code": None, "note": "no location (overlay node)"}
|
|
407
|
+
base = Path(self.source_root) if self.source_root else Path(".")
|
|
408
|
+
path = base / node.file
|
|
409
|
+
try:
|
|
410
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
411
|
+
loc["code"] = "\n".join(lines[node.lineno - 1:(node.endlineno or node.lineno)])
|
|
412
|
+
except OSError:
|
|
413
|
+
loc["code"] = None
|
|
414
|
+
loc["note"] = f"source unreadable at {path} — set source_root"
|
|
415
|
+
return loc
|
|
416
|
+
|
|
417
|
+
def _op_report(self, args) -> dict:
|
|
418
|
+
kind = args["kind"]
|
|
419
|
+
if kind == "impact":
|
|
420
|
+
return {"kind": kind, "markdown": render_impact(
|
|
421
|
+
self.query, args["symbol"], depth=int(args.get("depth", 2)))}
|
|
422
|
+
if kind == "dead-code": # R1-C8: confidence + whitelist from [dead_code]
|
|
423
|
+
from codemap.serve.audit import load_dead_code_whitelist
|
|
424
|
+
wl, wl_error = load_dead_code_whitelist(args.get("root") or self.source_root)
|
|
425
|
+
return {"kind": kind, "markdown": render_dead_code(
|
|
426
|
+
self.query, whitelist=wl, min_confidence=args.get("min_confidence"),
|
|
427
|
+
whitelist_error=wl_error)}
|
|
428
|
+
renderer = _REPORTS.get(kind)
|
|
429
|
+
if renderer is None:
|
|
430
|
+
raise ValueError(f"unknown report kind: {kind!r}")
|
|
431
|
+
return {"kind": kind, "markdown": renderer(self.query)}
|
|
432
|
+
|
|
433
|
+
def _op_export(self, args) -> dict:
|
|
434
|
+
view = args["view"]
|
|
435
|
+
if view == "rag":
|
|
436
|
+
return {"view": view, "chunks": build_chunks(self.query)}
|
|
437
|
+
if view == "mermaid":
|
|
438
|
+
return {"view": view, "diagram": render_mermaid(
|
|
439
|
+
self.query, args.get("mkind", "class"),
|
|
440
|
+
scope=args.get("scope"), root=args.get("root"),
|
|
441
|
+
depth=int(args.get("depth", 2)))}
|
|
442
|
+
if view == "vault":
|
|
443
|
+
return {"view": view, "files": build_vault(self.query)}
|
|
444
|
+
if view == "docs":
|
|
445
|
+
from codemap.serve.livingdocs import render_docs
|
|
446
|
+
return {"view": view, "markdown": render_docs(self.query)}
|
|
447
|
+
raise ValueError(f"unknown export view: {view!r}")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
_OPS = {
|
|
451
|
+
"ping": Session._op_ping,
|
|
452
|
+
"stats": Session._op_stats,
|
|
453
|
+
"reload": Session._op_reload,
|
|
454
|
+
"query": Session._op_query,
|
|
455
|
+
"impact": Session._op_impact,
|
|
456
|
+
"resolve": Session._op_resolve,
|
|
457
|
+
"search": Session._op_search,
|
|
458
|
+
"families": Session._op_families,
|
|
459
|
+
"column": Session._op_column,
|
|
460
|
+
"columns": Session._op_columns,
|
|
461
|
+
"columns_of": Session._op_columns_of,
|
|
462
|
+
"accessors": Session._op_accessors,
|
|
463
|
+
"tests": Session._op_tests,
|
|
464
|
+
"covers": Session._op_covers,
|
|
465
|
+
"callers": Session._op_callers,
|
|
466
|
+
"callees": Session._op_callees,
|
|
467
|
+
"implementers": Session._op_implementers,
|
|
468
|
+
"family": Session._op_family,
|
|
469
|
+
"call_contract": Session._op_call_contract,
|
|
470
|
+
"locate": Session._op_locate,
|
|
471
|
+
"review": Session._op_review,
|
|
472
|
+
"architecture": Session._op_architecture,
|
|
473
|
+
"check": Session._op_check,
|
|
474
|
+
"diff": Session._op_diff,
|
|
475
|
+
"communities": Session._op_communities,
|
|
476
|
+
"flows": Session._op_flows,
|
|
477
|
+
"semantic": Session._op_semantic,
|
|
478
|
+
"pack": Session._op_pack,
|
|
479
|
+
"source": Session._op_source,
|
|
480
|
+
"report": Session._op_report,
|
|
481
|
+
"export": Session._op_export,
|
|
482
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Subsystems view — module communities + call flows (R1-C18).
|
|
2
|
+
|
|
3
|
+
A narrative "what is the system made of, and how does a flow run through it" view,
|
|
4
|
+
built natively on codemap's own graph (the GitNexus разбор showed the value —
|
|
5
|
+
Leiden clusters + process/flow tracing; we compute it deterministically without
|
|
6
|
+
the external dependency). Feeds living docs (R1-C15).
|
|
7
|
+
|
|
8
|
+
* :func:`render_communities` — data-driven module subsystems (greedy modularity).
|
|
9
|
+
* :func:`render_flows` — forward call-flow from an entry symbol, or the list of
|
|
10
|
+
detected entry points with their reach when no symbol is given.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from codemap.query import Query
|
|
16
|
+
|
|
17
|
+
_CAP = 40 # list cap so a hub subsystem / wide flow stays readable
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def render_communities(query: Query) -> str:
|
|
21
|
+
comms = query.communities()
|
|
22
|
+
lines = ["# Subsystems — module communities", ""]
|
|
23
|
+
if not comms:
|
|
24
|
+
lines.append("_No import edges to cluster (core-only or empty graph)._")
|
|
25
|
+
return "\n".join(lines) + "\n"
|
|
26
|
+
lines.append(
|
|
27
|
+
f"_{len(comms)} data-driven clusters via greedy modularity over the import "
|
|
28
|
+
f"graph (deterministic). A cluster = modules that import each other more than "
|
|
29
|
+
f"the rest — a candidate subsystem, labelled by dominant layer._"
|
|
30
|
+
)
|
|
31
|
+
lines.append("")
|
|
32
|
+
for i, c in enumerate(comms, 1):
|
|
33
|
+
lines.append(f"## {i}. {c['label']} — {c['size']} modules")
|
|
34
|
+
for m in c["modules"][:_CAP]:
|
|
35
|
+
lines.append(f"- `{m}`")
|
|
36
|
+
if c["size"] > _CAP:
|
|
37
|
+
lines.append(f"- _… {c['size'] - _CAP} more_")
|
|
38
|
+
lines.append("")
|
|
39
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def render_flows(query: Query, symbol: str | None = None, *, depth: int = 5) -> str:
|
|
43
|
+
lines = ["# Call flows", ""]
|
|
44
|
+
if symbol is None:
|
|
45
|
+
eps = query.entry_points()
|
|
46
|
+
lines.append(
|
|
47
|
+
f"_{len(eps)} entry points (functions that call out but are never called "
|
|
48
|
+
f"— resolved edges). Forward reach at depth {depth}. Best-effort: call "
|
|
49
|
+
f"resolution is partial, so an unresolved caller can leave a real internal "
|
|
50
|
+
f"here._"
|
|
51
|
+
)
|
|
52
|
+
lines.append("")
|
|
53
|
+
rows = sorted(
|
|
54
|
+
((query.flow(ep, max_depth=depth)["reached"], ep) for ep in eps),
|
|
55
|
+
reverse=True,
|
|
56
|
+
)
|
|
57
|
+
for reached, ep in rows[:_CAP]:
|
|
58
|
+
lines.append(f"- `{ep}` → reaches {reached}")
|
|
59
|
+
if len(rows) > _CAP:
|
|
60
|
+
lines.append(f"- _… {len(rows) - _CAP} more entry points_")
|
|
61
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
62
|
+
|
|
63
|
+
ids = query.impact_targets(symbol)
|
|
64
|
+
if not ids:
|
|
65
|
+
lines.append(f"_No definition found for `{symbol}`._")
|
|
66
|
+
return "\n".join(lines) + "\n"
|
|
67
|
+
for sid in ids:
|
|
68
|
+
f = query.flow(sid, max_depth=depth)
|
|
69
|
+
lines.append(f"## `{sid}` — reaches {f['reached']} (depth {f['max_depth']})")
|
|
70
|
+
lines.append("")
|
|
71
|
+
if not f["edges"]:
|
|
72
|
+
lines.append("_Calls out to nothing resolved — a leaf in the call graph._")
|
|
73
|
+
lines.append("")
|
|
74
|
+
continue
|
|
75
|
+
by_dist: dict[int, list[str]] = {}
|
|
76
|
+
for e in f["edges"]:
|
|
77
|
+
by_dist.setdefault(e["distance"], []).append(f"{e['source']} → {e['target']}")
|
|
78
|
+
for d in sorted(by_dist):
|
|
79
|
+
lines.append(f"### depth {d} — {len(by_dist[d])} calls")
|
|
80
|
+
for pair in by_dist[d][:_CAP]:
|
|
81
|
+
lines.append(f"- `{pair}`")
|
|
82
|
+
if len(by_dist[d]) > _CAP:
|
|
83
|
+
lines.append(f"- _… {len(by_dist[d]) - _CAP} more_")
|
|
84
|
+
lines.append("")
|
|
85
|
+
return "\n".join(lines).rstrip() + "\n"
|