codmap 0.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codemap/__init__.py +10 -0
- codemap/apidiff.py +208 -0
- codemap/arch.py +190 -0
- codemap/cli.py +718 -0
- codemap/diagnostics.py +256 -0
- codemap/extract/__init__.py +10 -0
- codemap/extract/attrflow.py +230 -0
- codemap/extract/behavior.py +771 -0
- codemap/extract/dataflow.py +97 -0
- codemap/extract/dispatch.py +248 -0
- codemap/extract/griffe_extractor.py +496 -0
- codemap/extract/gsource.py +83 -0
- codemap/extract/roots.py +427 -0
- codemap/freshness.py +94 -0
- codemap/incremental.py +195 -0
- codemap/integrations/__init__.py +51 -0
- codemap/integrations/base.py +196 -0
- codemap/integrations/cocoindex.py +78 -0
- codemap/integrations/gate.py +58 -0
- codemap/integrations/gitnexus.py +93 -0
- codemap/integrations/registry.py +69 -0
- codemap/integrations/transport.py +46 -0
- codemap/model.py +178 -0
- codemap/provenance.py +248 -0
- codemap/query.py +1164 -0
- codemap/scope.py +212 -0
- codemap/serve/__init__.py +26 -0
- codemap/serve/_scip_pb2.py +100 -0
- codemap/serve/api_surface.py +60 -0
- codemap/serve/apidiff.py +83 -0
- codemap/serve/architecture.py +101 -0
- codemap/serve/audit.py +176 -0
- codemap/serve/check.py +80 -0
- codemap/serve/ctags.py +203 -0
- codemap/serve/impact.py +84 -0
- codemap/serve/livingdocs.py +174 -0
- codemap/serve/mcp_server.py +278 -0
- codemap/serve/mermaid.py +120 -0
- codemap/serve/pack.py +93 -0
- codemap/serve/rag.py +142 -0
- codemap/serve/review.py +197 -0
- codemap/serve/scip.py +183 -0
- codemap/serve/semantic.py +71 -0
- codemap/serve/server.py +43 -0
- codemap/serve/session.py +482 -0
- codemap/serve/subsystems.py +85 -0
- codemap/serve/vault.py +156 -0
- codemap/store.py +28 -0
- codemap/tomlio.py +59 -0
- codmap-0.0.3.dist-info/METADATA +245 -0
- codmap-0.0.3.dist-info/RECORD +55 -0
- codmap-0.0.3.dist-info/WHEEL +5 -0
- codmap-0.0.3.dist-info/entry_points.txt +2 -0
- codmap-0.0.3.dist-info/licenses/LICENSE +21 -0
- codmap-0.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
"""Behavioral pass — best-effort call-graph + control skeleton (DESIGN §7, M4/M5).
|
|
2
|
+
|
|
3
|
+
griffe gives the API surface but not the call sites; this second pass parses the
|
|
4
|
+
same source files with the stdlib ``ast`` and adds a *bounded* behavioral layer:
|
|
5
|
+
|
|
6
|
+
- `calls` edges (caller function → internal callee), each labeled by how it was
|
|
7
|
+
resolved (``extras.resolution``);
|
|
8
|
+
- per-function ``extras.calls`` coverage counts (out / resolved / external /
|
|
9
|
+
unresolved / dynamic) so the graph reports its own honesty;
|
|
10
|
+
- per-function ``extras.control`` skeleton (branches / loops / try / generator /
|
|
11
|
+
async) — structure, not semantics.
|
|
12
|
+
|
|
13
|
+
**Two tiers** (see gaps/ call_resolution_spike_2026-07-26 for the measurement):
|
|
14
|
+
|
|
15
|
+
- **fast** (default): stdlib ``ast`` name resolution — module / self / imported.
|
|
16
|
+
Sub-second, zero heavy deps, deterministic. Leaves local-variable calls
|
|
17
|
+
(``result.data``, ``fig.write_html``) ``unresolved`` on purpose — resolving them
|
|
18
|
+
needs type inference. ~19% of call-sites on bquant.
|
|
19
|
+
- **deep** (``deep=True``, jedi): local-variable type inference cracks the tail
|
|
20
|
+
``self.*`` → ~99%, ``x.foo()`` on locals → new edges. ~28% on bquant; ~1 min
|
|
21
|
+
build. Python's dynamism caps even this — the remainder is genuinely undecidable.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
import math
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from codemap.extract.gsource import module_file, module_imports
|
|
31
|
+
from codemap.model import Edge
|
|
32
|
+
|
|
33
|
+
_BUILTINS = set(vars(__import__("builtins")))
|
|
34
|
+
_SKIP_RECEIVERS = {"self", "cls", "super"}
|
|
35
|
+
|
|
36
|
+
# Halstead operators are the AST operator-symbol node families (radon's scheme):
|
|
37
|
+
# arithmetic/bitwise ``ast.operator``, unary ``ast.unaryop``, boolean ``ast.boolop``,
|
|
38
|
+
# comparison ``ast.cmpop``. Operands are names and literal constants.
|
|
39
|
+
_OPERATOR_NODES = (ast.operator, ast.unaryop, ast.boolop, ast.cmpop)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
|
|
43
|
+
search_path=None, only=None) -> None:
|
|
44
|
+
"""Augment ``graph`` (built by the griffe pass) with the behavioral layer.
|
|
45
|
+
|
|
46
|
+
``deep=True`` swaps the ast name-resolver for jedi type inference on calls
|
|
47
|
+
(``search_path`` is the dir containing the package, used as the jedi project).
|
|
48
|
+
``only`` (a set of module paths) restricts the pass to those modules — the
|
|
49
|
+
incremental hook (R1-C9): the caller reuses the old graph's edges/extras for the
|
|
50
|
+
modules left out. When ``None`` (default) every module is processed.
|
|
51
|
+
"""
|
|
52
|
+
modules = _index_modules(griffe_root)
|
|
53
|
+
known_modules = set(modules) # R1-C21: flat-layout sibling lookup
|
|
54
|
+
project = _jedi_project(search_path) if deep else None
|
|
55
|
+
for modpath in sorted(modules):
|
|
56
|
+
if only is not None and modpath not in only:
|
|
57
|
+
continue
|
|
58
|
+
mod = modules[modpath]
|
|
59
|
+
fp = module_file(mod) # None for a namespace dir (R1-C21)
|
|
60
|
+
if fp is None:
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
source = fp.read_text(encoding="utf-8")
|
|
64
|
+
tree = ast.parse(source)
|
|
65
|
+
except (OSError, SyntaxError):
|
|
66
|
+
continue
|
|
67
|
+
imports = module_imports(mod, modpath, known_modules) # R1-C21: flat-aware
|
|
68
|
+
modmembers = set(mod.members.keys())
|
|
69
|
+
script = _jedi_script(source, fp, project) if deep else None
|
|
70
|
+
nested: dict[tuple[str, str], dict] = {}
|
|
71
|
+
for fnode, class_stack, scope in _named_functions_scoped(tree):
|
|
72
|
+
node_id = _node_id(modpath, class_stack, fnode.name)
|
|
73
|
+
if node_id not in graph.nodes:
|
|
74
|
+
# R1-C22 D3: a closure / dynamically-built class body is not a definition
|
|
75
|
+
# node, but the calls inside it are real. Attribute them to the innermost
|
|
76
|
+
# definition that *does* exist instead of discarding them.
|
|
77
|
+
owner = _nearest_owner(graph, modpath, scope)
|
|
78
|
+
if owner is not None:
|
|
79
|
+
_collect_nested_calls(graph, owner, fnode, modpath, imports,
|
|
80
|
+
modmembers, script, target_pkg, nested,
|
|
81
|
+
known_modules)
|
|
82
|
+
continue
|
|
83
|
+
members = _class_members(mod, class_stack, modules) if class_stack else {}
|
|
84
|
+
class_prefix = ".".join([modpath, *class_stack]) if class_stack else ""
|
|
85
|
+
def fast(call, _cp=class_prefix, _im=imports, _mm=modmembers, _me=members):
|
|
86
|
+
return _resolve(call, modpath, _cp, _im, _mm, _me)
|
|
87
|
+
resolve = (_deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules)
|
|
88
|
+
if script else fast)
|
|
89
|
+
_process_function(graph, node_id, fnode, resolve)
|
|
90
|
+
# R1-C22 D1: functions/classes named as *values* here (dispatch tables,
|
|
91
|
+
# `default=` callbacks) — a use the call layer cannot see.
|
|
92
|
+
_emit_name_references(graph, node_id, fnode, modpath, imports, modmembers,
|
|
93
|
+
decorators_of=fnode)
|
|
94
|
+
# R1-C22 D2: module-level statements are a scope of their own, never walked
|
|
95
|
+
# above — import-time calls and dispatch tables live here.
|
|
96
|
+
def mod_fast(call, _im=imports, _mm=modmembers):
|
|
97
|
+
return _resolve(call, modpath, "", _im, _mm, {})
|
|
98
|
+
mod_resolve = (_deep_then_fast(graph, script, target_pkg, mod_fast, modpath, known_modules)
|
|
99
|
+
if script else mod_fast)
|
|
100
|
+
_process_module_level(graph, modpath, tree, mod_resolve, imports, modmembers)
|
|
101
|
+
_emit_nested_calls(graph, nested)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# -- jedi (deep tier) --------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def _jedi_project(search_path):
|
|
107
|
+
import jedi
|
|
108
|
+
return jedi.Project(str(search_path)) if search_path else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _jedi_script(source, path, project):
|
|
112
|
+
import jedi
|
|
113
|
+
return jedi.Script(code=source, path=str(path), project=project)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _callee_pos(func):
|
|
117
|
+
"""(line, column) of the callee name for jedi.goto (jedi cols are 0-based+1)."""
|
|
118
|
+
if isinstance(func, ast.Name):
|
|
119
|
+
return func.lineno, func.col_offset + 1
|
|
120
|
+
if isinstance(func, ast.Attribute):
|
|
121
|
+
return func.end_lineno, func.end_col_offset # last char of the attr name
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _flat_qualify(full_name: str, modpath: str, known_modules) -> str | None:
|
|
126
|
+
"""A jedi answer that names a **sibling module** by bare name → its package id.
|
|
127
|
+
|
|
128
|
+
The mirror of :func:`codemap.extract.gsource.module_imports` at the jedi boundary
|
|
129
|
+
(R1-C26, issue #10). In a flat layout the directory itself is on ``sys.path``, so
|
|
130
|
+
``from leaf import helper`` makes jedi report ``leaf.helper`` — a perfectly correct
|
|
131
|
+
answer that every ``startswith(pkg + ".")`` test reads as *external*. The deep tier
|
|
132
|
+
was never taught the flat-layout inference R1-C21 gave the structural and fast layers,
|
|
133
|
+
so on such a target it classified every cross-module call as external and dropped it.
|
|
134
|
+
|
|
135
|
+
Same guard as the import-edge rule: only a name that is not already package-internal,
|
|
136
|
+
and only when its head names a module sitting **beside** the caller.
|
|
137
|
+
"""
|
|
138
|
+
if "." not in modpath:
|
|
139
|
+
return None
|
|
140
|
+
parent = modpath.rsplit(".", 1)[0]
|
|
141
|
+
head = full_name.split(".", 1)[0]
|
|
142
|
+
candidate = f"{parent}.{head}"
|
|
143
|
+
return f"{parent}.{full_name}" if candidate in known_modules else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _resolve_jedi(call, script, target_pkg, *, modpath="", known_modules=frozenset()):
|
|
147
|
+
"""Resolve a call-site to a definition via jedi type inference.
|
|
148
|
+
|
|
149
|
+
Returns (target_id, resolution). ``deep`` = internal hit; ``external`` =
|
|
150
|
+
resolved outside the package; ``unresolved`` = jedi found nothing.
|
|
151
|
+
"""
|
|
152
|
+
pos = _callee_pos(call.func)
|
|
153
|
+
if pos is None:
|
|
154
|
+
return "", "unresolved"
|
|
155
|
+
try:
|
|
156
|
+
defs = script.goto(pos[0], pos[1], follow_imports=True, follow_builtin_imports=False)
|
|
157
|
+
except Exception:
|
|
158
|
+
return "", "unresolved"
|
|
159
|
+
if not defs:
|
|
160
|
+
return "", "unresolved"
|
|
161
|
+
names = sorted(d.full_name for d in defs if d.full_name)
|
|
162
|
+
internal = [n for n in names
|
|
163
|
+
if n == target_pkg or n.startswith(target_pkg + ".")]
|
|
164
|
+
if internal:
|
|
165
|
+
return internal[0], "deep"
|
|
166
|
+
# R1-C26: a flat-layout sibling is internal, it just does not look it.
|
|
167
|
+
for n in names:
|
|
168
|
+
qualified = _flat_qualify(n, modpath, known_modules)
|
|
169
|
+
if qualified:
|
|
170
|
+
return qualified, "deep"
|
|
171
|
+
return ("", "external") if names else ("", "unresolved")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# -- griffe context ----------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def _index_modules(root) -> dict:
|
|
177
|
+
out: dict = {}
|
|
178
|
+
|
|
179
|
+
def walk(o):
|
|
180
|
+
if o.kind.value == "module":
|
|
181
|
+
out[o.canonical_path] = o
|
|
182
|
+
for m in o.members.values():
|
|
183
|
+
if not m.is_alias and m.kind.value in ("module", "class"):
|
|
184
|
+
walk(m)
|
|
185
|
+
|
|
186
|
+
walk(root)
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _class_members(mod, class_stack, modules) -> dict:
|
|
191
|
+
"""Map ``member_name -> owning-class canonical id`` for the enclosing class.
|
|
192
|
+
|
|
193
|
+
Includes members inherited from internal base classes, each keyed to the base
|
|
194
|
+
class that actually defines it — so ``self.<inherited>()`` resolves to the base
|
|
195
|
+
method's real id, not a phantom ``ThisClass.<inherited>`` that is not a node
|
|
196
|
+
(fast-tier soundness, R1-C13-f1). Own members win over inherited (override).
|
|
197
|
+
"""
|
|
198
|
+
obj = mod
|
|
199
|
+
for cname in class_stack:
|
|
200
|
+
obj = obj.members.get(cname)
|
|
201
|
+
if obj is None:
|
|
202
|
+
return {}
|
|
203
|
+
owners = {name: obj.canonical_path for name in obj.members}
|
|
204
|
+
for base in getattr(obj, "bases", None) or []:
|
|
205
|
+
bpath = getattr(base, "canonical_path", None) or ""
|
|
206
|
+
if bpath.startswith(f"{mod.canonical_path.split('.')[0]}."):
|
|
207
|
+
*modparts, cname = bpath.split(".")
|
|
208
|
+
bmod = modules.get(".".join(modparts))
|
|
209
|
+
if bmod and cname in bmod.members:
|
|
210
|
+
bclass = bmod.members[cname]
|
|
211
|
+
for name in bclass.members:
|
|
212
|
+
owners.setdefault(name, bclass.canonical_path) # own already set → keep
|
|
213
|
+
return owners
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# -- ast scope walking -------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def _named_functions(tree):
|
|
219
|
+
"""Yield (FunctionDef, [class names]) for functions reachable as definitions.
|
|
220
|
+
|
|
221
|
+
Tracks the class nesting so we can rebuild canonical ids. Functions nested
|
|
222
|
+
inside other functions are still yielded but filtered out by the caller
|
|
223
|
+
(their id won't match a graph node).
|
|
224
|
+
"""
|
|
225
|
+
results = []
|
|
226
|
+
|
|
227
|
+
def visit(node, class_stack):
|
|
228
|
+
for child in ast.iter_child_nodes(node):
|
|
229
|
+
if isinstance(child, ast.ClassDef):
|
|
230
|
+
visit(child, class_stack + [child.name])
|
|
231
|
+
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
232
|
+
results.append((child, list(class_stack)))
|
|
233
|
+
visit(child, class_stack) # descend for nested classes/defs
|
|
234
|
+
else:
|
|
235
|
+
visit(child, class_stack)
|
|
236
|
+
|
|
237
|
+
visit(tree, [])
|
|
238
|
+
return results
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _node_id(modpath: str, class_stack: list[str], funcname: str) -> str:
|
|
242
|
+
return ".".join([modpath, *class_stack, funcname])
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _own_calls(fnode):
|
|
246
|
+
"""Call nodes in a function's own body — not inside nested defs/classes."""
|
|
247
|
+
return [n for n in _own_nodes(fnode) if isinstance(n, ast.Call)]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _own_nodes(fnode):
|
|
251
|
+
"""Every AST node in a function's own body — not descending into nested defs/classes.
|
|
252
|
+
|
|
253
|
+
Same scope boundary as ``_own_calls``: a nested/closure ``def`` (or a class body)
|
|
254
|
+
is its own definition node with its own metrics, so we stop at it to avoid
|
|
255
|
+
double-counting its complexity in the enclosing function.
|
|
256
|
+
"""
|
|
257
|
+
def visit(node):
|
|
258
|
+
for child in ast.iter_child_nodes(node):
|
|
259
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
260
|
+
continue # belongs to the nested scope
|
|
261
|
+
yield child
|
|
262
|
+
yield from visit(child)
|
|
263
|
+
|
|
264
|
+
yield from visit(fnode)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# -- per-function resolution + control ---------------------------------------
|
|
268
|
+
|
|
269
|
+
_EDGE_RESOLUTIONS = {"module", "self", "imported", "deep"}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules):
|
|
273
|
+
"""Deep tier = jedi **union** the name resolver, not jedi instead of it (R1-C26).
|
|
274
|
+
|
|
275
|
+
The two tiers used to be exclusive: with ``deep=True`` every call went to jedi and the
|
|
276
|
+
name-based resolver was never consulted, so anything jedi could not see was lost even
|
|
277
|
+
when the cheap tier had it. That made ``--deep`` a *downgrade* on a flat layout — jedi
|
|
278
|
+
cannot follow ``from leaf import helper`` when ``leaf`` is a sibling on ``sys.path``, so
|
|
279
|
+
the reporter's target went from 158 cross-module call edges on fast to **0** on deep
|
|
280
|
+
(issue #10). It also cost a handful of true edges on ordinary packaged targets.
|
|
281
|
+
|
|
282
|
+
Fallback only on ``unresolved`` — jedi finding *nothing*. When jedi answers
|
|
283
|
+
``external`` it resolved the name to a definition outside the package, and that answer
|
|
284
|
+
is better than a name-based guess that might match an internal symbol by coincidence.
|
|
285
|
+
"""
|
|
286
|
+
def resolve(call):
|
|
287
|
+
target, resolution = _resolve_jedi(call, script, target_pkg,
|
|
288
|
+
modpath=modpath, known_modules=known_modules)
|
|
289
|
+
# Fall back when jedi produced nothing emittable: no answer at all, or an internal
|
|
290
|
+
# name that is not a graph node (a local it typed to its own scope-path, or a
|
|
291
|
+
# `self.x` it bound to the subclass while the method lives on the base). Both used
|
|
292
|
+
# to be discarded by the soundness downgrade in `_process_function`, *after* the
|
|
293
|
+
# cheaper resolver was already out of reach.
|
|
294
|
+
if resolution == "unresolved" or (resolution == "deep"
|
|
295
|
+
and target not in graph.nodes):
|
|
296
|
+
return fast(call)
|
|
297
|
+
return target, resolution
|
|
298
|
+
return resolve
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _process_function(graph, node_id, fnode, resolve) -> None:
|
|
302
|
+
counts = {"out": 0, "resolved": 0, "external": 0, "unresolved": 0, "dynamic": 0}
|
|
303
|
+
# F7: edges are deduped caller->callee, so aggregate the per-call-site argument
|
|
304
|
+
# contract before emitting — a refactorer needs "how is it called", and the
|
|
305
|
+
# collapse itself (2 sites -> 1 edge) must stay visible via `callsites`.
|
|
306
|
+
by_target: dict[str, dict] = {}
|
|
307
|
+
for call in _own_calls(fnode):
|
|
308
|
+
counts["out"] += 1
|
|
309
|
+
target, resolution = resolve(call)
|
|
310
|
+
# Soundness (R1-C13-f1/f2): an internal call edge must point at a real
|
|
311
|
+
# graph node. A resolver can name a non-node — a local variable jedi typed
|
|
312
|
+
# to its own scope-path, or a nested/closure function that is not a
|
|
313
|
+
# definition node — so downgrade any such target to unresolved rather than
|
|
314
|
+
# emit an edge to nothing (which would poison callers/callees/impact).
|
|
315
|
+
if resolution in _EDGE_RESOLUTIONS and target and target in graph.nodes:
|
|
316
|
+
counts["resolved"] += 1
|
|
317
|
+
agg = by_target.setdefault(target, {"resolution": resolution, "shapes": []})
|
|
318
|
+
agg["shapes"].append(_arg_shape(call))
|
|
319
|
+
elif resolution == "external":
|
|
320
|
+
counts["external"] += 1
|
|
321
|
+
elif resolution == "dynamic":
|
|
322
|
+
counts["dynamic"] += 1
|
|
323
|
+
else:
|
|
324
|
+
counts["unresolved"] += 1
|
|
325
|
+
|
|
326
|
+
for target in sorted(by_target):
|
|
327
|
+
agg = by_target[target]
|
|
328
|
+
extras = {"resolution": agg["resolution"], **_arg_contract(agg["shapes"])}
|
|
329
|
+
graph.add_edge(Edge("calls", node_id, target, extras=extras))
|
|
330
|
+
|
|
331
|
+
node = graph.nodes[node_id]
|
|
332
|
+
node.extras["calls"] = counts
|
|
333
|
+
node.extras["control"] = _control(fnode)
|
|
334
|
+
node.extras["complexity"] = _complexity(fnode)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _arg_shape(call) -> tuple:
|
|
338
|
+
"""(positional_count | None, sorted kwnames, splat?) for one call-site.
|
|
339
|
+
|
|
340
|
+
``None`` positional count / ``splat=True`` mean ``*args``/``**kwargs`` made the
|
|
341
|
+
arity partly unknown — an honest signal for change-set reasoning.
|
|
342
|
+
"""
|
|
343
|
+
splat = any(isinstance(a, ast.Starred) for a in call.args) \
|
|
344
|
+
or any(kw.arg is None for kw in call.keywords)
|
|
345
|
+
posargs = None if any(isinstance(a, ast.Starred) for a in call.args) else \
|
|
346
|
+
sum(1 for a in call.args if not isinstance(a, ast.Starred))
|
|
347
|
+
kwnames = sorted(kw.arg for kw in call.keywords if kw.arg is not None)
|
|
348
|
+
return posargs, tuple(kwnames), splat
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _arg_contract(shapes: list[tuple]) -> dict:
|
|
352
|
+
"""Aggregate call-site shapes into an edge argument contract (F7)."""
|
|
353
|
+
posargs = sorted({s[0] for s in shapes if s[0] is not None})
|
|
354
|
+
kwnames = sorted({k for s in shapes for k in s[1]})
|
|
355
|
+
splat = any(s[2] for s in shapes)
|
|
356
|
+
contract: dict = {"callsites": len(shapes)}
|
|
357
|
+
if posargs:
|
|
358
|
+
contract["posargs"] = posargs
|
|
359
|
+
if kwnames:
|
|
360
|
+
contract["kwargs"] = kwnames
|
|
361
|
+
if splat:
|
|
362
|
+
contract["splat"] = True
|
|
363
|
+
return contract
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _resolve(call, modpath, class_prefix, imports, modmembers, members):
|
|
367
|
+
"""Return (target_id, resolution). Only module/self/imported become edges."""
|
|
368
|
+
pkg = modpath.split(".")[0] + "."
|
|
369
|
+
f = call.func
|
|
370
|
+
if isinstance(f, ast.Name):
|
|
371
|
+
name = f.id
|
|
372
|
+
if name in imports:
|
|
373
|
+
tgt = imports[name]
|
|
374
|
+
if tgt.startswith(pkg):
|
|
375
|
+
return tgt, "imported"
|
|
376
|
+
return tgt, "external"
|
|
377
|
+
if name in modmembers:
|
|
378
|
+
return f"{modpath}.{name}", "module"
|
|
379
|
+
if name in _BUILTINS:
|
|
380
|
+
return name, "external"
|
|
381
|
+
return name, "unresolved"
|
|
382
|
+
if isinstance(f, ast.Attribute):
|
|
383
|
+
attr = f.attr
|
|
384
|
+
recv = f.value
|
|
385
|
+
if isinstance(recv, ast.Name):
|
|
386
|
+
if recv.id in _SKIP_RECEIVERS and attr in members and class_prefix:
|
|
387
|
+
# method call on self — resolve to the class that actually defines
|
|
388
|
+
# the member (own or a base), never a phantom same-class id.
|
|
389
|
+
return f"{members[attr]}.{attr}", "self"
|
|
390
|
+
if recv.id in imports:
|
|
391
|
+
tgt = imports[recv.id]
|
|
392
|
+
if tgt.startswith(pkg):
|
|
393
|
+
return f"{tgt}.{attr}", "imported"
|
|
394
|
+
return f"{tgt}.{attr}", "external"
|
|
395
|
+
if recv.id in modmembers:
|
|
396
|
+
return f"{modpath}.{recv.id}.{attr}", "module"
|
|
397
|
+
# X.method('literal', ...) — dynamic string-keyed dispatch (registry/dict)
|
|
398
|
+
if call.args and isinstance(call.args[0], ast.Constant) \
|
|
399
|
+
and isinstance(call.args[0].value, str) and attr in ("create", "get", "register"):
|
|
400
|
+
return "", "dynamic"
|
|
401
|
+
return "", "unresolved"
|
|
402
|
+
return "", "unresolved"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _control(fnode) -> dict:
|
|
406
|
+
"""Coarse control-flow skeleton of a function body (structure, not meaning)."""
|
|
407
|
+
branches = loops = 0
|
|
408
|
+
has_try = has_yield = False
|
|
409
|
+
for node in ast.walk(fnode):
|
|
410
|
+
if isinstance(node, ast.If):
|
|
411
|
+
branches += 1
|
|
412
|
+
elif isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
|
|
413
|
+
loops += 1
|
|
414
|
+
elif isinstance(node, ast.Try):
|
|
415
|
+
has_try = True
|
|
416
|
+
elif isinstance(node, (ast.Yield, ast.YieldFrom)):
|
|
417
|
+
has_yield = True
|
|
418
|
+
out = {"branches": branches, "loops": loops}
|
|
419
|
+
if has_try:
|
|
420
|
+
out["try"] = True
|
|
421
|
+
if has_yield:
|
|
422
|
+
out["generator"] = True
|
|
423
|
+
if isinstance(fnode, ast.AsyncFunctionDef):
|
|
424
|
+
out["async"] = True
|
|
425
|
+
return out
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# -- complexity metrics (R1-C4) ----------------------------------------------
|
|
429
|
+
#
|
|
430
|
+
# Source-only, deterministic, stdlib-only — computed in the same AST pass as the
|
|
431
|
+
# control skeleton, over the function's *own* body (nested defs are separate nodes
|
|
432
|
+
# with their own metrics). All numbers are intrinsic to the code, so they live on
|
|
433
|
+
# the node in extras and need no source at query time. codemap's value here is not
|
|
434
|
+
# the metrics themselves (radon has those) but combining them with the graph's
|
|
435
|
+
# structural signals (coupling, fan-in/out) — see Query.hotspots.
|
|
436
|
+
|
|
437
|
+
def _cyclomatic(fnode) -> int:
|
|
438
|
+
"""McCabe cyclomatic complexity = 1 + number of decision points.
|
|
439
|
+
|
|
440
|
+
Decision points (radon-compatible): ``if``/``elif`` (each ``elif`` is a nested
|
|
441
|
+
``If``), ternary ``IfExp``, ``for``/``while``, each ``except`` handler, each
|
|
442
|
+
boolean operand join (``a and b and c`` → +2), each comprehension clause and its
|
|
443
|
+
filters, and each ``match`` case.
|
|
444
|
+
"""
|
|
445
|
+
cc = 1
|
|
446
|
+
for node in _own_nodes(fnode):
|
|
447
|
+
if isinstance(node, (ast.If, ast.IfExp, ast.For, ast.AsyncFor, ast.While,
|
|
448
|
+
ast.ExceptHandler)):
|
|
449
|
+
cc += 1
|
|
450
|
+
elif isinstance(node, ast.BoolOp):
|
|
451
|
+
cc += len(node.values) - 1
|
|
452
|
+
elif isinstance(node, ast.comprehension):
|
|
453
|
+
cc += 1 + len(node.ifs)
|
|
454
|
+
elif isinstance(node, ast.match_case):
|
|
455
|
+
cc += 1
|
|
456
|
+
return cc
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _halstead_volume(fnode) -> float:
|
|
460
|
+
"""Halstead volume ``N * log2(η)`` over operator-symbol nodes + names/constants.
|
|
461
|
+
|
|
462
|
+
η = distinct operators + distinct operands; N = their total counts. A body with
|
|
463
|
+
no operators/operands (e.g. ``return``-only) has volume 0.
|
|
464
|
+
"""
|
|
465
|
+
op_kinds: set[str] = set()
|
|
466
|
+
operands: set[str] = set()
|
|
467
|
+
n_ops = n_operands = 0
|
|
468
|
+
for node in _own_nodes(fnode):
|
|
469
|
+
if isinstance(node, _OPERATOR_NODES):
|
|
470
|
+
op_kinds.add(type(node).__name__)
|
|
471
|
+
n_ops += 1
|
|
472
|
+
elif isinstance(node, ast.Name):
|
|
473
|
+
operands.add(node.id)
|
|
474
|
+
n_operands += 1
|
|
475
|
+
elif isinstance(node, ast.Constant):
|
|
476
|
+
operands.add(f"{type(node.value).__name__}:{node.value!r}")
|
|
477
|
+
n_operands += 1
|
|
478
|
+
vocab = len(op_kinds) + len(operands)
|
|
479
|
+
length = n_ops + n_operands
|
|
480
|
+
if vocab == 0 or length == 0:
|
|
481
|
+
return 0.0
|
|
482
|
+
return round(length * math.log2(vocab), 2)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _maintainability(cc: int, volume: float, sloc: int) -> float:
|
|
486
|
+
"""Maintainability Index, radon-normalized to 0–100 (higher = more maintainable).
|
|
487
|
+
|
|
488
|
+
``171 − 5.2·ln(V) − 0.23·CC − 16.2·ln(SLOC)`` rescaled to [0, 100]. The comment
|
|
489
|
+
term is omitted (we score per symbol, not per file). ``ln`` inputs are floored at
|
|
490
|
+
1 so a trivial one-liner scores ~100 instead of blowing up.
|
|
491
|
+
"""
|
|
492
|
+
ln_v = math.log(volume) if volume > 1 else 0.0
|
|
493
|
+
ln_sloc = math.log(sloc) if sloc > 1 else 0.0
|
|
494
|
+
raw = 171.0 - 5.2 * ln_v - 0.23 * cc - 16.2 * ln_sloc
|
|
495
|
+
return round(max(0.0, raw * 100.0 / 171.0), 1)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _complexity(fnode) -> dict:
|
|
499
|
+
"""Per-function complexity: cyclomatic, Halstead volume, MI, and physical SLOC."""
|
|
500
|
+
cc = _cyclomatic(fnode)
|
|
501
|
+
volume = _halstead_volume(fnode)
|
|
502
|
+
end = getattr(fnode, "end_lineno", None)
|
|
503
|
+
start = getattr(fnode, "lineno", None)
|
|
504
|
+
sloc = (end - start + 1) if (end and start) else 1
|
|
505
|
+
return {"cc": cc, "volume": volume, "sloc": sloc,
|
|
506
|
+
"mi": _maintainability(cc, volume, sloc)}
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _own_name_loads(scope, *, decorators_of=None):
|
|
510
|
+
"""Bare ``Name`` loads in a scope's own body that **use a symbol**, not call it.
|
|
511
|
+
|
|
512
|
+
Yields ``(node, kind)`` where kind is ``"name"`` (the value form — a dict entry, a
|
|
513
|
+
list element, a ``default=`` callback, an assignment RHS) or ``"annotation"`` (the
|
|
514
|
+
symbol names a *type*). Both are real references and both were missing from the graph,
|
|
515
|
+
but they mean different things — a dispatch table implies runtime liveness, an
|
|
516
|
+
annotation implies a contract — so R1-C22 labels them apart rather than blurring them.
|
|
517
|
+
|
|
518
|
+
Excludes the callee position (that is a `calls` edge) and the scope's own decorator
|
|
519
|
+
list (that is `decorated_by`).
|
|
520
|
+
"""
|
|
521
|
+
nodes = list(_own_nodes(scope)) if not isinstance(scope, ast.Module) \
|
|
522
|
+
else list(_module_level_nodes(scope))
|
|
523
|
+
skip = {id(n.func) for n in nodes if isinstance(n, ast.Call)}
|
|
524
|
+
for deco in (decorators_of.decorator_list if decorators_of is not None else []):
|
|
525
|
+
skip |= {id(n) for n in ast.walk(deco)}
|
|
526
|
+
annotated: set[int] = set()
|
|
527
|
+
for n in nodes + ([decorators_of] if decorators_of is not None else []):
|
|
528
|
+
for ann in _annotation_nodes(n):
|
|
529
|
+
annotated |= {id(x) for x in ast.walk(ann)}
|
|
530
|
+
found = [(n, "annotation" if id(n) in annotated else "name")
|
|
531
|
+
for n in nodes
|
|
532
|
+
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load) and id(n) not in skip]
|
|
533
|
+
# R1-C23/D4: a *quoted* annotation was invisible. R1-C22 taught the graph that an
|
|
534
|
+
# annotation is a reference and it only learned the unquoted form — while the quoted
|
|
535
|
+
# one is the standard idiom for a type that would otherwise be a circular import, so
|
|
536
|
+
# the dependencies most worth seeing were exactly the ones dropped.
|
|
537
|
+
for n in nodes + ([decorators_of] if decorators_of is not None else []):
|
|
538
|
+
for ann in _annotation_nodes(n):
|
|
539
|
+
for const in ast.walk(ann):
|
|
540
|
+
if isinstance(const, ast.Constant) and isinstance(const.value, str):
|
|
541
|
+
found += [(nm, "annotation")
|
|
542
|
+
for nm in _string_annotation_names(const.value)]
|
|
543
|
+
return found
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _string_annotation_names(text: str) -> list[ast.Name]:
|
|
547
|
+
"""Name loads inside a string annotation (``-> "Base"``, ``Optional["Node"]``).
|
|
548
|
+
|
|
549
|
+
Parsed, not pattern-matched, so ``"dict[str, Node]"`` yields ``Node`` and a string
|
|
550
|
+
that is not a type expression yields nothing. An unparseable annotation is a type
|
|
551
|
+
checker's problem, not a graph edge — it is skipped in silence.
|
|
552
|
+
"""
|
|
553
|
+
try:
|
|
554
|
+
tree = ast.parse(text, mode="eval")
|
|
555
|
+
except (SyntaxError, ValueError):
|
|
556
|
+
return []
|
|
557
|
+
return [n for n in ast.walk(tree)
|
|
558
|
+
if isinstance(n, ast.Name) and isinstance(n.ctx, ast.Load)]
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _local_bindings(scope) -> set[str]:
|
|
562
|
+
"""Names bound in a **function** scope, which therefore never mean the module symbol.
|
|
563
|
+
|
|
564
|
+
Python binds per scope, not per statement: once a name is assigned anywhere in a
|
|
565
|
+
function, every read of it in that function is the local — so a local that happens to
|
|
566
|
+
share a name with a module-level function is not a reference to it (R1-C22-f1, issue
|
|
567
|
+
#9, the mirror of the under-attribution #7 fixed).
|
|
568
|
+
|
|
569
|
+
Covers assignment / augmented / walrus / `for` / `with ... as` / `except ... as`
|
|
570
|
+
(all of which produce a `Name` store or an explicit name), parameters, and nested
|
|
571
|
+
`def`/`class` names. ``global``/``nonlocal`` opt a name back out: it really is the
|
|
572
|
+
module binding then.
|
|
573
|
+
|
|
574
|
+
A **function-local import** is deliberately *not* treated as shadowing. It binds the
|
|
575
|
+
name to the symbol it imports, which is the very thing the edge records — suppressing
|
|
576
|
+
it would drop a real reference (measured: it dropped
|
|
577
|
+
``register_builtin_indicators → IndicatorFactory`` on bquant, where the function
|
|
578
|
+
imports that class inside its own body). The residual case — a local import that
|
|
579
|
+
aliases a *different* symbol sharing a name with a module member — resolves to the
|
|
580
|
+
module member instead; rarer than the identity case, and the same class of
|
|
581
|
+
over-attribution this function otherwise fixes.
|
|
582
|
+
|
|
583
|
+
The **module** scope is deliberately not filtered by the caller: rebinding a name at
|
|
584
|
+
module level (`_panel = wrap(_panel)`) does not create a different symbol — it is the
|
|
585
|
+
same node the graph already has.
|
|
586
|
+
"""
|
|
587
|
+
bound: set[str] = set()
|
|
588
|
+
declared_global: set[str] = set()
|
|
589
|
+
|
|
590
|
+
args = getattr(scope, "args", None)
|
|
591
|
+
if args is not None:
|
|
592
|
+
for a in [*args.posonlyargs, *args.args, *args.kwonlyargs, args.vararg, args.kwarg]:
|
|
593
|
+
if a is not None:
|
|
594
|
+
bound.add(a.arg)
|
|
595
|
+
# names a nested def/class binds in *this* scope (their bodies are separate scopes)
|
|
596
|
+
for child in ast.iter_child_nodes(scope):
|
|
597
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
598
|
+
bound.add(child.name)
|
|
599
|
+
|
|
600
|
+
for node in _own_nodes(scope):
|
|
601
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
602
|
+
bound.add(node.id)
|
|
603
|
+
elif isinstance(node, ast.ExceptHandler) and node.name:
|
|
604
|
+
bound.add(node.name)
|
|
605
|
+
elif isinstance(node, (ast.Global, ast.Nonlocal)):
|
|
606
|
+
declared_global.update(node.names)
|
|
607
|
+
return bound - declared_global
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _annotation_nodes(node):
|
|
611
|
+
"""The annotation sub-trees hanging off one AST node (params, return, AnnAssign)."""
|
|
612
|
+
if isinstance(node, ast.AnnAssign) and node.annotation is not None:
|
|
613
|
+
yield node.annotation
|
|
614
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
615
|
+
if node.returns is not None:
|
|
616
|
+
yield node.returns
|
|
617
|
+
args = node.args
|
|
618
|
+
for a in [*args.posonlyargs, *args.args, *args.kwonlyargs,
|
|
619
|
+
args.vararg, args.kwarg]:
|
|
620
|
+
if a is not None and a.annotation is not None:
|
|
621
|
+
yield a.annotation
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _module_level_nodes(tree):
|
|
625
|
+
"""Every node in module-level code — not descending into any def/class body.
|
|
626
|
+
|
|
627
|
+
`add_behavior` walks named functions, so import-time statements were never visited:
|
|
628
|
+
`_register_all_indicators()` at the bottom of a module produced no edge, and the whole
|
|
629
|
+
class of import-time behaviour (registration, availability probes, singletons) was
|
|
630
|
+
invisible to the call graph (R1-C22).
|
|
631
|
+
"""
|
|
632
|
+
def visit(node):
|
|
633
|
+
for child in ast.iter_child_nodes(node):
|
|
634
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
635
|
+
continue # its own definition node — walked separately
|
|
636
|
+
yield child
|
|
637
|
+
yield from visit(child)
|
|
638
|
+
|
|
639
|
+
yield from visit(tree)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _resolve_name(name, modpath, imports, modmembers):
|
|
643
|
+
"""(target_id, resolution) for a bare name used as a value — the `_resolve` rules for
|
|
644
|
+
``ast.Name`` minus the call. Only module/imported become edges."""
|
|
645
|
+
pkg = modpath.split(".")[0] + "."
|
|
646
|
+
if name in imports:
|
|
647
|
+
tgt = imports[name]
|
|
648
|
+
return (tgt, "imported") if tgt.startswith(pkg) else (tgt, "external")
|
|
649
|
+
if name in modmembers:
|
|
650
|
+
return f"{modpath}.{name}", "module"
|
|
651
|
+
return None, "unresolved"
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _emit_name_references(graph, src_id, scope, modpath, imports, modmembers,
|
|
655
|
+
*, decorators_of=None) -> None:
|
|
656
|
+
"""`references` edges for functions/classes named as values (R1-C22 D1).
|
|
657
|
+
|
|
658
|
+
No new edge type: `references` is already "dispatch site → the symbol it names".
|
|
659
|
+
Labelled ``resolution="name"`` so an intra-core name-load stays distinguishable from
|
|
660
|
+
the consumer/doc references, and deduped per target with a site count.
|
|
661
|
+
"""
|
|
662
|
+
# R1-C22-f1: a load of a locally-bound name is the local, not the module symbol.
|
|
663
|
+
local = set() if isinstance(scope, ast.Module) else _local_bindings(scope)
|
|
664
|
+
counts: dict[tuple[str, str], int] = {}
|
|
665
|
+
for node, kind in _own_name_loads(scope, decorators_of=decorators_of):
|
|
666
|
+
if node.id in local:
|
|
667
|
+
continue
|
|
668
|
+
target, resolution = _resolve_name(node.id, modpath, imports, modmembers)
|
|
669
|
+
if resolution not in ("module", "imported") or not target:
|
|
670
|
+
continue
|
|
671
|
+
tgt_node = graph.nodes.get(target)
|
|
672
|
+
if tgt_node is None or tgt_node.kind not in ("function", "class"):
|
|
673
|
+
continue # a value, not a callable definition — nothing to reference
|
|
674
|
+
if target == src_id:
|
|
675
|
+
continue # self-reference (recursion by name) says nothing about liveness
|
|
676
|
+
counts[(target, kind)] = counts.get((target, kind), 0) + 1
|
|
677
|
+
for target, kind in sorted(counts):
|
|
678
|
+
graph.add_edge(Edge("references", src_id, target,
|
|
679
|
+
extras={"resolution": kind, "sites": counts[(target, kind)]}))
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _process_module_level(graph, modpath, tree, resolve, imports, modmembers) -> None:
|
|
683
|
+
"""Calls and value-references in module-level code, sourced from the module (D1/D2)."""
|
|
684
|
+
by_target: dict[str, dict] = {}
|
|
685
|
+
for call in (n for n in _module_level_nodes(tree) if isinstance(n, ast.Call)):
|
|
686
|
+
target, resolution = resolve(call)
|
|
687
|
+
if resolution in _EDGE_RESOLUTIONS and target and target in graph.nodes:
|
|
688
|
+
agg = by_target.setdefault(target, {"resolution": resolution, "shapes": []})
|
|
689
|
+
agg["shapes"].append(_arg_shape(call))
|
|
690
|
+
for target in sorted(by_target):
|
|
691
|
+
agg = by_target[target]
|
|
692
|
+
graph.add_edge(Edge("calls", modpath, target,
|
|
693
|
+
extras={"resolution": agg["resolution"],
|
|
694
|
+
**_arg_contract(agg["shapes"])}))
|
|
695
|
+
_emit_name_references(graph, modpath, tree, modpath, imports, modmembers)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _named_functions_scoped(tree):
|
|
699
|
+
"""Like :func:`_named_functions`, plus the **full** enclosing scope names.
|
|
700
|
+
|
|
701
|
+
``_named_functions`` tracks only class nesting, which is all a definition id needs.
|
|
702
|
+
R1-C22 D3 also has to answer "which definition *contains* this code" for a function
|
|
703
|
+
that is not a definition node itself (a closure, or a method of a dynamically-built
|
|
704
|
+
class), so the whole def/class chain is carried here. Kept as a separate generator
|
|
705
|
+
because `dataflow`/`dispatch`/`attrflow` unpack the two-tuple.
|
|
706
|
+
"""
|
|
707
|
+
results = []
|
|
708
|
+
|
|
709
|
+
def visit(node, class_stack, scope):
|
|
710
|
+
for child in ast.iter_child_nodes(node):
|
|
711
|
+
if isinstance(child, ast.ClassDef):
|
|
712
|
+
visit(child, class_stack + [child.name], scope + [child.name])
|
|
713
|
+
elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
714
|
+
results.append((child, list(class_stack), list(scope)))
|
|
715
|
+
visit(child, class_stack, scope + [child.name])
|
|
716
|
+
else:
|
|
717
|
+
visit(child, class_stack, scope)
|
|
718
|
+
|
|
719
|
+
visit(tree, [], [])
|
|
720
|
+
return results
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _nearest_owner(graph, modpath, scope) -> str | None:
|
|
724
|
+
"""The innermost enclosing definition of ``scope`` that is a real graph node.
|
|
725
|
+
|
|
726
|
+
A call inside a closure is a real call; only the *node it is attributed to* is an
|
|
727
|
+
approximation, so the edge carries ``extras.via="nested"`` (R1-C13 discipline).
|
|
728
|
+
"""
|
|
729
|
+
for cut in range(len(scope), 0, -1):
|
|
730
|
+
candidate = ".".join([modpath, *scope[:cut]])
|
|
731
|
+
if candidate in graph.nodes:
|
|
732
|
+
return candidate
|
|
733
|
+
return modpath if modpath in graph.nodes else None
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _collect_nested_calls(graph, owner, fnode, modpath, imports, modmembers,
|
|
737
|
+
script, target_pkg, out, known_modules=frozenset()) -> None:
|
|
738
|
+
"""Resolve a non-node function's own calls and stage them under ``owner`` (D3)."""
|
|
739
|
+
def fast(call):
|
|
740
|
+
return _resolve(call, modpath, "", imports, modmembers, {})
|
|
741
|
+
# R1-C26: the same union the named-function path uses — a closure's calls must not be
|
|
742
|
+
# resolved by a weaker rule than its neighbours'.
|
|
743
|
+
resolve = (_deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules)
|
|
744
|
+
if script is not None else fast)
|
|
745
|
+
for call in _own_calls(fnode):
|
|
746
|
+
target, resolution = resolve(call)
|
|
747
|
+
if resolution not in _EDGE_RESOLUTIONS or not target or target not in graph.nodes:
|
|
748
|
+
continue
|
|
749
|
+
if target == owner:
|
|
750
|
+
continue # a closure calling its own enclosing function — recursion, not a dep
|
|
751
|
+
agg = out.setdefault((owner, target), {"resolution": resolution, "shapes": []})
|
|
752
|
+
agg["shapes"].append(_arg_shape(call))
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _emit_nested_calls(graph, nested) -> None:
|
|
756
|
+
"""Emit staged nested calls, skipping pairs the owner already calls directly (D3).
|
|
757
|
+
|
|
758
|
+
A duplicate ``owner → target`` edge would double-count in every degree/hub metric, so
|
|
759
|
+
an existing direct call wins: the relationship is already recorded, and this would add
|
|
760
|
+
only a weaker-provenance copy of it.
|
|
761
|
+
"""
|
|
762
|
+
if not nested:
|
|
763
|
+
return
|
|
764
|
+
existing = {(e.source, e.target) for e in graph.edges if e.type == "calls"}
|
|
765
|
+
for (owner, target) in sorted(nested):
|
|
766
|
+
if (owner, target) in existing:
|
|
767
|
+
continue
|
|
768
|
+
agg = nested[(owner, target)]
|
|
769
|
+
graph.add_edge(Edge("calls", owner, target,
|
|
770
|
+
extras={"resolution": agg["resolution"], "via": "nested",
|
|
771
|
+
**_arg_contract(agg["shapes"])}))
|