codmap 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. codemap/__init__.py +10 -0
  2. codemap/apidiff.py +208 -0
  3. codemap/arch.py +190 -0
  4. codemap/cli.py +718 -0
  5. codemap/diagnostics.py +256 -0
  6. codemap/extract/__init__.py +10 -0
  7. codemap/extract/attrflow.py +230 -0
  8. codemap/extract/behavior.py +771 -0
  9. codemap/extract/dataflow.py +97 -0
  10. codemap/extract/dispatch.py +248 -0
  11. codemap/extract/griffe_extractor.py +496 -0
  12. codemap/extract/gsource.py +83 -0
  13. codemap/extract/roots.py +427 -0
  14. codemap/freshness.py +94 -0
  15. codemap/incremental.py +195 -0
  16. codemap/integrations/__init__.py +51 -0
  17. codemap/integrations/base.py +196 -0
  18. codemap/integrations/cocoindex.py +78 -0
  19. codemap/integrations/gate.py +58 -0
  20. codemap/integrations/gitnexus.py +93 -0
  21. codemap/integrations/registry.py +69 -0
  22. codemap/integrations/transport.py +46 -0
  23. codemap/model.py +178 -0
  24. codemap/provenance.py +248 -0
  25. codemap/query.py +1164 -0
  26. codemap/scope.py +212 -0
  27. codemap/serve/__init__.py +26 -0
  28. codemap/serve/_scip_pb2.py +100 -0
  29. codemap/serve/api_surface.py +60 -0
  30. codemap/serve/apidiff.py +83 -0
  31. codemap/serve/architecture.py +101 -0
  32. codemap/serve/audit.py +176 -0
  33. codemap/serve/check.py +80 -0
  34. codemap/serve/ctags.py +203 -0
  35. codemap/serve/impact.py +84 -0
  36. codemap/serve/livingdocs.py +174 -0
  37. codemap/serve/mcp_server.py +278 -0
  38. codemap/serve/mermaid.py +120 -0
  39. codemap/serve/pack.py +93 -0
  40. codemap/serve/rag.py +142 -0
  41. codemap/serve/review.py +197 -0
  42. codemap/serve/scip.py +183 -0
  43. codemap/serve/semantic.py +71 -0
  44. codemap/serve/server.py +43 -0
  45. codemap/serve/session.py +482 -0
  46. codemap/serve/subsystems.py +85 -0
  47. codemap/serve/vault.py +156 -0
  48. codemap/store.py +28 -0
  49. codemap/tomlio.py +59 -0
  50. codmap-0.0.3.dist-info/METADATA +245 -0
  51. codmap-0.0.3.dist-info/RECORD +55 -0
  52. codmap-0.0.3.dist-info/WHEEL +5 -0
  53. codmap-0.0.3.dist-info/entry_points.txt +2 -0
  54. codmap-0.0.3.dist-info/licenses/LICENSE +21 -0
  55. codmap-0.0.3.dist-info/top_level.txt +1 -0
@@ -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"])}))