codmap 0.0.18__tar.gz → 0.0.19__tar.gz

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 (139) hide show
  1. {codmap-0.0.18 → codmap-0.0.19}/PKG-INFO +2 -2
  2. {codmap-0.0.18 → codmap-0.0.19}/README.md +1 -1
  3. {codmap-0.0.18 → codmap-0.0.19}/codemap/extract/griffe_extractor.py +85 -7
  4. {codmap-0.0.18 → codmap-0.0.19}/codemap/query.py +63 -6
  5. {codmap-0.0.18 → codmap-0.0.19}/codemap/scope.py +4 -1
  6. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/api_surface.py +45 -2
  7. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/architecture.py +6 -5
  8. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/audit.py +5 -5
  9. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/check.py +4 -3
  10. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/livingdocs.py +3 -3
  11. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/session.py +5 -0
  12. {codmap-0.0.18 → codmap-0.0.19}/codmap.egg-info/PKG-INFO +2 -2
  13. {codmap-0.0.18 → codmap-0.0.19}/codmap.egg-info/SOURCES.txt +3 -0
  14. {codmap-0.0.18 → codmap-0.0.19}/pyproject.toml +1 -1
  15. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m1_query.py +16 -4
  16. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c29_lazy_imports.py +3 -3
  17. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c48_type_checking_imports.py +5 -5
  18. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c49_type_only_cycles.py +5 -4
  19. codmap-0.0.19/tests/test_r1c55_override_grade.py +118 -0
  20. codmap-0.0.19/tests/test_r1c56_stub_files.py +166 -0
  21. codmap-0.0.19/tests/test_r1c57_facade_package.py +136 -0
  22. {codmap-0.0.18 → codmap-0.0.19}/LICENSE +0 -0
  23. {codmap-0.0.18 → codmap-0.0.19}/codemap/__init__.py +0 -0
  24. {codmap-0.0.18 → codmap-0.0.19}/codemap/apidiff.py +0 -0
  25. {codmap-0.0.18 → codmap-0.0.19}/codemap/arch.py +0 -0
  26. {codmap-0.0.18 → codmap-0.0.19}/codemap/cli.py +0 -0
  27. {codmap-0.0.18 → codmap-0.0.19}/codemap/diagnostics.py +0 -0
  28. {codmap-0.0.18 → codmap-0.0.19}/codemap/extract/__init__.py +0 -0
  29. {codmap-0.0.18 → codmap-0.0.19}/codemap/extract/attrflow.py +0 -0
  30. {codmap-0.0.18 → codmap-0.0.19}/codemap/extract/behavior.py +0 -0
  31. {codmap-0.0.18 → codmap-0.0.19}/codemap/extract/dataflow.py +0 -0
  32. {codmap-0.0.18 → codmap-0.0.19}/codemap/extract/dispatch.py +0 -0
  33. {codmap-0.0.18 → codmap-0.0.19}/codemap/extract/gsource.py +0 -0
  34. {codmap-0.0.18 → codmap-0.0.19}/codemap/extract/roots.py +0 -0
  35. {codmap-0.0.18 → codmap-0.0.19}/codemap/extract/union.py +0 -0
  36. {codmap-0.0.18 → codmap-0.0.19}/codemap/freshness.py +0 -0
  37. {codmap-0.0.18 → codmap-0.0.19}/codemap/incremental.py +0 -0
  38. {codmap-0.0.18 → codmap-0.0.19}/codemap/integrations/__init__.py +0 -0
  39. {codmap-0.0.18 → codmap-0.0.19}/codemap/integrations/base.py +0 -0
  40. {codmap-0.0.18 → codmap-0.0.19}/codemap/integrations/cocoindex.py +0 -0
  41. {codmap-0.0.18 → codmap-0.0.19}/codemap/integrations/gate.py +0 -0
  42. {codmap-0.0.18 → codmap-0.0.19}/codemap/integrations/gitnexus.py +0 -0
  43. {codmap-0.0.18 → codmap-0.0.19}/codemap/integrations/registry.py +0 -0
  44. {codmap-0.0.18 → codmap-0.0.19}/codemap/integrations/transport.py +0 -0
  45. {codmap-0.0.18 → codmap-0.0.19}/codemap/model.py +0 -0
  46. {codmap-0.0.18 → codmap-0.0.19}/codemap/provenance.py +0 -0
  47. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/__init__.py +0 -0
  48. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/_scip_pb2.py +0 -0
  49. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/apidiff.py +0 -0
  50. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/ctags.py +0 -0
  51. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/impact.py +0 -0
  52. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/limits.py +0 -0
  53. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/mcp_server.py +0 -0
  54. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/mermaid.py +0 -0
  55. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/pack.py +0 -0
  56. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/rag.py +0 -0
  57. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/review.py +0 -0
  58. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/scip.py +0 -0
  59. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/semantic.py +0 -0
  60. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/server.py +0 -0
  61. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/subsystems.py +0 -0
  62. {codmap-0.0.18 → codmap-0.0.19}/codemap/serve/vault.py +0 -0
  63. {codmap-0.0.18 → codmap-0.0.19}/codemap/store.py +0 -0
  64. {codmap-0.0.18 → codmap-0.0.19}/codemap/tomlio.py +0 -0
  65. {codmap-0.0.18 → codmap-0.0.19}/codemap/watch.py +0 -0
  66. {codmap-0.0.18 → codmap-0.0.19}/codmap.egg-info/dependency_links.txt +0 -0
  67. {codmap-0.0.18 → codmap-0.0.19}/codmap.egg-info/entry_points.txt +0 -0
  68. {codmap-0.0.18 → codmap-0.0.19}/codmap.egg-info/requires.txt +0 -0
  69. {codmap-0.0.18 → codmap-0.0.19}/codmap.egg-info/top_level.txt +0 -0
  70. {codmap-0.0.18 → codmap-0.0.19}/setup.cfg +0 -0
  71. {codmap-0.0.18 → codmap-0.0.19}/tests/test_epistemic.py +0 -0
  72. {codmap-0.0.18 → codmap-0.0.19}/tests/test_gitnexus_router.py +0 -0
  73. {codmap-0.0.18 → codmap-0.0.19}/tests/test_impact_depth.py +0 -0
  74. {codmap-0.0.18 → codmap-0.0.19}/tests/test_integrations.py +0 -0
  75. {codmap-0.0.18 → codmap-0.0.19}/tests/test_issue3_serve_freshness.py +0 -0
  76. {codmap-0.0.18 → codmap-0.0.19}/tests/test_livingdocs.py +0 -0
  77. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m0_api_surface.py +0 -0
  78. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m11_argcontract.py +0 -0
  79. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m12_dataflow.py +0 -0
  80. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m15_review.py +0 -0
  81. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m16_architecture.py +0 -0
  82. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m17_mcp.py +0 -0
  83. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m18_freshness.py +0 -0
  84. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m1_5_semantics.py +0 -0
  85. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m2_views.py +0 -0
  86. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m32_watch.py +0 -0
  87. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m3_serve.py +0 -0
  88. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m4_behavior.py +0 -0
  89. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m5_deep.py +0 -0
  90. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m6_repo_scope.py +0 -0
  91. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m7_dispatch.py +0 -0
  92. {codmap-0.0.18 → codmap-0.0.19}/tests/test_m9_family.py +0 -0
  93. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  94. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c13_grep_vs_graph.py +0 -0
  95. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c16_semantic.py +0 -0
  96. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c20_attribute_edges.py +0 -0
  97. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c21_flat_layout.py +0 -0
  98. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c22_source_visible_refs.py +0 -0
  99. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c23_hard_python.py +0 -0
  100. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c24_test_mapping.py +0 -0
  101. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c25_provenance.py +0 -0
  102. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c26_deep_union.py +0 -0
  103. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c27_config_honesty.py +0 -0
  104. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c28_limit_envelope.py +0 -0
  105. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c2_ctags.py +0 -0
  106. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c30_local_import_calls.py +0 -0
  107. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c30f1_reexport_calls.py +0 -0
  108. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c30f2_check_scope.py +0 -0
  109. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c31_path_origin.py +0 -0
  110. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c32_report_json.py +0 -0
  111. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c33_query_signature.py +0 -0
  112. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c34_signature_kinds.py +0 -0
  113. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c35_contract_location.py +0 -0
  114. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c36_target_resolution.py +0 -0
  115. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c37_rule_mutation.py +0 -0
  116. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c38_tool_drift.py +0 -0
  117. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c39_edge_resolution.py +0 -0
  118. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c3_arch_contract.py +0 -0
  119. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c3_dogfood.py +0 -0
  120. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c40_flow_reach.py +0 -0
  121. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c41_scope_membership.py +0 -0
  122. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c42_deep_tier_stability.py +0 -0
  123. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c43_incremental_splice.py +0 -0
  124. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c44_absent_answers.py +0 -0
  125. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c45_repeat_union.py +0 -0
  126. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c46_shadowed_definitions.py +0 -0
  127. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c47_incremental_resample.py +0 -0
  128. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c4_complexity.py +0 -0
  129. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c50_flow_entry_points.py +0 -0
  130. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c53_narrowing_declared.py +0 -0
  131. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c54_deterministic_rendering.py +0 -0
  132. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c5_apidiff.py +0 -0
  133. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c6_pack.py +0 -0
  134. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c7_edge_vocab.py +0 -0
  135. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c8_deadcode.py +0 -0
  136. {codmap-0.0.18 → codmap-0.0.19}/tests/test_r1c9_incremental.py +0 -0
  137. {codmap-0.0.18 → codmap-0.0.19}/tests/test_scip_export.py +0 -0
  138. {codmap-0.0.18 → codmap-0.0.19}/tests/test_scope.py +0 -0
  139. {codmap-0.0.18 → codmap-0.0.19}/tests/test_subsystems.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.18
3
+ Version: 0.0.19
4
4
  Summary: Static analyzer that turns a package's source into a queryable code graph.
5
5
  Author-email: kogriv <kogriv@gmail.com>
6
6
  License-Expression: MIT
@@ -44,7 +44,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
44
44
 
45
45
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
46
46
 
47
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **938 tests with no failures on
47
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **961 tests with no failures on
48
48
  Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
49
49
  wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
50
50
  as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
@@ -8,7 +8,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
8
8
 
9
9
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
10
10
 
11
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **938 tests with no failures on
11
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **961 tests with no failures on
12
12
  Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
13
13
  wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
14
14
  as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
@@ -80,6 +80,63 @@ def _assert_is_the_target(loaded, pkg_dir: Path, module_name: str) -> None:
80
80
  )
81
81
 
82
82
 
83
+ #: How many sibling packages one load may pull in before we stop trying (R1-C57).
84
+ _MAX_SIBLING_LOADS = 8
85
+
86
+
87
+ def _load_with_siblings(module_name: str, search_path: Path):
88
+ """``griffe.load`` the target, pulling in siblings its re-exports point at (R1-C57).
89
+
90
+ The facade shape — a public package whose whole job is to re-export a private one,
91
+ as ``pytest``/``_pytest`` and ``attrs``/``attr`` do — used to **crash the build**:
92
+ griffe merges a ``.pyi`` beside ``__init__.py``, that merge resolves aliases, and an
93
+ alias into a package nobody loaded raises ``AliasResolutionError``. Exit code 1, no
94
+ graph, for a six-file package that is valid and published.
95
+
96
+ An unresolvable re-export is partiality, not a crash. So: load the sibling the alias
97
+ names into the *same* collection and retry. The sibling has to be there — it sits on
98
+ the same search path, which is how Python finds it too — and loading it does not put
99
+ it in the graph: the walk starts from the target's own root, and targets outside it
100
+ stay external, exactly as they were before.
101
+
102
+ Returns ``(root, siblings)``; ``siblings`` is what had to be pulled in, and it is
103
+ recorded in the input report so a reader can see the load was not self-contained.
104
+ """
105
+ collection = griffe.ModulesCollection()
106
+ siblings: list[str] = []
107
+ for _ in range(_MAX_SIBLING_LOADS + 1):
108
+ try:
109
+ return griffe.load(module_name, search_paths=[str(search_path)],
110
+ try_relative_path=False,
111
+ modules_collection=collection), siblings
112
+ except griffe.AliasResolutionError as exc:
113
+ top = exc.alias.target_path.split(".")[0]
114
+ if top == module_name or top in siblings:
115
+ raise # retrying would loop on the same alias
116
+ if not _package_on_path(top, search_path):
117
+ raise ValueError(
118
+ f"`{module_name}` re-exports `{exc.alias.target_path}`, and "
119
+ f"`{top}` is not beside it in {search_path}. This is the facade "
120
+ f"layout (a public package re-exporting a private one): build from "
121
+ f"a tree that holds both, or point codemap at the implementation "
122
+ f"package instead."
123
+ ) from exc
124
+ griffe.load(top, search_paths=[str(search_path)], try_relative_path=False,
125
+ modules_collection=collection)
126
+ siblings.append(top)
127
+ raise ValueError(
128
+ f"`{module_name}` still had unresolved re-exports after loading "
129
+ f"{len(siblings)} sibling package(s): {', '.join(siblings)}."
130
+ )
131
+
132
+
133
+ def _package_on_path(name: str, search_path: Path) -> bool:
134
+ """True when ``name`` is importable as a package/module from ``search_path``."""
135
+ d = search_path / name
136
+ return (d / "__init__.py").is_file() or (d / "__init__.pyi").is_file() \
137
+ or (search_path / f"{name}.py").is_file() or (search_path / f"{name}.pyi").is_file()
138
+
139
+
83
140
  def build_structural(package_path: str | Path):
84
141
  """The cheap, deterministic base: griffe load + definition nodes + structural
85
142
  edges (contains / imports / inherits / decorated_by / export). No behavioral
@@ -99,7 +156,7 @@ def build_structural(package_path: str | Path):
99
156
  # a repo whose root holds `pkg/`, `build /elsewhere/pkg` then silently analysed the
100
157
  # local `pkg` — same shape of answer, different code. We always know the directory we
101
158
  # were handed, so the name must resolve through `search_paths` and nowhere else.
102
- root = griffe.load(module_name, search_paths=[str(search_path)], try_relative_path=False)
159
+ root, siblings = _load_with_siblings(module_name, search_path)
103
160
  # Defence in depth: whatever the finder does next (a .pth file, a namespace package,
104
161
  # a future default), a graph must describe the directory that was asked for. A wrong
105
162
  # answer here is invisible downstream — it is well-formed, complete and about the
@@ -402,13 +459,18 @@ def _collect(graph, obj, root, target_pkg, walk) -> None:
402
459
  # carry, or carries under the wrong scope, in one parse.
403
460
  nested, eager = _source_import_targets(obj)
404
461
  type_checking = {tgt for tgt, scope in nested if scope == "type_checking"}
462
+ # R1-C56/D1: a `.pyi` is a declaration — Python never executes it, so *none* of
463
+ # its imports run, whatever they look like. Recorded as a fourth scope rather
464
+ # than borrowed from `type_checking`: the mechanism is a different one, and the
465
+ # cycle classes are named by mechanism.
466
+ stub = _is_stub(obj)
405
467
  for name, tgt in (obj.imports or {}).items():
406
468
  # griffe files an import under `if TYPE_CHECKING:` as module-level; it never
407
469
  # runs. Demote it unless the same target is also imported eagerly (D2).
408
470
  scope = "type_checking" if tgt in type_checking and tgt not in eager else "module"
409
- walk.imports.append((obj.canonical_path, tgt, scope))
471
+ walk.imports.append((obj.canonical_path, tgt, "stub" if stub else scope))
410
472
  for tgt, scope in nested:
411
- walk.imports.append((obj.canonical_path, tgt, scope))
473
+ walk.imports.append((obj.canonical_path, tgt, "stub" if stub else scope))
412
474
  for name, member in obj.members.items():
413
475
  if member.is_alias:
414
476
  # capture ALL re-exports (public flag kept) — a symbol can be importable
@@ -487,7 +549,7 @@ def _emit_decorated_by(graph, obj) -> None:
487
549
  #: import is a **function-local** dependency: it can execute, and labelling it by the
488
550
  #: import that cannot would let a tree launder runtime coupling into the type layer
489
551
  #: (R1-C49, and the test that says so).
490
- _SCOPE_RANK = {"module": 0, "function": 1, "type_checking": 2}
552
+ _SCOPE_RANK = {"module": 0, "function": 1, "type_checking": 2, "stub": 3}
491
553
 
492
554
 
493
555
  def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
@@ -506,7 +568,18 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
506
568
  # no edge at all. Same narrow gate as pass B: only when the head names a
507
569
  # module sitting beside the re-exporter, and labelled as the inference it is.
508
570
  if _flat_sibling(parent_module, target_path, known_modules) is None:
509
- continue # external re-export (e.g. `import numpy as np`) — out of scope
571
+ # R1-C57: `import numpy as np` is not this package's API and stays out.
572
+ # A **public** alias to an outside definition is the opposite case: it is
573
+ # exactly what the package exposes, and dropping it made the facade layout
574
+ # (`pytest` re-exporting 90 names from `_pytest`) report a public surface
575
+ # of one symbol. Kept, with the target left as written and marked external
576
+ # — the definition is outside this graph, and the edge says so rather than
577
+ # pretending to resolve it.
578
+ if not is_public:
579
+ continue
580
+ extras["external"] = True
581
+ graph.add_edge(Edge("export", parent_module, target_path, extras=extras))
582
+ continue
510
583
  target_path = f"{parent_module.rsplit('.', 1)[0]}.{target_path}"
511
584
  extras["resolution"] = "flat"
512
585
  graph.add_edge(Edge("export", parent_module, target_path, extras=extras))
@@ -599,6 +672,12 @@ def _add_node(graph, obj, root) -> None:
599
672
  )
600
673
 
601
674
 
675
+ def _is_stub(obj) -> bool:
676
+ """True when ``obj`` lives in a ``.pyi`` — a declaration Python never executes."""
677
+ f = module_file(obj)
678
+ return f is not None and f.suffix == ".pyi"
679
+
680
+
602
681
  def _stub_marked(extras: dict, obj) -> dict:
603
682
  """Label a symbol that exists only in a ``.pyi`` stub (R1-C23 / design D5).
604
683
 
@@ -607,8 +686,7 @@ def _stub_marked(extras: dict, obj) -> dict:
607
686
  a stubs distribution, and leaving them unmarked presents a function that does not
608
687
  exist as if it did. Consumers that reason about execution (dead-code) exclude them.
609
688
  """
610
- f = module_file(obj)
611
- if f is not None and f.suffix == ".pyi":
689
+ if _is_stub(obj):
612
690
  extras = {**extras, "stub": True}
613
691
  return extras
614
692
 
@@ -153,7 +153,10 @@ class Query:
153
153
  # R1-C49: between the two — everything that can execute. A `TYPE_CHECKING` import
154
154
  # never does, so a cycle that needs one is not runtime coupling at all.
155
155
  self._imports_runtime = nx.DiGraph()
156
- self._import_scopes = {"module": 0, "function": 0, "type_checking": 0}
156
+ # R1-C56: `stub` is the fourth scope — a `.pyi` is never executed, so none of its
157
+ # imports run. Counted apart from `type_checking` (different mechanism, same
158
+ # consequence) and, like it, kept out of both the eager and the runtime graph.
159
+ self._import_scopes = {"module": 0, "function": 0, "type_checking": 0, "stub": 0}
157
160
  for n in graph.nodes.values():
158
161
  if n.kind == "module":
159
162
  self._imports.add_node(n.id)
@@ -163,9 +166,9 @@ class Query:
163
166
  if e.type == "imports":
164
167
  self._imports.add_edge(e.source, e.target)
165
168
  scope = e.extras.get("scope")
166
- if scope in ("function", "type_checking"):
167
- # R1-C29: runs when the function runs; R1-C48: never runs. Neither
168
- # is an import-time edge — but only the first is a runtime one.
169
+ if scope in ("function", "type_checking", "stub"):
170
+ # R1-C29: runs when the function runs; R1-C48 and R1-C56: never runs.
171
+ # Neither is an import-time edge — but only the first is a runtime one.
169
172
  self._import_scopes[scope] += 1
170
173
  if scope == "function":
171
174
  self._imports_runtime.add_edge(e.source, e.target)
@@ -665,10 +668,57 @@ class Query:
665
668
  out = [c for c in out if _CONFIDENCE_RANK[c["confidence"]] >= floor]
666
669
  return out
667
670
 
671
+ def _overridden_base(self, n: Node) -> tuple[str, int] | None:
672
+ """The ancestor method ``n`` overrides, and that ancestor's inbound calls (R1-C55).
673
+
674
+ An override is not reached by its own name: the base is called and dynamic
675
+ dispatch lands here, so "no inbound calls" is a statement about the *name*, not
676
+ about the body. Measured on Pillow, where 40 of 63 ``high`` candidates were
677
+ ``_open`` implementations of a template method the graph itself records as
678
+ called from ``ImageFile.__init__`` — the strongest grade, on the most ordinary
679
+ shape in object-oriented Python.
680
+
681
+ Walks ``inherits`` transitively and weighs **every** ancestor that declares the
682
+ name, not just the nearest: in a three-deep chain the middle link is an override
683
+ too, so it has no inbound call of its own, and stopping there would report "the
684
+ base is itself uncalled" while the call sits one level further up. An ancestor
685
+ with inbound calls therefore wins; failing that, the nearest one is named.
686
+ """
687
+ cls, _, meth = n.id.rpartition(".")
688
+ if not cls or cls not in self._inherits:
689
+ return None
690
+ nearest: tuple[str, int] | None = None
691
+ seen, queue = {cls}, [cls]
692
+ while queue: # breadth-first, so `nearest` is the closest declaration
693
+ for base in self.bases(queue.pop(0)):
694
+ if base in seen:
695
+ continue
696
+ seen.add(base)
697
+ queue.append(base)
698
+ cand = f"{base}.{meth}"
699
+ if cand not in self.graph.nodes:
700
+ continue
701
+ in_calls = self._calls.in_degree(cand) if cand in self._calls else 0
702
+ if in_calls:
703
+ return cand, in_calls
704
+ nearest = nearest or (cand, 0)
705
+ return nearest
706
+
668
707
  def _grade_dead(self, n: Node) -> dict:
669
708
  """Score one uncalled-private candidate → {id, confidence, root, reasons}."""
670
709
  refs = self.references_to(n.id) # inbound of every kind, across roots
671
710
  registry = n.extras.get("registry")
711
+ override = self._overridden_base(n)
712
+ if override and not refs:
713
+ # R1-C55: the name is uncalled; the body is reachable through the base.
714
+ base, in_calls = override
715
+ if in_calls:
716
+ return {"id": n.id, "confidence": "low", "root": self.root_of(n.id),
717
+ "reasons": [f"overrides {base}, which has {in_calls} inbound "
718
+ f"call(s) — reached by dispatch, not by name"]}
719
+ return {"id": n.id, "confidence": "medium", "root": self.root_of(n.id),
720
+ "reasons": [f"overrides {base}, which is itself uncalled here — "
721
+ f"dead only if the base is"]}
672
722
  if refs:
673
723
  by: dict[tuple[str, str], int] = {}
674
724
  for r in refs:
@@ -1110,13 +1160,19 @@ class Query:
1110
1160
  if frozenset(c) not in eager)
1111
1161
 
1112
1162
  def type_only_import_cycles(self) -> list[list[str]]:
1113
- """Dependency cycles that close **only** with an import under ``if TYPE_CHECKING:``.
1163
+ """Dependency cycles that close **only** with an import that never executes.
1114
1164
 
1115
1165
  The third kind (R1-C49, issue #18). These modules name each other's types and have
1116
1166
  **no runtime dependency whatever**: neither import pulls the other at any moment of
1117
1167
  execution. That is why they are not gated by ``no_lazy_cycles`` — which exists
1118
1168
  against a lazy import used to walk *around* ``no_cycles`` — and get their own
1119
1169
  opt-in rule instead.
1170
+
1171
+ Two mechanisms, one consequence (R1-C56/D2): an import under ``if TYPE_CHECKING:``
1172
+ and an import written in a ``.pyi``, which Python does not execute at all. The
1173
+ class partitions by consequence — what a reader needs is whether the import can
1174
+ break — while the edge keeps the mechanism in ``extras.scope``. Measured on
1175
+ Pillow, whose *only* "hard" cycle was a stub declaring the module that imports it.
1120
1176
  """
1121
1177
  runtime = {frozenset(c) for c in nx.simple_cycles(self._imports_runtime)}
1122
1178
  return _canonical_cycles(c for c in nx.simple_cycles(self._imports)
@@ -1132,7 +1188,8 @@ class Query:
1132
1188
  """
1133
1189
  return {"module_level": self._import_scopes["module"],
1134
1190
  "function_local": self._import_scopes["function"],
1135
- "type_checking": self._import_scopes["type_checking"]}
1191
+ "type_checking": self._import_scopes["type_checking"],
1192
+ "stub": self._import_scopes["stub"]}
1136
1193
 
1137
1194
  def orphan_modules(self, root: str | None = None) -> list[str]:
1138
1195
  """Modules with no incoming imports (dead-code candidates — heuristic).
@@ -19,7 +19,10 @@ import subprocess
19
19
  from pathlib import Path
20
20
 
21
21
  # What codemap actually consumes as input (source + docs it indexes as references).
22
- DEFAULT_INCLUDE = ("*.py", "*.md")
22
+ # R1-C56/D3: `.pyi` is here because griffe reads it. Leaving it out made the manifest
23
+ # describe a different input than the graph was built from, so `scope_id` — and with it
24
+ # `--incremental` and `watch` — read as unknown on every tree that ships stubs.
25
+ DEFAULT_INCLUDE = ("*.py", "*.pyi", "*.md")
23
26
  # fs-mode default excludes (git mode gets these for free via .gitignore).
24
27
  DEFAULT_EXCLUDE_DIRS = frozenset({
25
28
  "__pycache__", ".git", ".venv", "venv", "node_modules", "build", "dist",
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from collections import defaultdict
11
11
 
12
+ from codemap.diagnostics import diagnostics, render_lines
12
13
  from codemap.model import Graph
13
14
 
14
15
  _SYMBOL_KINDS = {"class", "function", "attribute"}
@@ -41,16 +42,39 @@ def build_api_surface(graph: Graph) -> dict:
41
42
  "doc": _first_line(n.docstring), "file": n.file, "lineno": n.lineno}
42
43
  for n in symbols
43
44
  ]})
45
+ reexported = _reexported_from_outside(graph)
44
46
  return {
45
47
  "kind": "api-surface",
46
48
  "target": graph.target,
47
49
  "totals": {"symbols": sum(len(m["symbols"]) for m in modules),
48
50
  "modules_with_symbols": len(modules),
49
- "public_modules": len(public_modules)},
51
+ "public_modules": len(public_modules),
52
+ # R1-C57: always present, zero included — a reader must not have to
53
+ # tell "this package re-exports nothing" from "we did not look".
54
+ "reexported_from_outside": len(reexported)},
50
55
  "modules": modules,
56
+ "reexported_from_outside": reexported,
57
+ "diagnostics": diagnostics(graph),
51
58
  }
52
59
 
53
60
 
61
+ def _reexported_from_outside(graph: Graph) -> list[dict]:
62
+ """Public names this package exposes whose definition lives outside it (R1-C57).
63
+
64
+ The facade layout — `pytest` re-exporting 90 names from `_pytest`, `attrs` from
65
+ `attr` — has almost no symbols of its own, so counting nodes reported a public
66
+ surface of **one** for a package whose whole purpose is its API. These names are
67
+ the surface; the definitions are simply not in this graph, and the answer says so
68
+ instead of omitting them.
69
+ """
70
+ out = []
71
+ for e in graph.edges:
72
+ if e.type == "export" and e.extras.get("external") and e.extras.get("public"):
73
+ out.append({"name": e.extras.get("as", ""), "module": e.source,
74
+ "defined_at": e.target})
75
+ return sorted(out, key=lambda r: (r["module"], r["name"]))
76
+
77
+
54
78
  def render_api_surface(graph: Graph) -> str:
55
79
  """Render the public API surface of ``graph`` as Markdown."""
56
80
  by_module: dict[str, list] = defaultdict(list)
@@ -61,12 +85,24 @@ def render_api_surface(graph: Graph) -> str:
61
85
  by_module[module].append(node)
62
86
 
63
87
  lines = [f"# API surface — `{graph.target}`", ""]
88
+ # R1-C57: every other report carried the build's diagnostics and this one did not, so
89
+ # a facade package printed its surface with no hint that the import graph behind it was
90
+ # empty. A warning that reaches four reports out of five is a warning the reader can
91
+ # miss by asking the wrong question.
92
+ lines.extend(render_lines(graph))
64
93
  public_modules = sorted(
65
94
  n.id for n in graph.nodes.values() if n.kind == "module" and n.visibility == "public"
66
95
  )
67
96
  total = sum(len(v) for v in by_module.values())
68
- lines.append(f"_{total} public symbols across {len(public_modules)} modules._")
97
+ reexported = _reexported_from_outside(graph)
98
+ lines.append(f"_{total} public symbols across {len(public_modules)} modules; "
99
+ f"{len(reexported)} more re-exported from outside this root._")
69
100
  lines.append("")
101
+ if reexported:
102
+ lines.append(f"> ⚠ This package exposes **{len(reexported)}** name(s) it does not "
103
+ f"define — the facade layout. Their definitions are in another root "
104
+ f"and are **not judged here**; build that package to see them.")
105
+ lines.append("")
70
106
 
71
107
  for module in public_modules:
72
108
  symbols = sorted(by_module.get(module, []), key=lambda n: n.id)
@@ -84,6 +120,13 @@ def render_api_surface(graph: Graph) -> str:
84
120
  lines.append(f" - {doc}")
85
121
  lines.append("")
86
122
 
123
+ if reexported:
124
+ lines.append("## Re-exported from outside this root")
125
+ lines.append("")
126
+ for r in reexported:
127
+ lines.append(f"- **`{r['name']}`** → `{r['defined_at']}` (via `{r['module']}`)")
128
+ lines.append("")
129
+
87
130
  return "\n".join(lines).rstrip() + "\n"
88
131
 
89
132
 
@@ -82,9 +82,10 @@ def render_architecture(query: Query) -> str:
82
82
  or ["_none found in the eager import graph._"])
83
83
  out.append("")
84
84
  out.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
85
- f"function-local and {im['type_checking']} `TYPE_CHECKING` import(s). Only "
86
- f"module-level imports run at import time, so only they can break on import; "
87
- f"an import under `if TYPE_CHECKING:` never runs._")
85
+ f"function-local, {im['type_checking']} `TYPE_CHECKING` and {im['stub']} "
86
+ f"`.pyi` import(s). Only module-level imports run at import time, so only "
87
+ f"they can break on import; an import under `if TYPE_CHECKING:` never runs, "
88
+ f"and a `.pyi` is not executed at all._")
88
89
  out.append("")
89
90
  if a["lazy_cycles"]:
90
91
  out.append(f"### Dependency cycles closed only by a function-local import: "
@@ -103,8 +104,8 @@ def render_architecture(query: Query) -> str:
103
104
  if a["type_only_cycles"]:
104
105
  # R1-C49: the third kind, kept apart from the second because the difference is the
105
106
  # whole point — these modules have no runtime dependency on each other at all.
106
- out.append(f"### Dependency cycles closed only by an import under "
107
- f"`if TYPE_CHECKING:`: {len(a['type_only_cycles'])}")
107
+ out.append(f"### Dependency cycles closed only by an import that never runs "
108
+ f"(`if TYPE_CHECKING:` or a `.pyi`): {len(a['type_only_cycles'])}")
108
109
  out.append("")
109
110
  out.append("_Neither module pulls the other at any moment of execution — they name "
110
111
  "each other's types. Not an import-time failure and not runtime coupling; "
@@ -32,11 +32,11 @@ def render_dependencies(query: Query) -> str:
32
32
  lines.append("_none found in the eager import graph._")
33
33
  lines.append("")
34
34
  lines.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
35
- f"function-local and {im['type_checking']} `TYPE_CHECKING` import(s); only "
36
- f"the first run at import time. {len(lazy)} further cycle(s) close through "
37
- f"a function-local import (runtime coupling, not an import-time failure) "
38
- f"and {len(type_only)} through an import under `if TYPE_CHECKING:` (no "
39
- f"runtime dependency at all)._")
35
+ f"function-local, {im['type_checking']} `TYPE_CHECKING` and {im['stub']} "
36
+ f"`.pyi` import(s); only the first run at import time. {len(lazy)} further "
37
+ f"cycle(s) close through a function-local import (runtime coupling, not an "
38
+ f"import-time failure) and {len(type_only)} through an import that never "
39
+ f"runs — `if TYPE_CHECKING:` or a `.pyi` (no runtime dependency at all)._")
40
40
  lines.append("")
41
41
 
42
42
  lines.append("## Most-depended-on modules (top 15)")
@@ -47,9 +47,10 @@ def _not_judged(query, contract: ArchitectureContract) -> list[dict]:
47
47
  "type_checking_imports": query.import_map()["type_checking"],
48
48
  "count": len(lazy) + len(type_only),
49
49
  "note": ("`lazy` needs a function-local import — runtime coupling, gated by "
50
- "`no_lazy_cycles = true`. `type_only` needs an import under "
51
- "`if TYPE_CHECKING:` — no runtime dependency at all, gated by "
52
- "`no_type_only_cycles = true`. `report architecture` lists both."),
50
+ "`no_lazy_cycles = true`. `type_only` needs an import that never runs "
51
+ "(`if TYPE_CHECKING:`, or a `.pyi`, which Python does not execute) — no "
52
+ "runtime dependency at all, gated by `no_type_only_cycles = true`. "
53
+ "`report architecture` lists both."),
53
54
  }]
54
55
 
55
56
 
@@ -160,9 +160,9 @@ def render_docs(query: Query) -> str:
160
160
  out.append(f"- **{len(lazy)} dependency cycle(s) closed only by a function-local "
161
161
  f"import** — deliberate, and still runtime coupling.")
162
162
  if type_only:
163
- out.append(f"- **{len(type_only)} dependency cycle(s) closed only by an import under "
164
- f"`if TYPE_CHECKING:`** — the modules name each other's types and have no "
165
- f"runtime dependency.")
163
+ out.append(f"- **{len(type_only)} dependency cycle(s) closed only by an import that "
164
+ f"never runs** (`if TYPE_CHECKING:` or a `.pyi`) — the modules name each "
165
+ f"other's types and have no runtime dependency.")
166
166
  if lay["violations"]:
167
167
  out.append("- **Layer violations (mutual dependency):** "
168
168
  + ", ".join(f"{a} ↔ {b}" for a, b in lay["violations"]))
@@ -191,6 +191,11 @@ def _match(q: Query, n) -> dict:
191
191
  (posargs / kwargs / splat per call site), which no node field can know.
192
192
  """
193
193
  e = {"id": n.id, "kind": n.kind, "file": n.file, "lines": [n.lineno, n.endlineno]}
194
+ if n.extras.get("stub"):
195
+ # R1-C56/D4: a `.pyi` declaration, not code. Whoever is about to read the body
196
+ # must learn there is none from the answer, not from the file extension — the
197
+ # same rule as the epistemic label and the kind of an empty answer.
198
+ e["stub"] = True
194
199
  if n.signature:
195
200
  e["signature"] = n.signature
196
201
  elif n.kind == "class":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.18
3
+ Version: 0.0.19
4
4
  Summary: Static analyzer that turns a package's source into a queryable code graph.
5
5
  Author-email: kogriv <kogriv@gmail.com>
6
6
  License-Expression: MIT
@@ -44,7 +44,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
44
44
 
45
45
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
46
46
 
47
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **938 tests with no failures on
47
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **961 tests with no failures on
48
48
  Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
49
49
  wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
50
50
  as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
@@ -124,6 +124,9 @@ tests/test_r1c4_complexity.py
124
124
  tests/test_r1c50_flow_entry_points.py
125
125
  tests/test_r1c53_narrowing_declared.py
126
126
  tests/test_r1c54_deterministic_rendering.py
127
+ tests/test_r1c55_override_grade.py
128
+ tests/test_r1c56_stub_files.py
129
+ tests/test_r1c57_facade_package.py
127
130
  tests/test_r1c5_apidiff.py
128
131
  tests/test_r1c6_pack.py
129
132
  tests/test_r1c7_edge_vocab.py
@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
9
9
  # version. README says this plainly, since `pip install codmap` / `import codemap` is a
10
10
  # seam a reader will otherwise hit by surprise.
11
11
  name = "codmap"
12
- version = "0.0.18"
12
+ version = "0.0.19"
13
13
  description = "Static analyzer that turns a package's source into a queryable code graph."
14
14
  readme = "README.md"
15
15
  # M20/D1: measured, not assumed. Each release of the declared range runs the full suite in
@@ -79,7 +79,8 @@ def test_cycle_detection(q, graph):
79
79
  type_only = {frozenset(c) for c in q.type_only_import_cycles()}
80
80
  assert not (eager & lazy) and not (eager & type_only) and not (lazy & type_only), \
81
81
  "a cycle has exactly one kind — the weakest scope that closes it (R1-C49)"
82
- assert set(q.import_map()) == {"module_level", "function_local", "type_checking"}
82
+ assert set(q.import_map()) == {"module_level", "function_local", "type_checking",
83
+ "stub"}
83
84
  assert q.import_map()["module_level"] > 0
84
85
 
85
86
  scopes: dict[tuple[str, str], set[str]] = {}
@@ -94,13 +95,24 @@ def test_cycle_detection(q, graph):
94
95
  for c in lazy:
95
96
  assert closing(c, "function"), f"a lazy cycle needs a function-local edge: {c}"
96
97
  for c in type_only:
97
- assert closing(c, "type_checking"), f"a type-only cycle needs one: {c}"
98
+ # R1-C56: two mechanisms never execute — `if TYPE_CHECKING:` and a `.pyi`. The
99
+ # class is defined by the consequence, so either scope may be what closes it.
100
+ assert closing(c, "type_checking") or closing(c, "stub"), \
101
+ f"a type-only cycle needs an import that never runs: {c}"
98
102
 
99
103
 
100
104
  def test_orphan_modules(q):
105
+ """An invariant, not a name: `bquant.cli` was the example until the target grew a
106
+ `__main__.py` that imports it, and the assertion started measuring the target
107
+ rather than the query (R1-C25 — the second time in this suite, after
108
+ `test_cycle_detection`). What must hold on any tree: an orphan is a module the
109
+ import graph has no inbound edge for."""
101
110
  orphans = q.orphan_modules()
102
- assert isinstance(orphans, list)
103
- assert "bquant.cli" in orphans # entry point, imported by nothing internally
111
+ assert isinstance(orphans, list) and orphans, "the dogfood tree has orphans"
112
+ for mod in orphans:
113
+ assert q.graph.nodes[mod].kind == "module"
114
+ assert not [e for e in q.graph.edges
115
+ if e.type == "imports" and e.target == mod], f"{mod} is imported"
104
116
 
105
117
 
106
118
  def test_determinism_with_edges():
@@ -153,7 +153,7 @@ def test_import_map_is_emitted_even_when_nothing_is_lazy(tmp_path):
153
153
  to tell "no lazy imports here" from "this build did not look for them"."""
154
154
  pkg = _pkg(tmp_path, {"a.py": "x = 1\n", "b.py": "from pkg.a import x\n"})
155
155
  im = Query(extract(str(pkg))).import_map()
156
- assert im == {"module_level": 1, "function_local": 0, "type_checking": 0}
156
+ assert im == {"module_level": 1, "function_local": 0, "type_checking": 0, "stub": 0}
157
157
 
158
158
 
159
159
  # -- and no consumer states acyclicity as a property -------------------------
@@ -162,7 +162,7 @@ def test_architecture_never_claims_acyclic(lazy_cycle):
162
162
  md = render_architecture(Query(extract(str(lazy_cycle))))
163
163
  assert "acyclic" not in md, "a partial map cannot support a property claim"
164
164
  assert "none found in the eager import graph" in md
165
- assert "function-local and 0 `TYPE_CHECKING` import(s)" in md
165
+ assert "function-local, 0 `TYPE_CHECKING` and 0 `.pyi` import(s)" in md
166
166
  assert "Dependency cycles closed only by a function-local import: 1" in md
167
167
 
168
168
 
@@ -176,7 +176,7 @@ def test_architecture_payload_carries_both_kinds_and_the_counts(lazy_cycle):
176
176
  a = build_architecture(Query(extract(str(lazy_cycle))))
177
177
  assert a["cycles"] == []
178
178
  assert [sorted(c) for c in a["lazy_cycles"]] == [["pkg.a", "pkg.b"]]
179
- assert a["import_map"] == {"module_level": 1, "function_local": 1, "type_checking": 0}
179
+ assert a["import_map"] == {"module_level": 1, "function_local": 1, "type_checking": 0, "stub": 0}
180
180
 
181
181
 
182
182
  def test_no_renderer_states_acyclicity_anywhere(tmp_path):
@@ -137,7 +137,7 @@ def test_the_cycle_is_not_eager(tree):
137
137
 
138
138
 
139
139
  def test_import_map_names_the_scope_always(tree):
140
- assert tree[2].import_map() == {"module_level": 4, "function_local": 1, "type_checking": 3}
140
+ assert tree[2].import_map() == {"module_level": 4, "function_local": 1, "type_checking": 3, "stub": 0}
141
141
 
142
142
 
143
143
  def test_import_map_says_zero_when_there_is_none(tmp_path):
@@ -150,10 +150,10 @@ def test_every_consumer_says_which_imports_it_did_not_judge(tree):
150
150
  a = build_architecture(q)
151
151
  assert a["import_map"]["type_checking"] == 3
152
152
  md = render_architecture(q)
153
- assert "3 `TYPE_CHECKING` import(s)" in md
154
- assert "closed only by an import under `if TYPE_CHECKING:`: 1" in md
155
- assert "`TYPE_CHECKING` import(s)" in render_dependencies(q)
156
- assert "closed only by an import under `if TYPE_CHECKING:`" in render_docs(q)
153
+ assert "3 `TYPE_CHECKING` and 0 `.pyi` import(s)" in md
154
+ assert "closed only by an import that never runs (`if TYPE_CHECKING:` or a `.pyi`): 1" in md
155
+ assert "`TYPE_CHECKING` and 0 `.pyi` import(s)" in render_dependencies(q)
156
+ assert "closed only by an import that never runs" in render_docs(q)
157
157
 
158
158
 
159
159
  def test_the_gate_is_green_and_names_what_it_read(tree):
@@ -192,18 +192,19 @@ def test_the_reports_carry_both_kinds_and_say_zero(both, tmp_path):
192
192
  assert _flat(a["type_only_cycles"]) == [["bp.typed_a", "bp.typed_b"]]
193
193
  md = render_architecture(both)
194
194
  assert "Dependency cycles closed only by a function-local import: 1" in md
195
- assert "Dependency cycles closed only by an import under `if TYPE_CHECKING:`: 1" in md
195
+ assert ("Dependency cycles closed only by an import that never runs "
196
+ "(`if TYPE_CHECKING:` or a `.pyi`): 1") in md
196
197
  dep = render_dependencies(both)
197
198
  assert "1 further cycle(s) close through a function-local import" in dep
198
- assert "1 through an import under `if TYPE_CHECKING:`" in dep
199
+ assert "1 through an import that never runs" in dep
199
200
  assert _flat(build_dependencies(both)["type_only_import_cycles"]) == [["bp.typed_a", "bp.typed_b"]]
200
201
  docs = render_docs(both)
201
202
  assert "closed only by a function-local import" in docs
202
- assert "closed only by an import under `if TYPE_CHECKING:`" in docs
203
+ assert "closed only by an import that never runs" in docs
203
204
 
204
205
  quiet = Query(extract(str(_pkg(tmp_path, {"a": "from .b import x\n", "b": "x = 1\n"}))))
205
206
  assert build_architecture(quiet)["type_only_cycles"] == []
206
- assert "and 0 through an import under `if TYPE_CHECKING:`" in render_dependencies(quiet)
207
+ assert "and 0 through an import that never runs" in render_dependencies(quiet)
207
208
 
208
209
 
209
210
  # -- f1: a rule that ran must name itself ---------------------------------------------------