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
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"
@@ -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"