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.
Files changed (55) hide show
  1. codemap/__init__.py +10 -0
  2. codemap/apidiff.py +208 -0
  3. codemap/arch.py +190 -0
  4. codemap/cli.py +718 -0
  5. codemap/diagnostics.py +256 -0
  6. codemap/extract/__init__.py +10 -0
  7. codemap/extract/attrflow.py +230 -0
  8. codemap/extract/behavior.py +771 -0
  9. codemap/extract/dataflow.py +97 -0
  10. codemap/extract/dispatch.py +248 -0
  11. codemap/extract/griffe_extractor.py +496 -0
  12. codemap/extract/gsource.py +83 -0
  13. codemap/extract/roots.py +427 -0
  14. codemap/freshness.py +94 -0
  15. codemap/incremental.py +195 -0
  16. codemap/integrations/__init__.py +51 -0
  17. codemap/integrations/base.py +196 -0
  18. codemap/integrations/cocoindex.py +78 -0
  19. codemap/integrations/gate.py +58 -0
  20. codemap/integrations/gitnexus.py +93 -0
  21. codemap/integrations/registry.py +69 -0
  22. codemap/integrations/transport.py +46 -0
  23. codemap/model.py +178 -0
  24. codemap/provenance.py +248 -0
  25. codemap/query.py +1164 -0
  26. codemap/scope.py +212 -0
  27. codemap/serve/__init__.py +26 -0
  28. codemap/serve/_scip_pb2.py +100 -0
  29. codemap/serve/api_surface.py +60 -0
  30. codemap/serve/apidiff.py +83 -0
  31. codemap/serve/architecture.py +101 -0
  32. codemap/serve/audit.py +176 -0
  33. codemap/serve/check.py +80 -0
  34. codemap/serve/ctags.py +203 -0
  35. codemap/serve/impact.py +84 -0
  36. codemap/serve/livingdocs.py +174 -0
  37. codemap/serve/mcp_server.py +278 -0
  38. codemap/serve/mermaid.py +120 -0
  39. codemap/serve/pack.py +93 -0
  40. codemap/serve/rag.py +142 -0
  41. codemap/serve/review.py +197 -0
  42. codemap/serve/scip.py +183 -0
  43. codemap/serve/semantic.py +71 -0
  44. codemap/serve/server.py +43 -0
  45. codemap/serve/session.py +482 -0
  46. codemap/serve/subsystems.py +85 -0
  47. codemap/serve/vault.py +156 -0
  48. codemap/store.py +28 -0
  49. codemap/tomlio.py +59 -0
  50. codmap-0.0.3.dist-info/METADATA +245 -0
  51. codmap-0.0.3.dist-info/RECORD +55 -0
  52. codmap-0.0.3.dist-info/WHEEL +5 -0
  53. codmap-0.0.3.dist-info/entry_points.txt +2 -0
  54. codmap-0.0.3.dist-info/licenses/LICENSE +21 -0
  55. codmap-0.0.3.dist-info/top_level.txt +1 -0
@@ -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"