codmap 0.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codemap/__init__.py +10 -0
- codemap/apidiff.py +208 -0
- codemap/arch.py +190 -0
- codemap/cli.py +718 -0
- codemap/diagnostics.py +256 -0
- codemap/extract/__init__.py +10 -0
- codemap/extract/attrflow.py +230 -0
- codemap/extract/behavior.py +771 -0
- codemap/extract/dataflow.py +97 -0
- codemap/extract/dispatch.py +248 -0
- codemap/extract/griffe_extractor.py +496 -0
- codemap/extract/gsource.py +83 -0
- codemap/extract/roots.py +427 -0
- codemap/freshness.py +94 -0
- codemap/incremental.py +195 -0
- codemap/integrations/__init__.py +51 -0
- codemap/integrations/base.py +196 -0
- codemap/integrations/cocoindex.py +78 -0
- codemap/integrations/gate.py +58 -0
- codemap/integrations/gitnexus.py +93 -0
- codemap/integrations/registry.py +69 -0
- codemap/integrations/transport.py +46 -0
- codemap/model.py +178 -0
- codemap/provenance.py +248 -0
- codemap/query.py +1164 -0
- codemap/scope.py +212 -0
- codemap/serve/__init__.py +26 -0
- codemap/serve/_scip_pb2.py +100 -0
- codemap/serve/api_surface.py +60 -0
- codemap/serve/apidiff.py +83 -0
- codemap/serve/architecture.py +101 -0
- codemap/serve/audit.py +176 -0
- codemap/serve/check.py +80 -0
- codemap/serve/ctags.py +203 -0
- codemap/serve/impact.py +84 -0
- codemap/serve/livingdocs.py +174 -0
- codemap/serve/mcp_server.py +278 -0
- codemap/serve/mermaid.py +120 -0
- codemap/serve/pack.py +93 -0
- codemap/serve/rag.py +142 -0
- codemap/serve/review.py +197 -0
- codemap/serve/scip.py +183 -0
- codemap/serve/semantic.py +71 -0
- codemap/serve/server.py +43 -0
- codemap/serve/session.py +482 -0
- codemap/serve/subsystems.py +85 -0
- codemap/serve/vault.py +156 -0
- codemap/store.py +28 -0
- codemap/tomlio.py +59 -0
- codmap-0.0.3.dist-info/METADATA +245 -0
- codmap-0.0.3.dist-info/RECORD +55 -0
- codmap-0.0.3.dist-info/WHEEL +5 -0
- codmap-0.0.3.dist-info/entry_points.txt +2 -0
- codmap-0.0.3.dist-info/licenses/LICENSE +21 -0
- codmap-0.0.3.dist-info/top_level.txt +1 -0
codemap/serve/audit.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Audit reports — consumer C (DESIGN §1-C): dependencies/cycles, dead code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from codemap.diagnostics import render_lines
|
|
6
|
+
from codemap.query import Query
|
|
7
|
+
from codemap.tomlio import read_toml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_dependencies(query: Query) -> str:
|
|
11
|
+
g = query.import_graph
|
|
12
|
+
lines = [f"# Module dependencies — `{query.graph.target}`", ""]
|
|
13
|
+
lines.append(f"_{g.number_of_nodes()} modules, {g.number_of_edges()} import edges._")
|
|
14
|
+
lines.append("")
|
|
15
|
+
# R1-C21: name what the graph cannot support before listing conclusions drawn from
|
|
16
|
+
# it; each check supplies its own consequence, or none (issue #8).
|
|
17
|
+
lines.extend(render_lines(query.graph))
|
|
18
|
+
|
|
19
|
+
cycles = query.import_cycles()
|
|
20
|
+
lines.append(f"## Import cycles: {len(cycles)}")
|
|
21
|
+
lines.append("")
|
|
22
|
+
if cycles:
|
|
23
|
+
for cyc in sorted(cycles, key=lambda c: (len(c), c)):
|
|
24
|
+
lines.append(f"- {' → '.join(cyc)} → {cyc[0]}")
|
|
25
|
+
else:
|
|
26
|
+
lines.append("_none — import graph is acyclic._")
|
|
27
|
+
lines.append("")
|
|
28
|
+
|
|
29
|
+
lines.append("## Most-depended-on modules (top 15)")
|
|
30
|
+
lines.append("")
|
|
31
|
+
ranked = sorted(g.nodes, key=lambda m: g.in_degree(m), reverse=True)
|
|
32
|
+
for mid in ranked[:15]:
|
|
33
|
+
deg = g.in_degree(mid)
|
|
34
|
+
if deg == 0:
|
|
35
|
+
break
|
|
36
|
+
lines.append(f"- `{mid}` — imported by {deg}")
|
|
37
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_dead_code_whitelist(root: str | None) -> tuple[tuple[str, ...], str | None]:
|
|
41
|
+
"""Read ``[dead_code].whitelist`` (exact ids / globs) from codemap.toml under ``root``.
|
|
42
|
+
|
|
43
|
+
Returns ``(whitelist, error)``. Empty on an absent file — and *also* empty on a file
|
|
44
|
+
that would not parse, because a bad whitelist must never break a report — but the two
|
|
45
|
+
are no longer the same answer (R1-C27): the reason comes back so the report can say
|
|
46
|
+
that nothing was suppressed because nothing could be read.
|
|
47
|
+
"""
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
if not root:
|
|
50
|
+
return (), None
|
|
51
|
+
data, error = read_toml(Path(root) / "codemap.toml")
|
|
52
|
+
if error:
|
|
53
|
+
return (), error
|
|
54
|
+
return tuple(data.get("dead_code", {}).get("whitelist", []) or []), None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_dead_code(query: Query, *, whitelist: tuple[str, ...] = (),
|
|
58
|
+
min_confidence: str | None = None,
|
|
59
|
+
whitelist_error: str | None = None) -> str:
|
|
60
|
+
by_root = query.orphan_modules_by_root()
|
|
61
|
+
core_orphans = by_root.get("core", [])
|
|
62
|
+
consumer = {r: v for r, v in by_root.items() if r != "core"}
|
|
63
|
+
dead = query.dead_code(whitelist=whitelist, min_confidence=min_confidence)
|
|
64
|
+
lines = [f"# Dead-code candidates — `{query.graph.target}`", ""]
|
|
65
|
+
lines.append(
|
|
66
|
+
"_Static heuristics only — dynamic imports, CLI entry points, test targets "
|
|
67
|
+
"and partial call resolution (~1/4 of sites, gaps/ CM-09) are blind spots. "
|
|
68
|
+
"**Candidates, not proof.**_"
|
|
69
|
+
)
|
|
70
|
+
lines.append("")
|
|
71
|
+
# R1-C27: an unreadable codemap.toml suppresses nothing, and a list below that is
|
|
72
|
+
# longer than the user expects is otherwise indistinguishable from a whitelist that
|
|
73
|
+
# did not match. Say it before the findings, like every other blind spot here.
|
|
74
|
+
if whitelist_error:
|
|
75
|
+
lines.append(f"> ⚠️ **Whitelist not read — nothing is suppressed.** {whitelist_error}")
|
|
76
|
+
lines.append("")
|
|
77
|
+
# R1-C21: name what the graph cannot support before listing conclusions drawn from
|
|
78
|
+
# it; each check supplies its own consequence, or none (issue #8).
|
|
79
|
+
lines.extend(render_lines(query.graph))
|
|
80
|
+
# F8: on a repo-scoped graph, consumer roots are orphan by nature (nobody
|
|
81
|
+
# imports an entrypoint). Only core orphans are candidate dead code.
|
|
82
|
+
lines.append(f"## Orphan modules — core (no incoming imports): {len(core_orphans)}")
|
|
83
|
+
lines.append("")
|
|
84
|
+
lines.extend([f"- `{mid}`" for mid in core_orphans] or ["_none._"])
|
|
85
|
+
if consumer:
|
|
86
|
+
total = sum(len(v) for v in consumer.values())
|
|
87
|
+
breakdown = ", ".join(f"{r} {len(v)}" for r, v in sorted(consumer.items()))
|
|
88
|
+
lines.append("")
|
|
89
|
+
lines.append(f"## Consumer entrypoints (orphan by nature, not dead code): {total}")
|
|
90
|
+
lines.append("")
|
|
91
|
+
lines.append(
|
|
92
|
+
f"_{breakdown} — tests/examples/scripts/research are never imported; "
|
|
93
|
+
"expected orphan. Excluded from dead-code candidates (F8)._"
|
|
94
|
+
)
|
|
95
|
+
# R1-C8: uncalled private functions, graded by confidence with a provenance reason.
|
|
96
|
+
lines.append("")
|
|
97
|
+
filt = f" (min-confidence: {min_confidence})" if min_confidence else ""
|
|
98
|
+
wl = f", {len(whitelist)} whitelisted pattern(s)" if whitelist else ""
|
|
99
|
+
lines.append(f"## Uncalled private functions: {len(dead)}{filt}{wl}")
|
|
100
|
+
lines.append("")
|
|
101
|
+
lines.append("_Private functions with no incoming resolved call, graded by how sure. "
|
|
102
|
+
"**high** = no inbound edge or hook; **medium** = a decorator/registry "
|
|
103
|
+
"may invoke it implicitly; **low** = something references it (likely alive)._")
|
|
104
|
+
lines.append("")
|
|
105
|
+
if not dead:
|
|
106
|
+
lines.append("_none._")
|
|
107
|
+
for level in ("high", "medium", "low"):
|
|
108
|
+
rows = [c for c in dead if c["confidence"] == level]
|
|
109
|
+
if not rows:
|
|
110
|
+
continue
|
|
111
|
+
lines.append(f"### {level} ({len(rows)})")
|
|
112
|
+
lines.append("")
|
|
113
|
+
for c in rows:
|
|
114
|
+
reason = "; ".join(c["reasons"])
|
|
115
|
+
lines.append(f"- `{c['id']}` — {reason}")
|
|
116
|
+
lines.append("")
|
|
117
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def render_behavior(query: Query) -> str:
|
|
121
|
+
"""Consumer A/C: honest call-graph coverage + type-flow spot-check (M4)."""
|
|
122
|
+
graph = query.graph
|
|
123
|
+
funcs = [n for n in graph.nodes.values() if n.kind == "function"]
|
|
124
|
+
with_cov = [n for n in funcs if "calls" in n.extras]
|
|
125
|
+
agg = {"out": 0, "resolved": 0, "external": 0, "unresolved": 0, "dynamic": 0}
|
|
126
|
+
for n in with_cov:
|
|
127
|
+
for k, v in n.extras["calls"].items():
|
|
128
|
+
agg[k] += v
|
|
129
|
+
total = agg["out"] or 1
|
|
130
|
+
lines = [f"# Behavioral layer — `{graph.target}`", ""]
|
|
131
|
+
lines.append(
|
|
132
|
+
"_Best-effort static call-graph (DESIGN §7). Calls on local variables need "
|
|
133
|
+
"type inference and are left unresolved on purpose (gaps/ CM-09/10)._"
|
|
134
|
+
)
|
|
135
|
+
lines.append("")
|
|
136
|
+
lines.append(f"## Call-site resolution ({agg['out']} sites in {len(with_cov)} functions)")
|
|
137
|
+
lines.append("")
|
|
138
|
+
lines.append(f"- resolved to internal edges: **{agg['resolved']}** ({100*agg['resolved']/total:.1f}%)")
|
|
139
|
+
lines.append(f"- external / builtin (flagged): {agg['external']} ({100*agg['external']/total:.1f}%)")
|
|
140
|
+
lines.append(f"- dynamic string-keyed: {agg['dynamic']} ({100*agg['dynamic']/total:.1f}%)")
|
|
141
|
+
lines.append(f"- unresolved (local vars — parked): {agg['unresolved']} ({100*agg['unresolved']/total:.1f}%)")
|
|
142
|
+
lines.append("")
|
|
143
|
+
by_res: dict[str, int] = {}
|
|
144
|
+
for e in graph.edges:
|
|
145
|
+
if e.type == "calls":
|
|
146
|
+
by_res[e.extras.get("resolution", "?")] = by_res.get(e.extras.get("resolution", "?"), 0) + 1
|
|
147
|
+
calls_edges = sum(by_res.values())
|
|
148
|
+
lines.append(f"_Emitted {calls_edges} `calls` edges (deduped caller→callee)._")
|
|
149
|
+
bridged = by_res.get("registry", 0) + by_res.get("registry-candidate", 0)
|
|
150
|
+
if bridged:
|
|
151
|
+
lines.append("")
|
|
152
|
+
lines.append(
|
|
153
|
+
f"## Registry-bridged dispatch (M7): {bridged} edges "
|
|
154
|
+
f"({by_res.get('registry', 0)} exact, {by_res.get('registry-candidate', 0)} candidate)"
|
|
155
|
+
)
|
|
156
|
+
lines.append("")
|
|
157
|
+
lines.append(
|
|
158
|
+
"_Factory/registry seams (`create_x`, `Registry.get`) bridged to registered "
|
|
159
|
+
"impls via the M1.5 table. **Candidate** edges are an over-approximation "
|
|
160
|
+
"(dispatches to one of a family) — real for navigation, not for exact counts._"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# -- complexity (R1-C4) -------------------------------------------------
|
|
164
|
+
scored = [(n, n.extras["complexity"]) for n in funcs if "complexity" in n.extras]
|
|
165
|
+
if scored:
|
|
166
|
+
ccs = [m["cc"] for _, m in scored]
|
|
167
|
+
avg_cc = sum(ccs) / len(ccs)
|
|
168
|
+
top = sorted(scored, key=lambda x: (-x[1]["cc"], x[0].id))[:10]
|
|
169
|
+
lines.append("")
|
|
170
|
+
lines.append(f"## Complexity ({len(scored)} functions, mean CC {avg_cc:.1f})")
|
|
171
|
+
lines.append("")
|
|
172
|
+
lines.append("_CC = McCabe cyclomatic; MI = Maintainability Index (0–100, higher is better)._")
|
|
173
|
+
lines.append("")
|
|
174
|
+
for n, m in top:
|
|
175
|
+
lines.append(f"- `{n.id}` — CC {m['cc']}, MI {m['mi']} ({m['sloc']} sloc)")
|
|
176
|
+
return "\n".join(lines).rstrip() + "\n"
|
codemap/serve/check.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Render the architecture-contract check (R1-C3) — structured + markdown.
|
|
2
|
+
|
|
3
|
+
The gate itself lives in ``codemap.arch``; this is the presentation layer shared by
|
|
4
|
+
the ``check`` CLI command and the ``check`` serve op. A clean run is deliberately
|
|
5
|
+
quiet (one line); a failing run names every offending import edge so the fix is
|
|
6
|
+
mechanical.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from codemap.arch import ArchitectureContract, Violation
|
|
12
|
+
|
|
13
|
+
_EDGE_CAP = 25 # offending edges listed per rule before "+N more"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_check(query, contract: ArchitectureContract, violations: list[Violation]) -> dict:
|
|
17
|
+
"""Structured result: ok flag + violations with their concrete edges.
|
|
18
|
+
|
|
19
|
+
``ok`` answers *may the pipeline proceed?*, so an unreadable ``codemap.toml`` makes it
|
|
20
|
+
false with no violations listed — there are none to list, because no rule ever ran
|
|
21
|
+
(R1-C27). ``contract_error`` says why. This is the JSON-first surface, so it carried
|
|
22
|
+
the same lie as the markdown one and is fixed in the same place.
|
|
23
|
+
"""
|
|
24
|
+
return {
|
|
25
|
+
"target": query.graph.target,
|
|
26
|
+
"contract_empty": contract.is_empty(),
|
|
27
|
+
"contract_error": contract.error,
|
|
28
|
+
"ok": not violations and contract.error is None,
|
|
29
|
+
"violations": [
|
|
30
|
+
{"rule": v.rule, "summary": v.summary,
|
|
31
|
+
"edges": [list(e) for e in v.edges],
|
|
32
|
+
"modules": list(v.modules)}
|
|
33
|
+
for v in violations
|
|
34
|
+
],
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render_check(query, contract: ArchitectureContract, violations: list[Violation]) -> str:
|
|
39
|
+
target = query.graph.target
|
|
40
|
+
# R1-C27: ask `error` before `is_empty()`. A contract that would not parse used to be
|
|
41
|
+
# reported as a contract that does not exist, which is how one missing `]` turned a
|
|
42
|
+
# failing gate green. Nothing was enforced either way — but only one of the two is the
|
|
43
|
+
# user's decision, and the caller exits non-zero on this one.
|
|
44
|
+
if contract.error:
|
|
45
|
+
return (f"# Architecture check — `{target}`\n\n"
|
|
46
|
+
f"❌ **Contract not read — nothing was enforced.** {contract.error}\n\n"
|
|
47
|
+
"_Fix `codemap.toml` (or remove it) and run again. This is a failure, not "
|
|
48
|
+
"an absent contract: rules may exist that no rule-check ran against._\n")
|
|
49
|
+
if contract.is_empty():
|
|
50
|
+
return (f"# Architecture check — `{target}`\n\n"
|
|
51
|
+
"_No `[architecture]` contract found in codemap.toml — nothing to enforce._\n")
|
|
52
|
+
if not violations:
|
|
53
|
+
rules = []
|
|
54
|
+
if contract.layers:
|
|
55
|
+
rules.append(f"layered ({len(contract.layers)})")
|
|
56
|
+
if contract.independent:
|
|
57
|
+
rules.append(f"independent ({len(contract.independent)})")
|
|
58
|
+
if contract.forbidden:
|
|
59
|
+
rules.append(f"forbidden ({len(contract.forbidden)})")
|
|
60
|
+
if contract.no_cycles:
|
|
61
|
+
rules.append("no_cycles")
|
|
62
|
+
if contract.exhaustive:
|
|
63
|
+
rules.append("exhaustive")
|
|
64
|
+
return (f"# Architecture check — `{target}`\n\n"
|
|
65
|
+
f"✅ **Contract satisfied.** Rules enforced: {', '.join(rules)}.\n")
|
|
66
|
+
|
|
67
|
+
out = [f"# Architecture check — `{target}`", "",
|
|
68
|
+
f"❌ **{len(violations)} rule(s) broken.**", ""]
|
|
69
|
+
for v in violations:
|
|
70
|
+
out.append(f"## `{v.rule}` — {v.summary}")
|
|
71
|
+
out.append("")
|
|
72
|
+
if v.edges:
|
|
73
|
+
for u, w in v.edges[:_EDGE_CAP]:
|
|
74
|
+
out.append(f"- `{u}` → `{w}`")
|
|
75
|
+
if len(v.edges) > _EDGE_CAP:
|
|
76
|
+
out.append(f"- _… {len(v.edges) - _EDGE_CAP} more_")
|
|
77
|
+
for m in v.modules:
|
|
78
|
+
out.append(f"- {m}")
|
|
79
|
+
out.append("")
|
|
80
|
+
return "\n".join(out).rstrip() + "\n"
|
codemap/serve/ctags.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""ctags export — codemap's graph as a universal-ctags ``tags`` file (R1-C2).
|
|
2
|
+
|
|
3
|
+
The ``tags`` file is the lowest common denominator of code navigation: vim, Emacs,
|
|
4
|
+
``readtags`` and countless editors do go-to-definition by binary-searching a sorted
|
|
5
|
+
``tags`` file. codemap already knows every definition's name, file, line and scope,
|
|
6
|
+
so emitting this format is near-free interop — the "floor" of capability codemap
|
|
7
|
+
comfortably clears (research/00_landscape.md ranks ctags a *learn/emit* peer that
|
|
8
|
+
survives on simplicity where heavier graph indexers churned).
|
|
9
|
+
|
|
10
|
+
**Format.** Extended (exuberant/universal-ctags) format, one line per definition::
|
|
11
|
+
|
|
12
|
+
{name}<Tab>{file}<Tab>{address};"<Tab>{kind}<Tab>{ext fields}
|
|
13
|
+
|
|
14
|
+
The address is a search pattern ``/^<source line>$/`` when the source is readable
|
|
15
|
+
(robust to line drift — the whole point of ctags patterns), else a bare line number
|
|
16
|
+
(always available from the graph). Extension fields carry ``line:``, ``scope`` (e.g.
|
|
17
|
+
``class:Foo``), ``typeref`` / ``signature`` (functions), ``access`` (public/private)
|
|
18
|
+
and ``end:`` — all facts codemap already holds, no guessing.
|
|
19
|
+
|
|
20
|
+
**Honest scope.** Definitions only (classes / functions / methods / attributes);
|
|
21
|
+
codemap tracks no token positions, so this is a *tags* file, not a references index
|
|
22
|
+
(that is SCIP's job, R1-C1). Modules are files, not tags, so they are skipped — as
|
|
23
|
+
universal-ctags itself does. Output is byte-stable: pseudo-tags declare it sorted,
|
|
24
|
+
and real tags are sorted by name (then file, then address) for binary search.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
|
|
31
|
+
from codemap.model import Graph
|
|
32
|
+
from codemap.query import Query
|
|
33
|
+
|
|
34
|
+
# universal-ctags Python kind letters. Modules are not tagged (a module is a file).
|
|
35
|
+
# Methods (function whose parent is a class) use 'm'; module/function-level defs 'f'.
|
|
36
|
+
_CLASS, _FUNC, _METHOD, _VAR = "c", "f", "m", "v"
|
|
37
|
+
|
|
38
|
+
# Long kind names for the scope field key (``class:Foo`` / ``function:bar``).
|
|
39
|
+
_SCOPE_KIND = {"class": "class", "function": "function"}
|
|
40
|
+
|
|
41
|
+
# Node kinds that become tags (module/doc/column are skipped).
|
|
42
|
+
_TAGGABLE = {"class", "function", "attribute"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _kind_letter(kind: str, parent_kind: str | None) -> str:
|
|
46
|
+
if kind == "class":
|
|
47
|
+
return _CLASS
|
|
48
|
+
if kind == "function":
|
|
49
|
+
return _METHOD if parent_kind == "class" else _FUNC
|
|
50
|
+
return _VAR # attribute
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parent_id(nid: str) -> str | None:
|
|
54
|
+
return nid.rsplit(".", 1)[0] if "." in nid else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _module_prefix(nodes: dict, nid: str) -> str | None:
|
|
58
|
+
"""Return the id of the nearest module ancestor of ``nid`` (for scope stripping)."""
|
|
59
|
+
parts = nid.split(".")
|
|
60
|
+
for i in range(len(parts) - 1, 0, -1):
|
|
61
|
+
pid = ".".join(parts[:i])
|
|
62
|
+
node = nodes.get(pid)
|
|
63
|
+
if node and node.kind == "module":
|
|
64
|
+
return pid
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _scope_field(nodes: dict, nid: str) -> str | None:
|
|
69
|
+
"""Build the ``<scopekind>:<name>`` field, or None for a top-level (module-scoped) def."""
|
|
70
|
+
parent = _parent_id(nid)
|
|
71
|
+
if parent is None:
|
|
72
|
+
return None
|
|
73
|
+
pnode = nodes.get(parent)
|
|
74
|
+
if pnode is None or pnode.kind == "module":
|
|
75
|
+
return None # top-level def — universal-ctags omits module scope
|
|
76
|
+
scopekind = _SCOPE_KIND.get(pnode.kind, pnode.kind)
|
|
77
|
+
mod = _module_prefix(nodes, parent)
|
|
78
|
+
name = parent[len(mod) + 1:] if mod and parent.startswith(mod + ".") else parent
|
|
79
|
+
return f"{scopekind}:{name}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _param_list(signature: str) -> str | None:
|
|
83
|
+
"""Extract the parenthesised parameter list from a signature (paren-balanced)."""
|
|
84
|
+
start = signature.find("(")
|
|
85
|
+
if start == -1:
|
|
86
|
+
return None
|
|
87
|
+
depth = 0
|
|
88
|
+
for i in range(start, len(signature)):
|
|
89
|
+
c = signature[i]
|
|
90
|
+
if c == "(":
|
|
91
|
+
depth += 1
|
|
92
|
+
elif c == ")":
|
|
93
|
+
depth -= 1
|
|
94
|
+
if depth == 0:
|
|
95
|
+
return signature[start:i + 1]
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _return_type(signature: str) -> str | None:
|
|
100
|
+
arrow = signature.rfind("->")
|
|
101
|
+
if arrow == -1:
|
|
102
|
+
return None
|
|
103
|
+
ret = signature[arrow + 2:].strip()
|
|
104
|
+
return ret or None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _escape_pattern(line: str) -> str:
|
|
108
|
+
"""Escape a source line for a ctags ``/^…$/`` search address.
|
|
109
|
+
|
|
110
|
+
Backslash first, then the pattern delimiter and regex end-anchor so a literal
|
|
111
|
+
``/`` or ``$`` in the line does not break or mis-anchor the match.
|
|
112
|
+
"""
|
|
113
|
+
return line.replace("\\", "\\\\").replace("/", "\\/").replace("$", "\\$")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _tab_sanitize(field: str) -> str:
|
|
117
|
+
"""Tabs/newlines are field separators — never let them into a field value."""
|
|
118
|
+
return field.replace("\t", " ").replace("\r", " ").replace("\n", " ")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class _SourceLines:
|
|
122
|
+
"""Lazily read source files (relative to ``root``) to build search-pattern addresses."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, root: str | None):
|
|
125
|
+
self.root = root
|
|
126
|
+
self._cache: dict[str, list[str] | None] = {}
|
|
127
|
+
|
|
128
|
+
def line(self, rel_path: str, lineno: int) -> str | None:
|
|
129
|
+
if self.root is None or not lineno:
|
|
130
|
+
return None
|
|
131
|
+
lines = self._cache.get(rel_path, "unset")
|
|
132
|
+
if lines == "unset":
|
|
133
|
+
try:
|
|
134
|
+
with open(os.path.join(self.root, rel_path), encoding="utf-8") as fh:
|
|
135
|
+
lines = fh.read().split("\n")
|
|
136
|
+
except (OSError, UnicodeDecodeError):
|
|
137
|
+
lines = None
|
|
138
|
+
self._cache[rel_path] = lines
|
|
139
|
+
if not lines or lineno > len(lines):
|
|
140
|
+
return None
|
|
141
|
+
return lines[lineno - 1]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _address(src: _SourceLines, file: str, lineno: int) -> str:
|
|
145
|
+
"""Search-pattern address if the source line is readable, else a bare line number."""
|
|
146
|
+
text = src.line(file, lineno)
|
|
147
|
+
if text is not None:
|
|
148
|
+
return f"/^{_escape_pattern(text)}$/"
|
|
149
|
+
return str(lineno)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_ctags(
|
|
153
|
+
query: Query,
|
|
154
|
+
*,
|
|
155
|
+
source_root: str | None = None,
|
|
156
|
+
tool_version: str = "0.0.1",
|
|
157
|
+
) -> str:
|
|
158
|
+
"""Render the graph as a sorted universal-ctags ``tags`` file (text)."""
|
|
159
|
+
graph: Graph = query.graph
|
|
160
|
+
nodes = graph.nodes
|
|
161
|
+
src = _SourceLines(source_root)
|
|
162
|
+
|
|
163
|
+
rows: list[tuple[str, str, str]] = [] # (name, file, full line) — sort key is (name, file, addr)
|
|
164
|
+
for nid in nodes:
|
|
165
|
+
node = nodes[nid]
|
|
166
|
+
if node.kind not in _TAGGABLE or not node.file or not node.lineno:
|
|
167
|
+
continue
|
|
168
|
+
name = nid.rsplit(".", 1)[-1]
|
|
169
|
+
parent = _parent_id(nid)
|
|
170
|
+
parent_kind = nodes[parent].kind if parent and parent in nodes else None
|
|
171
|
+
|
|
172
|
+
address = _address(src, node.file, node.lineno)
|
|
173
|
+
fields = [_kind_letter(node.kind, parent_kind), f"line:{node.lineno}"]
|
|
174
|
+
scope = _scope_field(nodes, nid)
|
|
175
|
+
if scope:
|
|
176
|
+
fields.append(scope)
|
|
177
|
+
if node.kind == "function" and node.signature:
|
|
178
|
+
params = _param_list(node.signature)
|
|
179
|
+
if params:
|
|
180
|
+
fields.append(f"signature:{params}")
|
|
181
|
+
ret = _return_type(node.signature)
|
|
182
|
+
if ret:
|
|
183
|
+
fields.append(f"typeref:typename:{ret}")
|
|
184
|
+
fields.append(f"access:{node.visibility}")
|
|
185
|
+
if node.endlineno and node.endlineno != node.lineno:
|
|
186
|
+
fields.append(f"end:{node.endlineno}")
|
|
187
|
+
|
|
188
|
+
fields = [_tab_sanitize(f) for f in fields]
|
|
189
|
+
line = f"{name}\t{node.file}\t{address};\"\t" + "\t".join(fields)
|
|
190
|
+
rows.append((name, node.file, line))
|
|
191
|
+
|
|
192
|
+
# Sorted by name (byte order), then file, then the whole line — binary-searchable.
|
|
193
|
+
rows.sort(key=lambda r: (r[0], r[1], r[2]))
|
|
194
|
+
|
|
195
|
+
header = [
|
|
196
|
+
"!_TAG_FILE_FORMAT\t2\t/extended format; --format=1 will not append ;\" to lines/",
|
|
197
|
+
"!_TAG_FILE_SORTED\t1\t/0=unsorted, 1=sorted, 2=foldcase/",
|
|
198
|
+
"!_TAG_PROGRAM_NAME\tcodemap\t//",
|
|
199
|
+
"!_TAG_PROGRAM_URL\thttps://github.com/kogriv/codemap\t/graph-native tags/",
|
|
200
|
+
f"!_TAG_PROGRAM_VERSION\t{_tab_sanitize(tool_version)}\t//",
|
|
201
|
+
]
|
|
202
|
+
body = [r[2] for r in rows]
|
|
203
|
+
return "\n".join(header + body) + "\n"
|
codemap/serve/impact.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Impact / blast-radius report — consumer C (DESIGN §10.12, M6).
|
|
2
|
+
|
|
3
|
+
"Can I change / remove X, and what breaks?" — the question the single-package
|
|
4
|
+
graph could not answer (the blast radius lives in tests/docs/examples, outside
|
|
5
|
+
the package; dogfood F1). Needs a repo-scoped graph (``extract_repo``); on a
|
|
6
|
+
core-only graph it simply reports in-package references.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from codemap.query import Query
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def render_impact(query: Query, symbol: str, *, depth: int = 2) -> str:
|
|
15
|
+
"""Markdown blast-radius for the symbol matching ``symbol`` (short or full)."""
|
|
16
|
+
ids = query.impact_targets(symbol) # F23: short name / full id / re-export
|
|
17
|
+
lines = [f"# Impact — `{symbol}`", ""]
|
|
18
|
+
lines.append(
|
|
19
|
+
"_Best-effort static blast radius: who references the symbol (and its "
|
|
20
|
+
"members), grouped by repo root. Call resolution is partial (gaps/ CM-09), "
|
|
21
|
+
"so this is a **lower bound** — pair with grep before deleting._"
|
|
22
|
+
)
|
|
23
|
+
lines.append("")
|
|
24
|
+
if not ids:
|
|
25
|
+
lines.append(f"_No definition found for `{symbol}`._")
|
|
26
|
+
return "\n".join(lines) + "\n"
|
|
27
|
+
|
|
28
|
+
for sid in ids:
|
|
29
|
+
rep = query.impact(sid, depth=depth)
|
|
30
|
+
refs = rep["refs"]
|
|
31
|
+
by_root = rep["by_root"]
|
|
32
|
+
lines.append(f"## `{sid}`")
|
|
33
|
+
lines.append("")
|
|
34
|
+
if not refs:
|
|
35
|
+
lines.append("_No inbound references — isolated in the analysed roots._")
|
|
36
|
+
lines.append("")
|
|
37
|
+
continue
|
|
38
|
+
total = len(refs)
|
|
39
|
+
roots = ", ".join(f"{r} ({sum(by_root[r].values())})" for r in sorted(by_root))
|
|
40
|
+
lines.append(f"**{total} references across roots:** {roots}")
|
|
41
|
+
# R1-C19: risk triage + depth histogram (transitive reach at a glance).
|
|
42
|
+
hist = ", ".join(f"d{d}×{rep['by_distance'][d]}" for d in sorted(rep["by_distance"]))
|
|
43
|
+
lines.append(
|
|
44
|
+
f"**Risk: {rep['risk'].upper()}** — depth reached {rep['max_distance']}"
|
|
45
|
+
+ (f"; distances: {hist}" if hist else "")
|
|
46
|
+
+ " _(heuristic: breadth × reach × root-spread)_"
|
|
47
|
+
)
|
|
48
|
+
lines.append("")
|
|
49
|
+
# per-root breakdown, direct refs first.
|
|
50
|
+
for root in sorted(by_root):
|
|
51
|
+
counts = ", ".join(f"{t}×{c}" for t, c in sorted(by_root[root].items()))
|
|
52
|
+
lines.append(f"### {root} — {counts}")
|
|
53
|
+
direct = sorted(
|
|
54
|
+
{r["source"] for r in refs if r["root"] == root and r["distance"] == 1}
|
|
55
|
+
)
|
|
56
|
+
for src in direct[:40]:
|
|
57
|
+
lines.append(f"- `{src}`")
|
|
58
|
+
if len(direct) > 40:
|
|
59
|
+
lines.append(f"- _… {len(direct) - 40} more_")
|
|
60
|
+
indirect = {r["source"] for r in refs if r["root"] == root and r["distance"] > 1}
|
|
61
|
+
if indirect:
|
|
62
|
+
lines.append(f"- _+{len(indirect)} transitive (distance >1)_")
|
|
63
|
+
lines.append("")
|
|
64
|
+
|
|
65
|
+
# F7: argument contract of the call-sites — what a signature change touches.
|
|
66
|
+
contract = query.call_contract(sid)
|
|
67
|
+
if contract:
|
|
68
|
+
sites = sum(c["callsites"] for c in contract)
|
|
69
|
+
lines.append(f"### Call-site contract ({sites} sites — for signature change)")
|
|
70
|
+
lines.append("")
|
|
71
|
+
for c in contract:
|
|
72
|
+
pos = "/".join(map(str, c["posargs"])) or "0"
|
|
73
|
+
kw = ", ".join(c["kwargs"]) or "—"
|
|
74
|
+
splat = " +splat" if c["splat"] else ""
|
|
75
|
+
lines.append(
|
|
76
|
+
f"- `{c['caller']}` ×{c['callsites']} — {pos} positional, kwargs: {kw}{splat}"
|
|
77
|
+
)
|
|
78
|
+
lines.append("")
|
|
79
|
+
lines.append(
|
|
80
|
+
"_Positional counts / kwarg names observed at the call-sites (resolved "
|
|
81
|
+
"edges only). Use to see which sites break under an arity/keyword change._"
|
|
82
|
+
)
|
|
83
|
+
lines.append("")
|
|
84
|
+
return "\n".join(lines).rstrip() + "\n"
|