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
|
@@ -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
|
+
)
|
codemap/serve/mermaid.py
ADDED
|
@@ -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"
|