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,174 @@
1
+ """Living docs — a narrative document generated from the graph (R1-C15).
2
+
3
+ The honest answer to "auto-generated codebase wiki". Tools like CodeWiki / neuro-
4
+ articles narrate what code *does* by guessing; codemap only states what the graph
5
+ proves. Everything here is traceable: structure (modules/classes/functions/imports/
6
+ inheritance) is exact static fact; docstrings are the authors' own words, quoted
7
+ verbatim — never generated, and an undocumented symbol is *marked*, not invented;
8
+ call-flow-derived claims carry the static lower-bound caveat (epistemic: partial).
9
+
10
+ Organised by **discovered subsystem** (communities, R1-C18) rather than a flat
11
+ module list — "what is this made of, and how does it run" — and deterministic, so
12
+ re-running refreshes it (the "living" part). Feeds nothing it can't cite.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections import defaultdict
18
+
19
+ from codemap.model import Graph
20
+ from codemap.query import Query
21
+
22
+ _SYM_KINDS = {"class", "function"}
23
+ _PER_SUBSYSTEM = 25 # symbols listed per subsystem before "+N more"
24
+ _ENTRY_POINTS = 15 # behavioural entry points listed
25
+ _FLOW_DEPTH = 4
26
+
27
+
28
+ def _first_line(docstring: str | None) -> str | None:
29
+ if not docstring:
30
+ return None
31
+ for line in docstring.strip().splitlines():
32
+ if line.strip():
33
+ return line.strip()
34
+ return None
35
+
36
+
37
+ def _module_of(node_id: str) -> str:
38
+ return node_id.rsplit(".", 1)[0]
39
+
40
+
41
+ def _public_symbols_by_module(graph: Graph) -> dict[str, list]:
42
+ """Public top-level class/function nodes of the **core** package, by module id.
43
+
44
+ Core only: living docs document the package, not its tests/docs/examples
45
+ (consumer roots), which on a repo-scoped graph would otherwise leak in.
46
+ """
47
+ by_module: dict[str, list] = defaultdict(list)
48
+ for n in graph.nodes.values():
49
+ if (n.kind in _SYM_KINDS and n.visibility == "public"
50
+ and n.extras.get("root", "core") == "core"):
51
+ by_module[_module_of(n.id)].append(n)
52
+ return by_module
53
+
54
+
55
+ def _render_symbol(n) -> list[str]:
56
+ name = n.id.rsplit(".", 1)[-1]
57
+ head = n.signature or name
58
+ marker = " **⚠ deprecated**" if n.is_deprecated else ""
59
+ doc = _first_line(n.docstring)
60
+ line = f"- **`{head}`** ({n.kind}){marker}"
61
+ return [line, f" - {doc}"] if doc else [line, " - _(undocumented)_"]
62
+
63
+
64
+ def render_docs(query: Query) -> str:
65
+ graph = query.graph
66
+ target = graph.target
67
+ by_module = _public_symbols_by_module(graph)
68
+ kinds = defaultdict(int)
69
+ for n in graph.nodes.values():
70
+ kinds[n.kind] += 1
71
+ total_public = sum(len(v) for v in by_module.values())
72
+
73
+ out = [f"# {target} — living documentation", ""]
74
+ out.append(
75
+ f"_{kinds['module']} modules · {kinds['class']} classes · {kinds['function']} "
76
+ f"functions · {total_public} public symbols. Generated from the code graph — "
77
+ f"structure is exact static fact; docstrings are the authors' own words, "
78
+ f"quoted verbatim._"
79
+ )
80
+ out.append("")
81
+
82
+ # -- subsystems (communities) -------------------------------------------
83
+ comms = query.communities()
84
+ grouped_modules: set[str] = set()
85
+ out.append("## Subsystems")
86
+ out.append("")
87
+ if not comms:
88
+ out.append("_No import edges to cluster — see the module list below._")
89
+ out.append("")
90
+ for i, c in enumerate(comms, 1):
91
+ out.append(f"### {i}. {c['label']} — {c['size']} modules")
92
+ out.append("")
93
+ syms = []
94
+ for m in c["modules"]:
95
+ grouped_modules.add(m)
96
+ syms.extend(sorted(by_module.get(m, []), key=lambda n: n.id))
97
+ if not syms:
98
+ out.append("_No public symbols (internal-only subsystem)._")
99
+ out.append("")
100
+ continue
101
+ for n in syms[:_PER_SUBSYSTEM]:
102
+ out.extend(_render_symbol(n))
103
+ if len(syms) > _PER_SUBSYSTEM:
104
+ out.append(f"- _… {len(syms) - _PER_SUBSYSTEM} more public symbols_")
105
+ out.append("")
106
+
107
+ # -- ungrouped modules (completeness: nothing dropped) ------------------
108
+ ungrouped = sorted(
109
+ m for m in by_module
110
+ if m not in grouped_modules and query.root_of(m) == "core" and by_module[m]
111
+ )
112
+ if ungrouped:
113
+ out.append("## Other modules (no import clustering)")
114
+ out.append("")
115
+ for m in ungrouped:
116
+ out.append(f"### `{m}`")
117
+ out.append("")
118
+ for n in sorted(by_module[m], key=lambda n: n.id)[:_PER_SUBSYSTEM]:
119
+ out.extend(_render_symbol(n))
120
+ out.append("")
121
+
122
+ # -- behavioural entry points (flows) -----------------------------------
123
+ eps = query.entry_points()
124
+ if eps:
125
+ out.append("## Behavioural entry points")
126
+ out.append("")
127
+ out.append(
128
+ f"_Where execution starts — functions that call out but are never called "
129
+ f"(resolved edges). Reach = symbols touched within {_FLOW_DEPTH} calls. "
130
+ f"**Static lower bound** (epistemic: partial): Python call resolution is "
131
+ f"incomplete, so an unresolved caller can leave a real internal here._"
132
+ )
133
+ out.append("")
134
+ ranked = sorted(
135
+ ((query.flow(ep, max_depth=_FLOW_DEPTH)["reached"], ep) for ep in eps),
136
+ reverse=True,
137
+ )
138
+ for reached, ep in ranked[:_ENTRY_POINTS]:
139
+ out.append(f"- `{ep}` → reaches {reached}")
140
+ if len(ranked) > _ENTRY_POINTS:
141
+ out.append(f"- _… {len(ranked) - _ENTRY_POINTS} more entry points_")
142
+ out.append("")
143
+
144
+ # -- architecture caveats (the honest health section) -------------------
145
+ cycles = query.import_cycles()
146
+ lay = query.layers()
147
+ gods = query.hotspots()["god_classes"]
148
+ out.append("## Architecture notes")
149
+ out.append("")
150
+ if cycles:
151
+ out.append(f"- **{len(cycles)} import cycle(s)** — e.g. "
152
+ + "; ".join(" → ".join(c) for c in sorted(cycles, key=len)[:3]))
153
+ else:
154
+ out.append("- Import graph is acyclic.")
155
+ if lay["violations"]:
156
+ out.append("- **Layer violations (mutual dependency):** "
157
+ + ", ".join(f"{a} ↔ {b}" for a, b in lay["violations"]))
158
+ if gods:
159
+ out.append("- **God-object candidates:** "
160
+ + ", ".join(f"`{g['class']}` ({g['methods']} methods)" for g in gods[:5]))
161
+ out.append("")
162
+
163
+ # -- honesty footer -----------------------------------------------------
164
+ out.append("---")
165
+ out.append("")
166
+ out.append(
167
+ "_Generated by codemap from the canonical code graph. **Exact:** modules, "
168
+ "classes, functions, imports, inheritance (static parse). **Verbatim:** "
169
+ "docstrings — the authors' words, never generated; undocumented symbols are "
170
+ "marked, not invented. **Lower bound (epistemic: partial):** call-flows and "
171
+ "entry-point reach — Python dynamism is not fully statically resolvable. "
172
+ "Deterministic — re-run to refresh._"
173
+ )
174
+ return "\n".join(out).rstrip() + "\n"
@@ -0,0 +1,278 @@
1
+ """MCP adapter — expose the warm serve surface as Model Context Protocol tools.
2
+
3
+ The `serve` layer is deliberately transport-neutral: ``Session.handle({op, args})``
4
+ takes and returns plain dicts. This module is the thin wrapper that maps each op to
5
+ one MCP tool, so an MCP client (an AI agent host) can drive codemap directly. No new
6
+ logic — every tool just calls ``session.handle`` and returns its envelope
7
+ (``{ok, result, resolved?}``), so the caller still sees the ambiguity signal (F14)
8
+ and error handling for free.
9
+
10
+ MCP is an **optional dependency** (`pip install codemap[mcp]`): the import is lazy so
11
+ `codemap` works without it; only `codemap serve --mcp` needs it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ if TYPE_CHECKING:
19
+ from codemap.serve.session import Session
20
+
21
+ _INSTRUCTIONS = (
22
+ "codemap exposes a static code graph of a package. Start with `search` (find "
23
+ "symbols by substring) or `stats` (graph overview), then `query` a symbol for its "
24
+ "dossier. Use `impact`/`callers`/`callees` for blast radius, `review` to turn a "
25
+ "diff into a change-set review, `architecture` for the system shape, `check` to "
26
+ "enforce the [architecture] contract, `diff` for a two-snapshot API breaking-change "
27
+ "report, `communities` "
28
+ "for module subsystems and `flows` for forward call-flow from an entry. `tests` "
29
+ "answers which tests exercise a symbol (and `covers` the inverse) on a repo-scoped "
30
+ "graph. Relational "
31
+ "tools accept a short name or re-export id; when a name is ambiguous the response "
32
+ "carries a `resolved.ambiguous` flag — check it before trusting the answer."
33
+ )
34
+
35
+
36
+ def _compact_impact(env: dict, limit: int) -> dict:
37
+ """Shrink an impact envelope for MCP (F22): drop the duplicate markdown and cap
38
+ each entry's flat ref list at ``limit`` — the ``by_root`` counts stay complete."""
39
+ if not env.get("ok"):
40
+ return env
41
+ env = dict(env)
42
+ result = dict(env.get("result") or {})
43
+ result.pop("markdown", None) # structured refs already carry everything
44
+ entries = []
45
+ for e in result.get("impact", []):
46
+ e = dict(e)
47
+ refs = e.get("refs", [])
48
+ if len(refs) > limit:
49
+ e = {**e, "refs": refs[:limit],
50
+ "refs_shown": limit, "refs_total": len(refs)}
51
+ entries.append(e)
52
+ result["impact"] = entries
53
+ env["result"] = result
54
+ return env
55
+
56
+
57
+ def _cap_list(env: dict, limit: int) -> dict:
58
+ """Cap a list-valued result at ``limit`` (F22), noting the total when truncated."""
59
+ if not env.get("ok"):
60
+ return env
61
+ res = env.get("result")
62
+ if isinstance(res, list) and len(res) > limit:
63
+ env = {**env, "result": res[:limit], "shown": limit, "total": len(res)}
64
+ return env
65
+
66
+
67
+ def build_mcp_server(session: "Session", name: str = "codemap") -> Any:
68
+ """Build an MCP server exposing the session's ops as tools (lazy mcp import)."""
69
+ try:
70
+ from mcp.server import MCPServer
71
+ except ImportError as exc: # pragma: no cover - exercised via CLI message
72
+ raise RuntimeError(
73
+ "MCP support requires the optional 'mcp' dependency: "
74
+ "pip install 'codemap[mcp]'"
75
+ ) from exc
76
+
77
+ server = MCPServer(name, instructions=_INSTRUCTIONS)
78
+
79
+ def op(name: str, args: dict | None = None) -> dict:
80
+ return session.handle({"op": name, "args": args or {}})
81
+
82
+ @server.tool()
83
+ def stats() -> dict:
84
+ """Graph overview: target, schema version, node/edge counts by kind/type, and
85
+ `freshness` for the graph ACTUALLY served — `stale: true` (+reason) if the
86
+ on-disk artifact was rebuilt after this server loaded it. Call `reload` then."""
87
+ return op("stats")
88
+
89
+ @server.tool()
90
+ def reload() -> dict:
91
+ """Reload the on-disk graph into this server without restarting — pick up an
92
+ external rebuild (e.g. `codemap build --incremental`). Returns before/after
93
+ counts and refreshed freshness. No-op with a reason if started from --build."""
94
+ return op("reload")
95
+
96
+ @server.tool()
97
+ def search(term: str, kind: str | None = None, limit: int = 50) -> dict:
98
+ """Find symbols whose id contains `term` (case-insensitive). The discovery
99
+ entry point for a cold agent. Optional `kind`: module|class|function|column."""
100
+ return op("search", {"term": term, "kind": kind, "limit": limit})
101
+
102
+ @server.tool()
103
+ def query(name: str) -> dict:
104
+ """Full dossier for a symbol name: where defined (file:line), bases/impls for
105
+ classes, callers/callees/columns for functions, registration recipe, column
106
+ dataflow. The main lookup."""
107
+ return op("query", {"name": name})
108
+
109
+ @server.tool()
110
+ def resolve(name: str) -> dict:
111
+ """Resolve a short name / re-export id to the canonical node id, with an
112
+ `ambiguous` flag and `alternatives` when the name maps to several defs."""
113
+ return op("resolve", {"name": name})
114
+
115
+ @server.tool()
116
+ def callers(symbol: str) -> dict:
117
+ """Functions that statically call `symbol` (resolved calls only)."""
118
+ return op("callers", {"symbol": symbol})
119
+
120
+ @server.tool()
121
+ def callees(symbol: str) -> dict:
122
+ """Internal symbols that `symbol` statically calls."""
123
+ return op("callees", {"symbol": symbol})
124
+
125
+ @server.tool()
126
+ def tests(symbol: str, depth: int = 3, cap: int = 25) -> dict:
127
+ """Which tests exercise `symbol` — the NEAREST band of test functions that reach
128
+ it, as runnable pytest node ids. `confidence` is high/medium by distance and
129
+ `unknown` when nothing is found within `depth`: unknown means unknown, never
130
+ "untested". Needs a repo-scoped graph (`--consumer tests --mode full`). Raise
131
+ `depth` above 3 only for low-confidence candidates."""
132
+ return op("tests", {"symbol": symbol, "depth": depth, "cap": cap})
133
+
134
+ @server.tool()
135
+ def covers(test: str, depth: int = 3, cap: int = 25) -> dict:
136
+ """The inverse of `tests`: which core symbols this test reaches, by distance.
137
+ Use it to check whether a test exercises what its name claims."""
138
+ return op("covers", {"test": test, "depth": depth, "cap": cap})
139
+
140
+ @server.tool()
141
+ def impact(symbol: str, depth: int = 2, limit: int = 40, full: bool = False) -> dict:
142
+ """Blast radius of changing `symbol`: inbound references up to `depth`, counted
143
+ by provenance root (core/tests/docs/…). Compact by default (F22): omits the
144
+ duplicate markdown and caps the flat ref list at `limit` (by_root counts stay
145
+ complete). Pass full=true for the entire payload including markdown."""
146
+ env = op("impact", {"symbol": symbol, "depth": depth})
147
+ return env if full else _compact_impact(env, limit)
148
+
149
+ @server.tool()
150
+ def call_contract(symbol: str, limit: int = 30, full: bool = False) -> dict:
151
+ """Per-caller argument contract of calls into `symbol` (call-sites, posargs,
152
+ kwargs, splat) — for reasoning about a signature change. Capped at `limit`
153
+ entries by default (F22); pass full=true for all of them."""
154
+ env = op("call_contract", {"symbol": symbol})
155
+ return env if full else _cap_list(env, limit)
156
+
157
+ @server.tool()
158
+ def implementers(protocol: str) -> dict:
159
+ """Concrete classes that implement `protocol` (registry family)."""
160
+ return op("implementers", {"protocol": protocol})
161
+
162
+ @server.tool()
163
+ def family(cls: str) -> dict:
164
+ """The Protocol(s) `cls` satisfies and its sibling implementations."""
165
+ return op("family", {"class": cls})
166
+
167
+ @server.tool()
168
+ def families() -> dict:
169
+ """All registry/Protocol families with their registration recipe (decorator +
170
+ key per member) — how to add a new implementation."""
171
+ return op("families")
172
+
173
+ @server.tool()
174
+ def column(name: str) -> dict:
175
+ """Producers/consumers of a string-keyed column `name` (dataflow)."""
176
+ return op("column", {"name": name})
177
+
178
+ @server.tool()
179
+ def columns_of(symbol: str) -> dict:
180
+ """Which string-key columns a function reads / writes (reverse dataflow)."""
181
+ return op("columns_of", {"symbol": symbol})
182
+
183
+ @server.tool()
184
+ def accessors(attribute: str) -> dict:
185
+ """Who reads / writes a class `attribute` (field blast-radius; R1-C20).
186
+
187
+ The attribute analog of `column`: `{reads: [funcs], writes: [funcs]}`.
188
+ Lower bound — attribute access is modelled best-effort (self./ClassName./
189
+ construction, and obj.field on the deep tier)."""
190
+ return op("accessors", {"attribute": attribute})
191
+
192
+ @server.tool()
193
+ def locate(file: str, line: int | None = None,
194
+ start: int | None = None, end: int | None = None) -> dict:
195
+ """Map a diff location to symbol(s): pass `file` + `line`, or `file` + `start`/
196
+ `end` for a hunk range. Returns the innermost enclosing symbol(s)."""
197
+ if line is not None:
198
+ return op("locate", {"file": file, "line": line})
199
+ return op("locate", {"file": file, "lines": [start, end]})
200
+
201
+ @server.tool()
202
+ def review(hunks: list[dict] | None = None, symbols: list[str] | None = None) -> dict:
203
+ """Change-set review: pass `hunks` ([{file, ranges:[[start,end]]}]) and/or
204
+ `symbols`. Returns a risk-sorted dossier per changed symbol + a blast summary."""
205
+ return op("review", {"hunks": hunks, "symbols": symbols})
206
+
207
+ @server.tool()
208
+ def architecture() -> dict:
209
+ """Whole-system shape: layers + direction/violations, coupling (Ca/Ce/
210
+ instability), god-objects & call-hubs, import cycles."""
211
+ return op("architecture")
212
+
213
+ @server.tool()
214
+ def diff(base: str) -> dict:
215
+ """API diff a baseline graph.json (`base`) → this server's graph. Returns
216
+ {ok, added, removed, changes:[{symbol, kind, severity, detail}], summary} —
217
+ public-API breaking-change detection between two snapshots."""
218
+ return op("diff", {"base": base})
219
+
220
+ @server.tool()
221
+ def check(root: str | None = None) -> dict:
222
+ """Architecture-contract gate: does the graph still satisfy the [architecture]
223
+ rules in codemap.toml? Returns {ok, violations:[{rule, summary, edges}]} — the
224
+ 'what did I break' check. `root` overrides where codemap.toml is read from."""
225
+ return op("check", {"root": root})
226
+
227
+ @server.tool()
228
+ def communities() -> list:
229
+ """Data-driven module subsystems: clusters of modules that import each other
230
+ more than the rest (deterministic greedy modularity), labelled by layer."""
231
+ return op("communities")
232
+
233
+ @server.tool()
234
+ def flows(symbol: str | None = None, depth: int = 5) -> dict:
235
+ """Forward call-flow: what calling `symbol` sets in motion (edges by distance,
236
+ the mirror of impact). Omit `symbol` to list entry points with their reach."""
237
+ return op("flows", {"symbol": symbol, "depth": depth})
238
+
239
+ @server.tool()
240
+ def source(symbol: str) -> dict:
241
+ """Source span of a symbol: {file, lines, code} (code when readable under the
242
+ server's source-root)."""
243
+ return op("source", {"symbol": symbol})
244
+
245
+ @server.tool()
246
+ def report(kind: str) -> dict:
247
+ """Render a markdown report. kind: api-surface | dependencies | dead-code |
248
+ behavior | architecture."""
249
+ return op("report", {"kind": kind})
250
+
251
+ @server.tool()
252
+ def semantic_search(query: str, limit: int = 10) -> dict:
253
+ """Concept search → codemap symbols. Routes the natural-language `query` to an
254
+ opt-in semantic-search adapter (cocoindex), then resolves each fuzzy hit to the
255
+ exact codemap symbol at its location — fuzzy retrieval, exact structure. Returns
256
+ {resolver, hits:[{symbol, score, file, lines}]}; empty if no adapter is enabled."""
257
+ return op("semantic", {"query": query, "limit": limit})
258
+
259
+ @server.tool()
260
+ def pack(budget: int = 2000, seeds: list[str] | None = None) -> dict:
261
+ """Token-budgeted context pack: the most relevant slice of the graph under `budget`
262
+ tokens, ranked by PageRank importance — or by relevance to `seeds` (symbol / file
263
+ ids you're working on). Returns {budget, used_tokens, included, truncated,
264
+ items:[{id, kind, rank, tokens, text}]}, top hubs first."""
265
+ return op("pack", {"budget": budget, "seeds": seeds or []})
266
+
267
+ return server
268
+
269
+
270
+ # tool names registered above (kept in sync for tests / introspection)
271
+ MCP_TOOLS = (
272
+ "stats", "reload", "search", "query", "resolve", "callers", "callees", "impact",
273
+ "call_contract", "tests", "covers", "implementers", "family", "families",
274
+ "column", "columns_of",
275
+ "accessors",
276
+ "locate", "review", "architecture", "check", "diff", "communities", "flows", "source", "report",
277
+ "semantic_search", "pack",
278
+ )
@@ -0,0 +1,120 @@
1
+ """Mermaid diagram views — consumer A/B (DESIGN §4.1-A, M2.3).
2
+
3
+ Renders scoped subgraphs of the canonical graph as Mermaid text (browsable in
4
+ Obsidian, GitHub, docs):
5
+
6
+ - ``class`` — class hierarchy from ``inherits`` edges (``classDiagram``);
7
+ - ``deps`` — module dependency graph from ``imports`` edges;
8
+ - ``calls`` — call graph around a root symbol from ``calls`` edges (BFS).
9
+
10
+ Scoping keeps diagrams legible: ``scope`` filters to an id-prefix subtree; the
11
+ call graph is always rooted with a depth bound (§4.2 — scoped subgraphs).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from codemap.query import Query
17
+
18
+
19
+ def _san(node_id: str) -> str:
20
+ """Mermaid-safe node id (dots/brackets break the parser)."""
21
+ return node_id.replace(".", "_").replace("[", "_").replace("]", "_")
22
+
23
+
24
+ def _short(node_id: str) -> str:
25
+ return node_id.rsplit(".", 1)[-1]
26
+
27
+
28
+ def _in_scope(node_id: str, scope: str | None) -> bool:
29
+ return scope is None or node_id == scope or node_id.startswith(scope + ".")
30
+
31
+
32
+ def render_class_diagram(query: Query, scope: str | None = None) -> str:
33
+ """Class hierarchy as a Mermaid ``classDiagram``.
34
+
35
+ ``inherits`` edges render as inheritance (``<|--``); ``implements`` edges
36
+ (M9/F4 — registry-family members → Protocol, never inherited) render as
37
+ realization (``<|..``), so a strategy family is no longer an empty diagram.
38
+ """
39
+ graph = query.graph
40
+ inh = sorted({(e.source, e.target) for e in graph.edges if e.type == "inherits"})
41
+ inh = [(s, t) for s, t in inh if _in_scope(s, scope)]
42
+ impl = sorted({(e.source, e.target) for e in graph.edges if e.type == "implements"})
43
+ impl = [(s, t) for s, t in impl if _in_scope(s, scope) or _in_scope(t, scope)]
44
+ nodes = {n for pair in inh + impl for n in pair}
45
+
46
+ lines = ["```mermaid", "classDiagram"]
47
+ for nid in sorted(nodes):
48
+ lines.append(f' class {_san(nid)}["{_short(nid)}"]')
49
+ for sub, base in inh:
50
+ # Mermaid: Base <|-- Sub (arrow points from subclass to base)
51
+ lines.append(f" {_san(base)} <|-- {_san(sub)}")
52
+ for cls, proto in impl:
53
+ # realization: Protocol <|.. Impl
54
+ lines.append(f" {_san(proto)} <|.. {_san(cls)}")
55
+ lines.append("```")
56
+ return "\n".join(lines) + "\n"
57
+
58
+
59
+ def render_dep_graph(query: Query, scope: str | None = None) -> str:
60
+ """Module dependency graph (``imports`` edges) as a Mermaid flowchart."""
61
+ graph = query.graph
62
+ edges = sorted(
63
+ {(e.source, e.target) for e in graph.edges if e.type == "imports"}
64
+ )
65
+ edges = [(s, t) for s, t in edges if _in_scope(s, scope) and _in_scope(t, scope)]
66
+ nodes = {n for pair in edges for n in pair}
67
+
68
+ lines = ["```mermaid", "graph LR"]
69
+ for nid in sorted(nodes):
70
+ lines.append(f' {_san(nid)}["{_short(nid)}"]')
71
+ for src, tgt in edges:
72
+ lines.append(f" {_san(src)} --> {_san(tgt)}")
73
+ lines.append("```")
74
+ return "\n".join(lines) + "\n"
75
+
76
+
77
+ def render_call_graph(query: Query, root: str, depth: int = 2) -> str:
78
+ """Call graph reachable from ``root`` within ``depth`` hops (``calls`` edges)."""
79
+ if root not in query.graph.nodes:
80
+ matches = query.find(root)
81
+ if not matches:
82
+ raise KeyError(f"symbol not found: {root}")
83
+ root = matches[0].id
84
+
85
+ seen = {root}
86
+ frontier = [root]
87
+ edges: set[tuple[str, str]] = set()
88
+ for _ in range(max(depth, 0)):
89
+ nxt = []
90
+ for node in frontier:
91
+ for callee in query.callees(node):
92
+ edges.add((node, callee))
93
+ if callee not in seen:
94
+ seen.add(callee)
95
+ nxt.append(callee)
96
+ frontier = nxt
97
+
98
+ lines = ["```mermaid", "graph LR"]
99
+ for nid in sorted(seen):
100
+ marker = ":::root" if nid == root else ""
101
+ lines.append(f' {_san(nid)}["{_short(nid)}"]{marker}')
102
+ for src, tgt in sorted(edges):
103
+ lines.append(f" {_san(src)} --> {_san(tgt)}")
104
+ lines.append(" classDef root fill:#f9f,stroke:#333;")
105
+ lines.append("```")
106
+ return "\n".join(lines) + "\n"
107
+
108
+
109
+ _KINDS = {"class": render_class_diagram, "deps": render_dep_graph}
110
+
111
+
112
+ def render_mermaid(query: Query, kind: str, scope: str | None = None,
113
+ root: str | None = None, depth: int = 2) -> str:
114
+ if kind == "calls":
115
+ if not root:
116
+ raise ValueError("mermaid 'calls' needs --root <symbol>")
117
+ return render_call_graph(query, root, depth)
118
+ if kind not in _KINDS:
119
+ raise ValueError(f"unknown mermaid kind: {kind}")
120
+ return _KINDS[kind](query, scope)
codemap/serve/pack.py ADDED
@@ -0,0 +1,93 @@
1
+ """Token-budgeted context pack (R1-C6) — codemap as a context provider.
2
+
3
+ codemap answers point queries; this adds the other half an AI-context tool needs:
4
+ *ranking* (what matters most) + a *budgeted render* (fit the most relevant slice of
5
+ the graph into N tokens). Nodes are scored by personalized PageRank
6
+ (:meth:`codemap.query.Query.rank`) — global importance, or relevance to ``seeds``
7
+ you're working on — then rendered highest-rank-first until the token budget is spent.
8
+
9
+ Token counting is a deterministic, dependency-free heuristic (~4 chars/token, the
10
+ common rule of thumb) — no tokenizer to install, and stable across runs. The pack is
11
+ a *lower bound on relevance, upper bound on size*: it never exceeds the budget, and
12
+ top hubs land before leaves (the acceptance).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from codemap.query import Query
18
+
19
+ # The signals a context consumer wants per symbol, compact. Kept deterministic.
20
+ _KIND_TAG = {"module": "mod", "class": "class", "function": "fn"}
21
+
22
+
23
+ def estimate_tokens(text: str) -> int:
24
+ """Cheap, deterministic token estimate (~4 chars/token); >=1 for any content."""
25
+ return max(1, len(text) // 4)
26
+
27
+
28
+ def _item_text(node) -> str:
29
+ """One compact context line for a symbol: id, kind, signature, docstring head."""
30
+ tag = _KIND_TAG.get(node.kind, node.kind)
31
+ sig = f" {node.signature}" if node.signature else ""
32
+ doc = ""
33
+ if node.docstring:
34
+ first = node.docstring.strip().splitlines()[0].strip()
35
+ if first:
36
+ doc = f" — {first}"
37
+ dep = " [deprecated]" if node.is_deprecated else ""
38
+ return f"{node.id} ({tag}){sig}{dep}{doc}"
39
+
40
+
41
+ def build_pack(query: Query, *, budget: int, seeds=(), root: str | None = None) -> dict:
42
+ """Rank symbols and greedily pack the most relevant under ``budget`` tokens.
43
+
44
+ Returns ``{budget, used_tokens, total_ranked, included, truncated, items}`` where
45
+ ``items`` is rank-ordered ``[{id, kind, rank, tokens, text}]``. Rank-order iteration
46
+ means top hubs (or seed-relevant symbols) are included before leaves; an item that
47
+ would overflow the budget is skipped so smaller relevant items can still fit.
48
+ """
49
+ ranked = query.rank(seeds=seeds, root=root)
50
+ order = sorted(ranked, key=lambda n: (-ranked[n], n)) # rank desc, id tiebreak
51
+ items: list[dict] = []
52
+ used = 0
53
+ truncated = False
54
+ for nid in order:
55
+ node = query.graph.nodes.get(nid)
56
+ if node is None:
57
+ continue
58
+ text = _item_text(node)
59
+ cost = estimate_tokens(text)
60
+ if used + cost > budget:
61
+ truncated = True
62
+ continue # skip; a later, smaller item may still fit
63
+ items.append({"id": nid, "kind": node.kind, "rank": ranked[nid],
64
+ "tokens": cost, "text": text})
65
+ used += cost
66
+ return {
67
+ "budget": budget,
68
+ "used_tokens": used,
69
+ "total_ranked": len(order),
70
+ "included": len(items),
71
+ "truncated": truncated,
72
+ "items": items,
73
+ }
74
+
75
+
76
+ def render_pack(query: Query, *, budget: int, seeds=(), root: str | None = None) -> str:
77
+ """Human/agent-readable markdown for a budgeted context pack."""
78
+ p = build_pack(query, budget=budget, seeds=seeds, root=root)
79
+ seed_note = f" · seeds: {', '.join(seeds)}" if seeds else " · global importance"
80
+ lines = [
81
+ f"# Context pack — `{query.graph.target}`{seed_note}",
82
+ "",
83
+ f"_{p['included']} / {p['total_ranked']} symbols · "
84
+ f"{p['used_tokens']} / {p['budget']} tokens"
85
+ f"{' · truncated (budget reached)' if p['truncated'] else ''}. "
86
+ "Ranked by PageRank importance; token estimate ≈ 4 chars/token._",
87
+ "",
88
+ ]
89
+ for it in p["items"]:
90
+ lines.append(f"- {it['text']}")
91
+ if not p["items"]:
92
+ lines.append("_nothing fit the budget._")
93
+ return "\n".join(lines).rstrip() + "\n"