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/query.py ADDED
@@ -0,0 +1,1164 @@
1
+ """Query layer over the canonical graph (DESIGN §4, §1).
2
+
3
+ The canonical store is JSON; this is the in-memory query backend (networkx),
4
+ built from it — not the other way round. Answers the §1 catalog: find a symbol,
5
+ where it is defined (through re-exports), and module dependencies both ways.
6
+ Larger scale would swap networkx for SQLite/Neo4j behind this same surface (§4).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import fnmatch
12
+ import re
13
+
14
+ import networkx as nx
15
+
16
+ from codemap.model import Graph, Node
17
+
18
+ # Dead-code confidence, most-certain first (R1-C8). "high" = no inbound edge of any
19
+ # kind and no decorator/registry hook; "medium" = an implicit-use hook (decorator /
20
+ # registry) could invoke it; "low" = something references it, so it's likely alive.
21
+ _CONFIDENCE_RANK = {"low": 0, "medium": 1, "high": 2}
22
+
23
+ _IDENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
24
+ # typing wrappers are containers, not the payload type we key flow on.
25
+ _TYPE_NOISE = {"Optional", "List", "Dict", "Tuple", "Set", "Union", "Any",
26
+ "Sequence", "Iterable", "Mapping", "Callable", "Type", "None"}
27
+
28
+
29
+ def _type_tokens(type_str: str) -> set[str]:
30
+ return {t for t in _IDENT.findall(type_str or "") if t not in _TYPE_NOISE}
31
+
32
+
33
+ # edge types that carry a "who depends on whom" signal for blast-radius (M6).
34
+ # ``accesses`` (R1-C20) always targets an ``attribute`` node, so adding it here
35
+ # extends impact/references_to to fields *only* — it never touches non-attribute
36
+ # blast-radius. Columns' ``reads``/``writes`` stay out on purpose (M12): a column
37
+ # is a data key, not a symbol whose change breaks a caller.
38
+ _IMPACT_EDGES = ("calls", "references", "inherits", "imports", "decorated_by",
39
+ "accesses")
40
+
41
+ # Edges that carry "usage / dependency" for relevance ranking (R1-C6). Directed
42
+ # user→used, so PageRank accumulates rank on heavily-depended-upon symbols. contains
43
+ # is excluded on purpose (structural, would just inflate big modules).
44
+ _RANK_EDGE_TYPES = ("calls", "imports", "references", "inherits", "implements")
45
+
46
+ # Test-mapping walk (R1-C24). Execution and naming relations only: `imports` is
47
+ # module→module and would spread the answer across the package without saying anything
48
+ # about what a *test* runs; `inherits` is kept because exercising a subclass does exercise
49
+ # its base. Deliberately narrower than _IMPACT_EDGES, which answers a different question.
50
+ _TEST_WALK_EDGES = ("calls", "references", "accesses", "inherits")
51
+
52
+ #: How far back a *confident* answer may look. Not taste — measured against coverage.py
53
+ #: ground truth on codemap's own suite (R1-C24 D6), where precision falls off a cliff and
54
+ #: the answer size explodes at the fourth hop:
55
+ #:
56
+ #: nearest hop symbols median precision median tests returned
57
+ #: 1 63 1.00 2
58
+ #: 2 91 1.00 4
59
+ #: 3 44 1.00 8
60
+ #: 4 61 0.67 78
61
+ #: 5 29 0.33 78
62
+ #:
63
+ #: By the fourth hop the walk has reached shared test infrastructure and is answering
64
+ #: "most of the suite" — worse than useless as a default, so it is available only when
65
+ #: asked for explicitly, and labelled `low`.
66
+ _TEST_MAX_DEPTH = 3
67
+ _TEST_DEEP_DEPTH = 6
68
+ #: Cap on tests listed per answer. Truncation is always *stated* — a silently trimmed
69
+ #: list reads as "these are all of them", which is issue #5 in a new costume.
70
+ _TEST_CAP = 25
71
+ #: Distance → how much the answer is worth, from the same measurement.
72
+ _TEST_CONFIDENCE = {1: "high", 2: "high", 3: "medium"}
73
+
74
+
75
+ def _pagerank(g: "nx.DiGraph", personalization: dict[str, float] | None,
76
+ *, alpha: float = 0.85, max_iter: int = 100, tol: float = 1.0e-9) -> dict:
77
+ """Pure-Python personalized PageRank (power iteration) — no numpy/scipy dep.
78
+
79
+ networkx's ``pagerank`` needs scipy; codemap stays lightweight (griffe/jedi/
80
+ networkx only), so we run the standard algorithm ourselves. Deterministic: nodes
81
+ are processed in sorted order and the iteration is a fixed contraction.
82
+ """
83
+ nodes = sorted(g)
84
+ n = len(nodes)
85
+ if n == 0:
86
+ return {}
87
+ if personalization and sum(personalization.values()) > 0:
88
+ s = sum(personalization.values())
89
+ p = {v: personalization.get(v, 0.0) / s for v in nodes}
90
+ else:
91
+ p = {v: 1.0 / n for v in nodes}
92
+ outdeg = {v: g.out_degree(v) for v in nodes}
93
+ dangling = [v for v in nodes if outdeg[v] == 0]
94
+ x = dict(p)
95
+ for _ in range(max_iter):
96
+ xlast = x
97
+ x = {v: 0.0 for v in nodes}
98
+ danglesum = alpha * sum(xlast[v] for v in dangling)
99
+ for v in nodes:
100
+ share = alpha * xlast[v] / outdeg[v] if outdeg[v] else 0.0
101
+ for w in sorted(g.successors(v)):
102
+ x[w] += share
103
+ for v in nodes:
104
+ x[v] += danglesum * p[v] + (1.0 - alpha) * p[v]
105
+ if sum(abs(x[v] - xlast[v]) for v in nodes) < n * tol:
106
+ break
107
+ return x
108
+
109
+
110
+ class Query:
111
+ def __init__(self, graph: Graph) -> None:
112
+ self.graph = graph
113
+ self._imports = nx.DiGraph()
114
+ for n in graph.nodes.values():
115
+ if n.kind == "module":
116
+ self._imports.add_node(n.id)
117
+ for e in graph.edges:
118
+ if e.type == "imports":
119
+ self._imports.add_edge(e.source, e.target)
120
+ # export edges: name -> [target definition paths]
121
+ self._exports: dict[str, list[str]] = {}
122
+ for e in graph.edges:
123
+ if e.type == "export":
124
+ self._exports.setdefault(e.extras.get("as", ""), []).append(e.target)
125
+ # inherits edges: class -> base (source imports base). Externals kept.
126
+ self._inherits = nx.DiGraph()
127
+ for e in graph.edges:
128
+ if e.type == "inherits":
129
+ self._inherits.add_edge(e.source, e.target)
130
+ # decorated_by edges: keep as (source, decorator-path) pairs.
131
+ self._decorated: list[tuple[str, str]] = [
132
+ (e.source, e.target) for e in graph.edges if e.type == "decorated_by"
133
+ ]
134
+ # calls edges (M4 behavioral layer): caller -> callee.
135
+ self._calls = nx.DiGraph()
136
+ # F7: callee -> [(caller, edge extras)] to expose the argument contract.
137
+ self._call_in: dict[str, list[tuple[str, dict]]] = {}
138
+ for e in graph.edges:
139
+ if e.type == "calls":
140
+ self._calls.add_edge(e.source, e.target)
141
+ self._call_in.setdefault(e.target, []).append((e.source, e.extras))
142
+ # implements edges (M9/F4): concrete impl -> Protocol (structural typing,
143
+ # synthesised via the registry family since it's never inherited).
144
+ self._implements = nx.DiGraph()
145
+ for e in graph.edges:
146
+ if e.type == "implements":
147
+ self._implements.add_edge(e.source, e.target)
148
+ # string-key dataflow (M12/F6): column id -> {writers, readers}.
149
+ self._col_writers: dict[str, set[str]] = {}
150
+ self._col_readers: dict[str, set[str]] = {}
151
+ for e in graph.edges:
152
+ if e.type == "writes":
153
+ self._col_writers.setdefault(e.target, set()).add(e.source)
154
+ elif e.type == "reads":
155
+ self._col_readers.setdefault(e.target, set()).add(e.source)
156
+ # attribute access (R1-C20): attribute id -> {writers, readers}, from the
157
+ # `accesses` edges (extras.access read/write). Powers readers()/writers()
158
+ # and the honest field-impact answer (issue #1).
159
+ self._attr_writers: dict[str, set[str]] = {}
160
+ self._attr_readers: dict[str, set[str]] = {}
161
+ for e in graph.edges:
162
+ if e.type == "accesses":
163
+ bucket = (self._attr_writers if e.extras.get("access") == "write"
164
+ else self._attr_readers)
165
+ bucket.setdefault(e.target, set()).add(e.source)
166
+ # provenance (M6): node id -> root (core | tests | docs | ...).
167
+ self._root_of = {n.id: n.extras.get("root", "core") for n in graph.nodes.values()}
168
+ # inbound index for impact/blast-radius: target -> [(source, edge_type)].
169
+ self._inbound: dict[str, list[tuple[str, str]]] = {}
170
+ for e in graph.edges:
171
+ if e.type in _IMPACT_EDGES:
172
+ self._inbound.setdefault(e.target, []).append((e.source, e.type))
173
+ # R1-C24: the same index forward, for `covers` (what does this test exercise).
174
+ self._outbound: dict[str, list[tuple[str, str]]] = {}
175
+ for e in graph.edges:
176
+ if e.type in _IMPACT_EDGES:
177
+ self._outbound.setdefault(e.source, []).append((e.target, e.type))
178
+ self._module_ids = [n.id for n in graph.nodes.values() if n.kind == "module"]
179
+ self._test_ids = {i for i in self.graph.nodes if self._is_test(i)}
180
+
181
+ # -- lookups -------------------------------------------------------------
182
+
183
+ def find(self, name: str) -> list[Node]:
184
+ """Definition nodes whose short name matches ``name``."""
185
+ return sorted(
186
+ (n for n in self.graph.nodes.values() if n.id.rsplit(".", 1)[-1] == name),
187
+ key=lambda n: n.id,
188
+ )
189
+
190
+ def canonical(self, name_or_id: str) -> str | None:
191
+ """Resolve a short name or **re-export id** to the canonical node id (F13).
192
+
193
+ Relational edges are keyed by the definition id (``…detection.base.X``),
194
+ but ``query`` surfaces re-export paths (``…detection.X``); feeding the
195
+ latter to ``implementers``/``callers`` silently returned nothing. This maps
196
+ either form to the real node so the natural chain works. Returns the chosen
197
+ id only; use :meth:`canonical_info` to also learn if the choice was a guess.
198
+ """
199
+ info = self.canonical_info(name_or_id)
200
+ return info["id"] if info else None
201
+
202
+ def canonical_info(self, name_or_id: str) -> dict | None:
203
+ """Resolve a name/re-export id **with an ambiguity signal** (M14/F13/F14).
204
+
205
+ Returns ``{input, id, ambiguous, alternatives}`` or ``None`` if no node
206
+ matches. ``ambiguous`` is True when ≥2 candidates tie on the disambiguation
207
+ signal — i.e. the choice was **arbitrary** (a bare short name like
208
+ ``calculate`` has 25 defs and no path to disambiguate). The B1 dogfood found
209
+ such names resolve silently to one def (even a test mock), so relational ops
210
+ can now warn instead of confidently answering about the wrong symbol.
211
+ """
212
+ if name_or_id in self.graph.nodes:
213
+ return {"input": name_or_id, "id": name_or_id,
214
+ "ambiguous": False, "alternatives": []}
215
+ short = name_or_id.rsplit(".", 1)[-1]
216
+ cands = [n.id for n in self.find(short)]
217
+ if not cands:
218
+ return None
219
+ if len(cands) == 1:
220
+ return {"input": name_or_id, "id": cands[0],
221
+ "ambiguous": False, "alternatives": []}
222
+ # prefer the node sharing the most path components with the requested id;
223
+ # ambiguous iff ≥2 candidates tie on that max (the pick fell back to -len).
224
+ parts = set(name_or_id.split("."))
225
+ shared = {c: len(parts & set(c.split("."))) for c in cands}
226
+ best = max(shared.values())
227
+ chosen = max(cands, key=lambda c: (shared[c], -len(c)))
228
+ ambiguous = sum(1 for c in cands if shared[c] == best) > 1
229
+ return {"input": name_or_id, "id": chosen, "ambiguous": ambiguous,
230
+ "alternatives": sorted(c for c in cands if c != chosen)}
231
+
232
+ def search(self, term: str, *, kind: str | None = None, limit: int = 50) -> list[dict]:
233
+ """Substring search over node ids — the discovery entry point (F9).
234
+
235
+ Case-insensitive match on the id (so both short name and module path hit).
236
+ Optional ``kind`` filter. Returns ``{id, kind, file, lineno}`` for a cold
237
+ agent that does not yet know exact names.
238
+ """
239
+ t = term.lower()
240
+ out = [
241
+ {"id": n.id, "kind": n.kind, "file": n.file, "lineno": n.lineno}
242
+ for n in self.graph.nodes.values()
243
+ if (kind is None or n.kind == kind) and t in n.id.lower()
244
+ ]
245
+ return sorted(out, key=lambda r: (len(r["id"]), r["id"]))[:limit]
246
+
247
+ # -- location → symbol (M15/F16 — the reviewer's diff entry point) --------
248
+
249
+ def _defs_in_file(self, file: str):
250
+ """(lineno, endlineno, id) for located definitions in ``file`` + module id."""
251
+ defs, module = [], None
252
+ for n in self.graph.nodes.values():
253
+ if n.file != file:
254
+ continue
255
+ if n.kind == "module":
256
+ module = n.id
257
+ elif n.kind in ("function", "class", "attribute") and n.lineno is not None:
258
+ defs.append((n.lineno, n.endlineno or n.lineno, n.id))
259
+ return defs, module
260
+
261
+ def symbol_at(self, file: str, line: int) -> str | None:
262
+ """Innermost definition whose span contains ``(file, line)`` (M15/F16).
263
+
264
+ The reviewer's entry point: a diff gives ``file:line``, not a symbol name.
265
+ Nodes carry ``file``/``lineno``/``endlineno`` — this walks them to the
266
+ tightest enclosing function/class/attribute, falling back to the **module**
267
+ when the line is module-level code (between defs, e.g. a top-level dict) so a
268
+ change there is never silently dropped. Returns None if the file is unknown
269
+ (e.g. a consumer-root node that carries no ``file``).
270
+ """
271
+ defs, module = self._defs_in_file(file)
272
+ best, best_span = None, None
273
+ for lo, hi, nid in defs:
274
+ if lo <= line <= hi and (best is None or (hi - lo) < best_span):
275
+ best, best_span = nid, hi - lo
276
+ return best or module
277
+
278
+ def symbols_in_range(self, file: str, start: int, end: int) -> list[str]:
279
+ """Distinct innermost symbols a hunk ``file:[start,end]`` touches (M15/F16).
280
+
281
+ Per changed line, the tightest enclosing symbol (module fallback), deduped —
282
+ so a hunk landing in one method of a big class yields that method, not the
283
+ whole class. Powers change-set review from raw diff hunks.
284
+ """
285
+ defs, module = self._defs_in_file(file)
286
+ if not defs and module is None:
287
+ return []
288
+ out: set[str] = set()
289
+ for line in range(start, end + 1):
290
+ best, best_span = None, None
291
+ for lo, hi, nid in defs:
292
+ if lo <= line <= hi and (best is None or (hi - lo) < best_span):
293
+ best, best_span = nid, hi - lo
294
+ out.add(best or module)
295
+ out.discard(None)
296
+ return sorted(out)
297
+
298
+ def where_defined(self, name: str) -> list[str]:
299
+ """Canonical definition path(s) for ``name`` — resolving re-exports.
300
+
301
+ Returns definition-node ids named ``name`` plus any re-export targets
302
+ exposed under that name (e.g. ``analyze_zones`` -> its pipeline def).
303
+ """
304
+ ids = {n.id for n in self.find(name)}
305
+ ids.update(self._exports.get(name, []))
306
+ return sorted(ids)
307
+
308
+ def impact_targets(self, name_or_id: str) -> list[str]:
309
+ """Node id(s) to run impact over, from a name / **full id** / re-export (F23).
310
+
311
+ The blast-radius surface previously resolved the input via short-name
312
+ ``find`` only, so a canonical/full id (``pkg.mod.Class`` — exactly what the
313
+ agent gets back from ``query``/``search``) matched nothing and returned an
314
+ empty impact, even for a widely-used symbol. Order: an existing node id maps
315
+ to itself; else all short-name matches (kept — a bare name like ``calculate``
316
+ legitimately fans out); else the canonical resolution of a re-export; else
317
+ ``where_defined``.
318
+ """
319
+ if name_or_id in self.graph.nodes:
320
+ return [name_or_id]
321
+ matches = [n.id for n in self.find(name_or_id)]
322
+ if matches:
323
+ return matches
324
+ canon = self.canonical(name_or_id)
325
+ return [canon] if canon else self.where_defined(name_or_id)
326
+
327
+ # -- module dependencies (both directions) ------------------------------
328
+
329
+ def dependencies(self, module_id: str) -> list[str]:
330
+ """Modules that ``module_id`` imports."""
331
+ if module_id not in self._imports:
332
+ return []
333
+ return sorted(self._imports.successors(module_id))
334
+
335
+ def dependents(self, module_id: str) -> list[str]:
336
+ """Modules that import ``module_id``."""
337
+ if module_id not in self._imports:
338
+ return []
339
+ return sorted(self._imports.predecessors(module_id))
340
+
341
+ # -- class hierarchy (inherits edges) -----------------------------------
342
+
343
+ def bases(self, class_id: str) -> list[str]:
344
+ """Direct base classes of ``class_id`` (internal + external)."""
345
+ if class_id not in self._inherits:
346
+ return []
347
+ return sorted(self._inherits.successors(class_id))
348
+
349
+ def subclasses(self, class_id: str) -> list[str]:
350
+ """Direct subclasses of ``class_id``."""
351
+ if class_id not in self._inherits:
352
+ return []
353
+ return sorted(self._inherits.predecessors(class_id))
354
+
355
+ def implementers(self, protocol_id: str) -> list[str]:
356
+ """Concrete classes that implement ``protocol_id`` (registry family, M9)."""
357
+ if protocol_id not in self._implements:
358
+ return []
359
+ return sorted(self._implements.predecessors(protocol_id))
360
+
361
+ def implements(self, class_id: str) -> list[str]:
362
+ """Protocol(s) ``class_id`` structurally satisfies (via its registry family)."""
363
+ if class_id not in self._implements:
364
+ return []
365
+ return sorted(self._implements.successors(class_id))
366
+
367
+ def family_siblings(self, class_id: str) -> list[str]:
368
+ """Other impls of the same Protocol family as ``class_id`` (M9)."""
369
+ sibs: set[str] = set()
370
+ for proto in self.implements(class_id):
371
+ sibs.update(self.implementers(proto))
372
+ sibs.discard(class_id)
373
+ return sorted(sibs)
374
+
375
+ def decorated_with(self, decorator: str) -> list[str]:
376
+ """Symbols decorated by ``decorator`` (matched on full path or short name)."""
377
+ return sorted(
378
+ src
379
+ for src, dec in self._decorated
380
+ if dec == decorator or dec.rsplit(".", 1)[-1] == decorator
381
+ )
382
+
383
+ # -- call graph (M4, best-effort — see gaps/ CM-09) ----------------------
384
+
385
+ def callers(self, symbol_id: str) -> list[str]:
386
+ """Functions that statically call ``symbol_id`` (resolved calls only)."""
387
+ if symbol_id not in self._calls:
388
+ return []
389
+ return sorted(self._calls.predecessors(symbol_id))
390
+
391
+ def callees(self, symbol_id: str) -> list[str]:
392
+ """Internal symbols ``symbol_id`` statically calls."""
393
+ if symbol_id not in self._calls:
394
+ return []
395
+ return sorted(self._calls.successors(symbol_id))
396
+
397
+ def call_contract(self, symbol_id: str) -> list[dict]:
398
+ """Per-caller argument contract of calls into ``symbol_id`` (+ members) — F7.
399
+
400
+ For signature-change reasoning: each entry gives the calling function, how
401
+ many call-sites it holds (``callsites`` — the collapse the edge hides), and
402
+ the observed argument shape (``posargs`` / ``kwargs`` / ``splat``). Only
403
+ resolved behavioral edges carry this; bridged (registry) edges do not.
404
+ """
405
+ out = []
406
+ for tgt in sorted(self._member_ids(symbol_id)):
407
+ for src, extras in self._call_in.get(tgt, []):
408
+ if "callsites" not in extras:
409
+ continue # bridge / edge without captured contract
410
+ out.append({
411
+ "caller": src, "target": tgt,
412
+ "callsites": extras.get("callsites", 1),
413
+ "posargs": extras.get("posargs", []),
414
+ "kwargs": extras.get("kwargs", []),
415
+ "splat": extras.get("splat", False),
416
+ })
417
+ return sorted(out, key=lambda r: (r["caller"], r["target"]))
418
+
419
+ # -- string-key dataflow (M12/F6) ----------------------------------------
420
+
421
+ def column(self, name: str) -> dict | None:
422
+ """Producers/consumers of the string key ``name`` (DataFrame column etc.).
423
+
424
+ Returns ``{writes: [funcs], reads: [funcs]}`` or ``None`` if the key was
425
+ never seen as a subscript. Over-set of true columns (dict keys included);
426
+ querying a specific key is still precise. See gap-doc F6.
427
+ """
428
+ col_id = name if name.startswith("column:") else "column:" + name
429
+ if col_id not in self.graph.nodes:
430
+ return None
431
+ return {
432
+ "writes": sorted(self._col_writers.get(col_id, set())),
433
+ "reads": sorted(self._col_readers.get(col_id, set())),
434
+ }
435
+
436
+ def columns(self, *, subscripted_only: bool = True) -> list[str]:
437
+ """String-key column nodes (M14/F15).
438
+
439
+ By default returns only keys ever accessed as a subscript (``x['k']``) — the
440
+ real column-like set. The B1 dogfood found 71% of raw keys were dict-literal
441
+ payload keys (result dicts, config, rcParams); ``subscripted_only=False``
442
+ returns the full over-set (unchanged historical behavior).
443
+ """
444
+ return sorted(
445
+ n.extras.get("key", n.id[len("column:"):])
446
+ for n in self.graph.nodes.values()
447
+ if n.kind == "column"
448
+ and (not subscripted_only or n.extras.get("subscripted", True))
449
+ )
450
+
451
+ def columns_of(self, func_id: str) -> dict:
452
+ """Which string keys ``func_id`` reads / writes — reverse dataflow (F11).
453
+
454
+ The mirror of ``column()``: standing on a function, see the data it touches
455
+ (``{reads: [key], writes: [key]}``), not just who touches a given key.
456
+ """
457
+ reads, writes = [], []
458
+ for col_id, funcs in self._col_readers.items():
459
+ if func_id in funcs:
460
+ reads.append(col_id[len("column:"):])
461
+ for col_id, funcs in self._col_writers.items():
462
+ if func_id in funcs:
463
+ writes.append(col_id[len("column:"):])
464
+ return {"reads": sorted(reads), "writes": sorted(writes)}
465
+
466
+ # -- attribute access (R1-C20, issue #1) ---------------------------------
467
+
468
+ def readers(self, attr_id: str) -> list[str]:
469
+ """Functions that *read* the attribute ``attr_id`` (resolves name/re-export).
470
+
471
+ The attribute analog of ``column().reads``: standing on a class field, see
472
+ who consumes it. Lower bound — attribute resolution is best-effort (fast
473
+ ``self.``/``ClassName.``/construction; deep ``obj.field``), like the calls
474
+ layer. Returns ``[]`` for an unknown id or one with no modelled reader.
475
+ """
476
+ canon = self.canonical(attr_id) or attr_id
477
+ return sorted(self._attr_readers.get(canon, set()))
478
+
479
+ def writers(self, attr_id: str) -> list[str]:
480
+ """Functions that *write* the attribute ``attr_id`` (assignment / construction).
481
+
482
+ The write side of :meth:`readers`; construction kwargs (``Cls(field=…)``)
483
+ count as writes to ``Cls.field`` (D3). Lower bound, same caveat.
484
+ """
485
+ canon = self.canonical(attr_id) or attr_id
486
+ return sorted(self._attr_writers.get(canon, set()))
487
+
488
+ # -- registry families (M9/F4, surfaced for extension recipes — F10) ------
489
+
490
+ def families(self) -> list[dict]:
491
+ """Registry/Protocol families with their registration recipe (F9/F10).
492
+
493
+ Each: the Protocol, its implementers, and per-member the registration
494
+ ``decorator`` + ``key`` — i.e. how to add a new one. Lets a cold agent
495
+ enumerate extension points and learn *how to plug in*, not just *what*.
496
+ """
497
+ out = []
498
+ for pid in sorted({e.target for e in self.graph.edges if e.type == "implements"}):
499
+ members = []
500
+ for impl in self.implementers(pid):
501
+ reg = self.graph.nodes[impl].extras.get("registry", {}) if impl in self.graph.nodes else {}
502
+ members.append({"class": impl, "key": reg.get("key"),
503
+ "decorator": reg.get("decorator")})
504
+ out.append({"protocol": pid, "members": members})
505
+ return out
506
+
507
+ def dead_symbols(self) -> list[str]:
508
+ """Ids of all uncalled-private-function candidates (any confidence).
509
+
510
+ Thin back-compat wrapper over :meth:`dead_code` (unfiltered) — a private
511
+ function with no incoming resolved call. See ``dead_code`` for the graded,
512
+ provenance-annotated form. Sorted by id (as before the grading was added).
513
+ """
514
+ return sorted(c["id"] for c in self.dead_code())
515
+
516
+ def dead_code(self, *, whitelist: tuple[str, ...] = (),
517
+ min_confidence: str | None = None) -> list[dict]:
518
+ """Graded dead-code candidates with a provenance reason (R1-C8).
519
+
520
+ A candidate is a **private** function with no incoming *resolved call*
521
+ (dunders excluded — invoked implicitly). Restricted to private because a
522
+ public uncalled function may be external API. Call resolution is partial
523
+ (~1/4 of sites; gaps/ CM-09), so this is triage, never proof — but codemap's
524
+ cross-root graph lets us grade each candidate instead of listing flat, which
525
+ is what cuts the false positives a call-only tool (vulture) can't:
526
+
527
+ - **high** — no inbound edge of *any* kind (call / reference / re-export) and
528
+ no decorator or registry hook: the strongest dead signal.
529
+ - **medium** — no inbound reference, but a decorator or registry membership
530
+ could invoke it implicitly (a framework hook, dispatched impl).
531
+ - **low** — something *references* it (a re-export, a name put in a list, a
532
+ registration): probably alive; the reason names who, so you can judge.
533
+
534
+ Symbols declared only in a ``.pyi`` stub are excluded outright (R1-C23): a stub
535
+ is a declaration, not code, so "nothing calls it" says nothing about it.
536
+
537
+ ``whitelist`` suppresses candidates by exact id or glob (``fnmatch``).
538
+ ``min_confidence`` (``low``/``medium``/``high``) drops anything below it.
539
+ Sorted most-confident first, then by id.
540
+ """
541
+ out: list[dict] = []
542
+ for n in self.graph.nodes.values():
543
+ if n.kind != "function" or n.visibility != "private":
544
+ continue
545
+ name = n.id.rsplit(".", 1)[-1]
546
+ if name.startswith("__") and name.endswith("__"):
547
+ continue # dunder — invoked implicitly
548
+ if n.extras.get("stub"):
549
+ continue # R1-C23/D5: a `.pyi` declaration has no body to be dead
550
+ if n.id in self._calls and self._calls.in_degree(n.id) > 0:
551
+ continue # has a resolved caller — not a candidate
552
+ if any(fnmatch.fnmatch(n.id, pat) for pat in whitelist):
553
+ continue # explicitly suppressed
554
+ out.append(self._grade_dead(n))
555
+
556
+ out.sort(key=lambda c: (-_CONFIDENCE_RANK[c["confidence"]], c["id"]))
557
+ if min_confidence:
558
+ floor = _CONFIDENCE_RANK[min_confidence]
559
+ out = [c for c in out if _CONFIDENCE_RANK[c["confidence"]] >= floor]
560
+ return out
561
+
562
+ def _grade_dead(self, n: Node) -> dict:
563
+ """Score one uncalled-private candidate → {id, confidence, root, reasons}."""
564
+ refs = self.references_to(n.id) # inbound of every kind, across roots
565
+ registry = n.extras.get("registry")
566
+ if refs:
567
+ by: dict[tuple[str, str], int] = {}
568
+ for r in refs:
569
+ by[(r["root"], r["type"])] = by.get((r["root"], r["type"]), 0) + 1
570
+ reasons = [f"referenced ({t}) by {root}×{c}"
571
+ for (root, t), c in sorted(by.items())]
572
+ confidence = "low"
573
+ elif n.decorators or registry:
574
+ reasons = [f"decorated by @{d.rsplit('.', 1)[-1]} — may be invoked implicitly"
575
+ for d in (n.decorators or [])]
576
+ if registry:
577
+ reasons.append(
578
+ f"registered as {registry.get('key', '?')!r} — may be dispatched")
579
+ confidence = "medium"
580
+ else:
581
+ reasons = ["no inbound calls, references, or decorators"]
582
+ confidence = "high"
583
+ return {"id": n.id, "confidence": confidence,
584
+ "root": self.root_of(n.id), "reasons": reasons}
585
+
586
+ # -- impact / blast-radius (M6 — repo scope) -----------------------------
587
+
588
+ def root_of(self, node_id: str) -> str:
589
+ """Provenance root of a node (``core`` if untagged / single-package graph)."""
590
+ return self._root_of.get(node_id, "core")
591
+
592
+ def _member_ids(self, symbol_id: str) -> set[str]:
593
+ """The symbol plus its members (a class' methods are called, not the class)."""
594
+ return {
595
+ i for i in self.graph.nodes
596
+ if i == symbol_id or i.startswith(symbol_id + ".")
597
+ }
598
+
599
+ def references_to(self, symbol_id: str, *, include_members: bool = True) -> list[dict]:
600
+ """Direct inbound references to ``symbol_id`` (+ members), each tagged by root.
601
+
602
+ Spans every impact edge (calls / references / inherits / imports /
603
+ decorated_by), so it reaches consumers outside the package (tests, docs,
604
+ examples) once the graph was built repo-scoped (``extract_repo``).
605
+ """
606
+ targets = self._member_ids(symbol_id) if include_members else {symbol_id}
607
+ out = []
608
+ for t in sorted(targets):
609
+ for src, etype in self._inbound.get(t, []):
610
+ if src in targets:
611
+ continue # self-internal (a method calling a sibling)
612
+ out.append({"source": src, "type": etype,
613
+ "root": self.root_of(src), "target": t})
614
+ return sorted(out, key=lambda r: (r["root"], r["type"], r["source"]))
615
+
616
+ def impact(self, symbol_id: str, *, depth: int = 2) -> dict:
617
+ """Blast radius of changing/removing ``symbol_id``.
618
+
619
+ Distance 1 = direct references (incl. to its members); further distances
620
+ follow inbound edges transitively up to ``depth``. Returns the ref list
621
+ (each with ``distance``) plus a ``by_root`` count matrix. Best-effort —
622
+ call resolution is partial (gaps/ CM-09), so this is a lower bound.
623
+ """
624
+ refs = self.references_to(symbol_id)
625
+ for r in refs:
626
+ r["distance"] = 1
627
+ seen = self._member_ids(symbol_id) | {r["source"] for r in refs}
628
+ current = {r["source"] for r in refs}
629
+ dist = 1
630
+ while dist < depth and current:
631
+ nxt: set[str] = set()
632
+ for node in sorted(current):
633
+ for src, etype in self._inbound.get(node, []):
634
+ if src in seen:
635
+ continue
636
+ seen.add(src)
637
+ refs.append({"source": src, "type": etype,
638
+ "root": self.root_of(src), "target": node,
639
+ "distance": dist + 1})
640
+ nxt.add(src)
641
+ current = nxt
642
+ dist += 1
643
+
644
+ by_root: dict[str, dict[str, int]] = {}
645
+ for r in refs:
646
+ by_root.setdefault(r["root"], {}).setdefault(r["type"], 0)
647
+ by_root[r["root"]][r["type"]] += 1
648
+ # R1-C19: depth histogram (refs per transitive distance) + a triage risk
649
+ # label from the blast-radius shape — from the GitNexus разбор, built on
650
+ # our own graph (no external dep).
651
+ by_distance: dict[int, int] = {}
652
+ for r in refs:
653
+ by_distance[r["distance"]] = by_distance.get(r["distance"], 0) + 1
654
+ max_distance = max(by_distance) if by_distance else 0
655
+ node = self.graph.nodes.get(symbol_id)
656
+ kind = node.kind if node else None
657
+ out = {"symbol": symbol_id, "refs": refs, "by_root": by_root,
658
+ "by_distance": by_distance, "max_distance": max_distance,
659
+ "risk": self._impact_risk(len(refs), max_distance, len(by_root),
660
+ kind=kind)}
661
+ # Honesty (R1-C20 P0, issue #1): a field with no modelled accessor is a
662
+ # *lower bound*, not proof of safety — attribute access resolution is
663
+ # best-effort. Say so instead of the affirmative "none".
664
+ if kind == "attribute" and not refs:
665
+ out["risk_reason"] = ("attribute access is modelled best-effort; "
666
+ "no accessor found is a lower bound, not proof "
667
+ "nothing depends on this field")
668
+ return out
669
+
670
+ @staticmethod
671
+ def _impact_risk(breadth: int, reach: int, roots: int, *,
672
+ kind: str | None = None) -> str:
673
+ """Heuristic change-risk from blast-radius shape (breadth × reach × root-spread).
674
+
675
+ Not a proof — a triage signal (like dead-code confidence). Breadth (how many
676
+ references) dominates; transitive ``reach`` and ``roots`` (how many provenance
677
+ roots — core/tests/docs/… — are touched) amplify it, since a symbol used
678
+ across roots is costlier to change. Pair with the ref list before acting.
679
+
680
+ For an ``attribute`` (R1-C20) an empty blast-radius is ``unknown``, not
681
+ ``none``: attribute access is modelled best-effort, so "no accessor" is a
682
+ lower bound. For a function/class the call/reference layer does target them,
683
+ so empty stays the honest ``none``.
684
+ """
685
+ if breadth == 0:
686
+ return "unknown" if kind == "attribute" else "none"
687
+ if breadth >= 30 or roots >= 4 or (breadth >= 15 and reach >= 3):
688
+ return "high"
689
+ if breadth >= 5 or roots >= 2 or reach >= 2:
690
+ return "medium"
691
+ return "low"
692
+
693
+ # -- subsystems: communities + flows (R1-C18) ----------------------------
694
+
695
+ def communities(self) -> list[dict]:
696
+ """Module subsystems via greedy modularity over the (undirected) import graph.
697
+
698
+ A community is a set of modules that import each other more than the rest —
699
+ a data-driven *subsystem*. Uses ``greedy_modularity_communities``, which is
700
+ **deterministic** (order-stable) — on-brand vs seed-dependent Louvain. Each
701
+ cluster is labelled by its dominant layer (component under the package root).
702
+ Inspired by the GitNexus разбор (Leiden clusters); computed natively on our
703
+ own graph — no external dependency. Sorted by size desc, then first module.
704
+ """
705
+ from collections import Counter
706
+ from networkx.algorithms import community as _comm
707
+ # Subsystems of the *package* = core modules only; consumer roots
708
+ # (tests/docs/examples) are not subsystems and would drag labels to
709
+ # "(root)" (they live outside the package namespace). Matches entry_points.
710
+ core_mods = [m for m in self._imports.nodes if self.root_of(m) == "core"]
711
+ ug = self._imports.subgraph(core_mods).to_undirected()
712
+ if ug.number_of_edges() == 0:
713
+ return []
714
+ out = []
715
+ for members in _comm.greedy_modularity_communities(ug):
716
+ mods = sorted(members)
717
+ layers = Counter(self._layer_of(m) for m in mods)
718
+ out.append({"label": layers.most_common(1)[0][0],
719
+ "size": len(mods), "modules": mods})
720
+ out.sort(key=lambda c: (-c["size"], c["modules"][0]))
721
+ return out
722
+
723
+ def entry_points(self, root: str = "core") -> list[str]:
724
+ """Call-forest roots: functions that call out but are never called (resolved).
725
+
726
+ Where behaviour starts — public API / mains / not-yet-triggered. Restricted
727
+ to one provenance ``root`` (default core). Best-effort: call resolution is
728
+ partial, so an unresolved caller can leave a real internal as an entry point.
729
+ """
730
+ return sorted(
731
+ n for n in self._calls.nodes
732
+ if self.root_of(n) == root
733
+ and self._calls.out_degree(n) > 0 and self._calls.in_degree(n) == 0
734
+ )
735
+
736
+ def flow(self, entry: str, *, max_depth: int = 5) -> dict:
737
+ """Forward call-flow from ``entry`` along ``calls`` edges, bounded by depth.
738
+
739
+ The mirror of :meth:`impact` (which walks inbound): *what does calling this
740
+ set in motion*. Each edge is tagged with its distance from the entry; each
741
+ node is expanded once (cycle-safe). Partial (resolution gaps) → lower bound.
742
+ """
743
+ if entry not in self._calls:
744
+ return {"entry": entry, "edges": [], "reached": 0, "max_depth": 0}
745
+ edges: list[dict] = []
746
+ seen = {entry}
747
+ current = {entry}
748
+ dist = 0
749
+ while dist < max_depth and current:
750
+ nxt: set[str] = set()
751
+ for src in sorted(current):
752
+ for tgt in sorted(self._calls.successors(src)):
753
+ edges.append({"source": src, "target": tgt, "distance": dist + 1})
754
+ if tgt not in seen:
755
+ seen.add(tgt)
756
+ nxt.add(tgt)
757
+ current = nxt
758
+ dist += 1
759
+ return {"entry": entry, "edges": edges, "reached": len(seen) - 1,
760
+ "max_depth": max((e["distance"] for e in edges), default=0)}
761
+
762
+ # -- relevance ranking (R1-C6) -------------------------------------------
763
+
764
+ def _expand_seeds(self, seeds) -> set[str]:
765
+ """Map each seed (node id / short name / file path) to concrete node ids."""
766
+ out: set[str] = set()
767
+ files = {n.file for n in self.graph.nodes.values() if n.file}
768
+ for s in seeds:
769
+ if s in self.graph.nodes:
770
+ out.add(s)
771
+ elif s in files:
772
+ out.update(n.id for n in self.graph.nodes.values() if n.file == s)
773
+ else:
774
+ out.update(n.id for n in self.find(s)) # short name → matches
775
+ return out
776
+
777
+ def rank(self, *, seeds=(), edge_types=_RANK_EDGE_TYPES,
778
+ root: str | None = None) -> dict[str, float]:
779
+ """Importance rank over usage edges via PageRank (R1-C6).
780
+
781
+ Without ``seeds``: global importance — heavily-depended-upon symbols (imports
782
+ /calls/references pointing at them) rank high. With ``seeds`` (node ids, short
783
+ names, or file paths): personalized restart biased to them → relevance to that
784
+ context (aider's repo-map trick). ``root`` restricts to one provenance root.
785
+ Deterministic: PageRank is a fixed power-iteration; scores rounded, ties broken
786
+ by id at the call sites that order them.
787
+ """
788
+ g = nx.DiGraph()
789
+ for nid, n in self.graph.nodes.items():
790
+ if n.kind in ("module", "class", "function") and (
791
+ root is None or self.root_of(nid) == root):
792
+ g.add_node(nid)
793
+ ets = set(edge_types)
794
+ for e in self.graph.edges:
795
+ if e.type in ets and e.source in g and e.target in g:
796
+ g.add_edge(e.source, e.target)
797
+ if g.number_of_nodes() == 0:
798
+ return {}
799
+ personalization = None
800
+ if seeds:
801
+ seed_ids = {s for s in self._expand_seeds(seeds) if s in g}
802
+ if seed_ids: # restart biased to seeds (normalized inside _pagerank)
803
+ personalization = {n: (1.0 if n in seed_ids else 0.0) for n in g}
804
+ pr = _pagerank(g, personalization)
805
+ return {n: round(v, 8) for n, v in pr.items()}
806
+
807
+ # -- type flow (M4 — producers/consumers by signature type) --------------
808
+
809
+ def producers(self, type_name: str) -> list[str]:
810
+ """Functions whose return type mentions ``type_name``."""
811
+ return sorted(
812
+ n.id for n in self.graph.nodes.values()
813
+ if n.kind == "function" and type_name in _type_tokens(n.extras.get("returns", ""))
814
+ )
815
+
816
+ def consumers(self, type_name: str) -> list[str]:
817
+ """Functions that take a parameter whose type mentions ``type_name``."""
818
+ out = []
819
+ for n in self.graph.nodes.values():
820
+ if n.kind != "function":
821
+ continue
822
+ for p in n.extras.get("params", []):
823
+ if type_name in _type_tokens(p.get("type", "")):
824
+ out.append(n.id)
825
+ break
826
+ return sorted(out)
827
+
828
+ # -- graph-wide ----------------------------------------------------------
829
+
830
+ def import_cycles(self) -> list[list[str]]:
831
+ return [c for c in nx.simple_cycles(self._imports)]
832
+
833
+ def orphan_modules(self, root: str | None = None) -> list[str]:
834
+ """Modules with no incoming imports (dead-code candidates — heuristic).
835
+
836
+ Excludes the package root and ``__init__``/``__main__`` (entry points).
837
+ Static heuristic: dynamic imports / entry points are not visible.
838
+
839
+ ``root`` (M6/F8): restrict to one provenance root. On a repo-scoped graph
840
+ consumer roots (``tests``/``examples``/``scripts``/``research``) are orphan
841
+ **by nature** — nothing imports an entrypoint — so ``root="core"`` isolates
842
+ the only orphans that mean *dead code*. Default ``None`` = every root.
843
+ """
844
+ pkg_root = self.graph.target
845
+ out = []
846
+ for mid in self._imports.nodes:
847
+ if mid == pkg_root or mid.rsplit(".", 1)[-1] in {"__init__", "__main__"}:
848
+ continue
849
+ if root is not None and self.root_of(mid) != root:
850
+ continue
851
+ if self._imports.in_degree(mid) == 0:
852
+ out.append(mid)
853
+ return sorted(out)
854
+
855
+ def orphan_modules_by_root(self) -> dict[str, list[str]]:
856
+ """Orphan modules grouped by provenance root (F8)."""
857
+ grouped: dict[str, list[str]] = {}
858
+ for mid in self.orphan_modules():
859
+ grouped.setdefault(self.root_of(mid), []).append(mid)
860
+ return {r: sorted(v) for r, v in sorted(grouped.items())}
861
+
862
+ @property
863
+ def import_graph(self) -> nx.DiGraph:
864
+ return self._imports
865
+
866
+ # -- architecture: whole-system shape (M16 / A9) -------------------------
867
+
868
+ def _layer_of(self, module_id: str) -> str:
869
+ """Layer = the component just under the package root (``bquant.<layer>``)."""
870
+ pkg = self.graph.target
871
+ parts = module_id.split(".")
872
+ if parts[0] == pkg and len(parts) >= 2:
873
+ return parts[1]
874
+ return "(root)"
875
+
876
+ def layers(self) -> dict:
877
+ """Layer dependency structure of the **core** package (M16/F18).
878
+
879
+ Groups modules into layers (the component under the package root), sums
880
+ inter-layer import edges, and flags **violations** order-free: a layer pair
881
+ with edges in *both* directions (mutual dependency) is a coupling smell
882
+ regardless of intended layering — no hardcoded ``core < analysis`` order.
883
+ """
884
+ ig = self._imports
885
+ members: dict[str, list[str]] = {}
886
+ for m in ig.nodes:
887
+ if self.root_of(m) == "core":
888
+ members.setdefault(self._layer_of(m), []).append(m)
889
+ edges: dict[tuple[str, str], int] = {}
890
+ for u, v in ig.edges():
891
+ if self.root_of(u) != "core" or self.root_of(v) != "core":
892
+ continue
893
+ lu, lv = self._layer_of(u), self._layer_of(v)
894
+ if lu != lv:
895
+ edges[(lu, lv)] = edges.get((lu, lv), 0) + 1
896
+ violations = sorted(
897
+ {tuple(sorted((a, b))) for (a, b) in edges if (b, a) in edges}
898
+ )
899
+ return {
900
+ "layers": {k: sorted(v) for k, v in sorted(members.items())},
901
+ "edges": {f"{a} -> {b}": n for (a, b), n in
902
+ sorted(edges.items(), key=lambda x: (-x[1], x[0]))},
903
+ "violations": [list(v) for v in violations],
904
+ }
905
+
906
+ def coupling(self, *, root: str = "core", limit: int = 20) -> list[dict]:
907
+ """Per-module afferent/efferent coupling + instability (M16/F19).
908
+
909
+ Ca = modules that import me, Ce = modules I import, Instability
910
+ I = Ce/(Ca+Ce) (0 = maximally stable, 1 = maximally unstable). Sorted most-
911
+ depended-on first. Restricted to ``root`` provenance (default core).
912
+ """
913
+ ig = self._imports
914
+ out = []
915
+ for m in ig.nodes:
916
+ if self.root_of(m) != root:
917
+ continue
918
+ ca, ce = ig.in_degree(m), ig.out_degree(m)
919
+ tot = ca + ce
920
+ out.append({"module": m, "ca": ca, "ce": ce,
921
+ "instability": round(ce / tot, 2) if tot else 0.0})
922
+ out.sort(key=lambda r: (-r["ca"], r["module"]))
923
+ return out[:limit]
924
+
925
+ def hotspots(self, *, root: str = "core", min_methods: int = 12,
926
+ min_cc: int = 8, limit: int = 15) -> dict:
927
+ """God-object classes + call-graph hubs + complex functions (M16/F20, R1-C4).
928
+
929
+ ``god_classes``: classes with ``>= min_methods`` methods (concentration of
930
+ behavior), each annotated with aggregate method complexity (``total_cc`` /
931
+ ``max_cc``) — the second axis, so "big by connectivity" and "complex by
932
+ McCabe" are both visible. ``complex_functions``: functions with the highest
933
+ cyclomatic complexity (``>= min_cc``), the sharpest per-symbol risk signal.
934
+ ``call_hubs``: symbols with the highest call-graph degree (in+out) — pervasive
935
+ utilities (a logger, sample loaders) hub by nature, so each is flagged
936
+ ``pervasive`` and the reader discounts expected noise, not real risk.
937
+ """
938
+ method_counts: dict[str, int] = {}
939
+ class_cc: dict[str, list[int]] = {}
940
+ for e in self.graph.edges:
941
+ if e.type != "contains":
942
+ continue
943
+ src = self.graph.nodes.get(e.source)
944
+ tgt = self.graph.nodes.get(e.target)
945
+ if src and src.kind == "class" and tgt and tgt.kind == "function" \
946
+ and self.root_of(e.source) == root:
947
+ method_counts[e.source] = method_counts.get(e.source, 0) + 1
948
+ cc = (tgt.extras.get("complexity") or {}).get("cc")
949
+ if cc is not None:
950
+ class_cc.setdefault(e.source, []).append(cc)
951
+ god = sorted(((c, n) for c, n in method_counts.items() if n >= min_methods),
952
+ key=lambda x: (-x[1], x[0]))[:limit]
953
+
954
+ # Second axis: individual functions ranked by cyclomatic complexity.
955
+ complex_fns = []
956
+ for nid, node in self.graph.nodes.items():
957
+ if node.kind != "function" or self.root_of(nid) != root:
958
+ continue
959
+ metrics = node.extras.get("complexity")
960
+ if metrics and metrics.get("cc", 0) >= min_cc:
961
+ complex_fns.append({"id": nid, "cc": metrics["cc"], "mi": metrics["mi"],
962
+ "sloc": metrics["sloc"]})
963
+ complex_fns.sort(key=lambda r: (-r["cc"], r["id"]))
964
+
965
+ cg = self._calls
966
+ hubs = []
967
+ for nid in cg.nodes:
968
+ if self.root_of(nid) != root:
969
+ continue
970
+ deg = cg.in_degree(nid) + cg.out_degree(nid)
971
+ short = nid.rsplit(".", 1)[-1]
972
+ recv = nid.rsplit(".", 2)[-2] if nid.count(".") >= 2 else ""
973
+ pervasive = any(t in (recv + "." + short).lower()
974
+ for t in ("logger", "log", "get_sample", "warning",
975
+ "debug", "info", "error"))
976
+ hubs.append({"id": nid, "degree": deg, "pervasive": pervasive})
977
+ hubs.sort(key=lambda r: (-r["degree"], r["id"]))
978
+
979
+ def _class_entry(c: str, n: int) -> dict:
980
+ ccs = class_cc.get(c, [])
981
+ return {"class": c, "methods": n,
982
+ "total_cc": sum(ccs), "max_cc": max(ccs) if ccs else 0}
983
+
984
+ return {
985
+ "god_classes": [_class_entry(c, n) for c, n in god],
986
+ "complex_functions": complex_fns[:limit],
987
+ "call_hubs": hubs[:limit],
988
+ }
989
+
990
+ # -- test mapping (R1-C24, axis A10) -------------------------------------
991
+
992
+ def _module_of(self, node_id: str) -> "Node | None":
993
+ """The module node containing ``node_id`` — longest module id that prefixes it."""
994
+ best = None
995
+ for mid in self._module_ids:
996
+ if node_id == mid or node_id.startswith(mid + "."):
997
+ if best is None or len(mid) > len(best):
998
+ best = mid
999
+ return self.graph.nodes.get(best) if best else None
1000
+
1001
+ def _is_test(self, node_id: str) -> bool:
1002
+ """Would pytest collect this node as a test? (design D1 — derived, not stored)
1003
+
1004
+ Three conditions, all syntax over data already in the graph: it lives under a
1005
+ consumer root whose role is ``tests``; its module file is a file pytest collects
1006
+ (``test_*.py`` / ``*_test.py`` — which is what keeps helper packages under
1007
+ ``tests/fixtures/`` out); and the function itself follows the naming rule,
1008
+ including the ``Test*`` class case. Derived rather than stamped at build time so
1009
+ the artifact does not carry one framework's naming convention, and so an existing
1010
+ graph gains the feature with no rebuild.
1011
+ """
1012
+ node = self.graph.nodes.get(node_id)
1013
+ if node is None or node.kind != "function" or self.root_of(node_id) != "tests":
1014
+ return False
1015
+ mod = self._module_of(node_id)
1016
+ if mod is None or not mod.file:
1017
+ return False
1018
+ base = mod.file.rsplit("/", 1)[-1]
1019
+ if not (base.startswith("test_") or base.endswith("_test.py")):
1020
+ return False
1021
+ tail = node_id[len(mod.id) + 1:].split(".")
1022
+ if not tail or not tail[-1].startswith("test"):
1023
+ return False
1024
+ if len(tail) == 1:
1025
+ return True
1026
+ return len(tail) == 2 and tail[0].startswith("Test")
1027
+
1028
+ def pytest_nodeid(self, node_id: str) -> str | None:
1029
+ """Graph id → the id you can paste after ``pytest`` (design D5).
1030
+
1031
+ ``tests.test_x.TestFoo.test_y`` → ``tests/test_x.py::TestFoo::test_y``. Without
1032
+ this the answer is a reading exercise instead of a command.
1033
+ """
1034
+ mod = self._module_of(node_id)
1035
+ if mod is None or not mod.file:
1036
+ return None
1037
+ tail = node_id[len(mod.id) + 1:]
1038
+ return mod.file + ("::" + "::".join(tail.split(".")) if tail else "")
1039
+
1040
+ def _test_caveats(self, *, truncated: int, searched: int, found: bool) -> list[str]:
1041
+ """The two labels every answer carries, plus whatever this graph earns.
1042
+
1043
+ Both are required (R1-C13): an over-set because reaching a symbol is not
1044
+ asserting on it, and a lower bound because dynamic dispatch is invisible.
1045
+ """
1046
+ out = [
1047
+ "over-set: a test that reaches this symbol does not necessarily assert on it",
1048
+ "lower bound: dynamic dispatch, fixtures resolved by name and monkeypatched "
1049
+ "calls are invisible to a static graph",
1050
+ ]
1051
+ tier = (self.graph.provenance or {}).get("tier")
1052
+ if tier == "fast":
1053
+ out.append("built on the fast tier — method calls are largely unresolved "
1054
+ "(21% vs 56% measured); rebuild with `--deep` for a usable set")
1055
+ elif tier is None:
1056
+ out.append("graph records no tier (built before schema 0.12) — if it is the "
1057
+ "fast tier, method calls are largely unresolved")
1058
+ if not self._test_ids:
1059
+ out.append("no test functions in this graph — build repo-scoped with "
1060
+ "`--consumer tests --mode full` (thin mode yields files, not tests)")
1061
+ if truncated:
1062
+ out.append(f"{truncated} further test(s) at this distance not listed (cap)")
1063
+ if not found:
1064
+ out.append(f"unknown, not none: no test reaches this symbol within {searched} "
1065
+ "hop(s). 16% of symbols that coverage.py proves are exercised look "
1066
+ "like this — ask for a deeper walk (`depth=6`) for low-confidence "
1067
+ "candidates, but do not read silence as 'untested'")
1068
+ else:
1069
+ out.append(f"searched {searched} hop(s) back")
1070
+ return out
1071
+
1072
+ def tests_for(self, symbol_id: str, *, depth: int = _TEST_MAX_DEPTH,
1073
+ cap: int = _TEST_CAP) -> dict:
1074
+ """Which tests exercise ``symbol_id`` — nearest band first (R1-C24, axis A10).
1075
+
1076
+ Distance 1 is not the question: measured on codemap's own repo, only **18%** of
1077
+ core symbols have a direct inbound edge from a test (68/380), because a test calls
1078
+ ``extract()`` and ``extract()`` calls two hundred things. Bounded backwards
1079
+ reachability answers 59% on the fast tier and 80% on deep.
1080
+
1081
+ Nor is "everything reachable" the answer: that returns a median of 21 tests and a
1082
+ maximum of 126 out of 416 — the suite. So the walk returns the **nearest non-empty
1083
+ band** (median 6.5), and deeper bands only on request. Ranking is the feature here,
1084
+ not a polish item.
1085
+
1086
+ Ranking is by graph distance and nothing else: distance is a fact about the graph,
1087
+ while name similarity or file adjacency would be a guess about intent, which is
1088
+ outside "source-only, deterministic".
1089
+ """
1090
+ targets = self._member_ids(symbol_id)
1091
+ seen = set(targets)
1092
+ frontier = set(targets)
1093
+ for dist in range(1, max(1, depth) + 1):
1094
+ nxt: set[str] = set()
1095
+ for node in sorted(frontier):
1096
+ for src, etype in self._inbound.get(node, []):
1097
+ if src not in seen and etype in _TEST_WALK_EDGES:
1098
+ seen.add(src)
1099
+ nxt.add(src)
1100
+ if not nxt:
1101
+ frontier = nxt
1102
+ break
1103
+ frontier = nxt
1104
+ hits = sorted(n for n in nxt if n in self._test_ids)
1105
+ if hits:
1106
+ return self._tests_envelope(symbol_id, hits, dist, cap, dist)
1107
+ return self._tests_envelope(symbol_id, [], None, cap, depth)
1108
+
1109
+ def _tests_envelope(self, symbol_id, hits, dist, cap, searched) -> dict:
1110
+ shown, truncated = hits[:cap], max(0, len(hits) - cap)
1111
+ return {
1112
+ "symbol": symbol_id,
1113
+ "tier": (self.graph.provenance or {}).get("tier"),
1114
+ "distance": dist,
1115
+ # `unknown` — never "none". A confident empty is the failure this project has
1116
+ # now shipped five fixes for (#1 risk:"none", #3, #5, #7, R1-C23).
1117
+ "confidence": _TEST_CONFIDENCE.get(dist, "low") if dist else "unknown",
1118
+ "tests": [self._test_row(t, dist) for t in shown],
1119
+ "total_at_distance": len(hits),
1120
+ "truncated": truncated,
1121
+ "caveats": self._test_caveats(truncated=truncated, searched=searched,
1122
+ found=bool(dist)),
1123
+ }
1124
+
1125
+ def _test_row(self, node_id: str, dist) -> dict:
1126
+ n = self.graph.nodes.get(node_id)
1127
+ return {"id": node_id, "node_id": self.pytest_nodeid(node_id),
1128
+ "line": getattr(n, "lineno", None), "distance": dist}
1129
+
1130
+ def covers(self, test_id: str, *, depth: int = _TEST_MAX_DEPTH,
1131
+ cap: int = _TEST_CAP) -> dict:
1132
+ """The inverse: which core symbols a test reaches (R1-C24 / design D5).
1133
+
1134
+ Same index read the other way, so it costs nothing extra — and it answers "is
1135
+ this test exercising the thing its name claims?" during review.
1136
+ """
1137
+ out: dict[str, int] = {}
1138
+ seen = {test_id}
1139
+ frontier = {test_id}
1140
+ for dist in range(1, max(1, depth) + 1):
1141
+ nxt: set[str] = set()
1142
+ for node in sorted(frontier):
1143
+ for e in self._outbound.get(node, []):
1144
+ tgt, etype = e
1145
+ if tgt in seen or etype not in _TEST_WALK_EDGES:
1146
+ continue
1147
+ seen.add(tgt)
1148
+ nxt.add(tgt)
1149
+ if self.root_of(tgt) == "core":
1150
+ out.setdefault(tgt, dist)
1151
+ if not nxt:
1152
+ break
1153
+ frontier = nxt
1154
+ rows = sorted(out.items(), key=lambda kv: (kv[1], kv[0]))
1155
+ return {
1156
+ "test": test_id,
1157
+ "node_id": self.pytest_nodeid(test_id),
1158
+ "tier": (self.graph.provenance or {}).get("tier"),
1159
+ "symbols": [{"id": s, "distance": d} for s, d in rows[:cap]],
1160
+ "total": len(rows),
1161
+ "truncated": max(0, len(rows) - cap),
1162
+ "caveats": self._test_caveats(truncated=max(0, len(rows) - cap),
1163
+ searched=depth, found=bool(rows)),
1164
+ }