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/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
|
+
}
|