codmap 0.0.18__tar.gz → 0.0.20__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 (140) hide show
  1. {codmap-0.0.18 → codmap-0.0.20}/PKG-INFO +2 -2
  2. {codmap-0.0.18 → codmap-0.0.20}/README.md +1 -1
  3. {codmap-0.0.18 → codmap-0.0.20}/codemap/arch.py +31 -14
  4. {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/griffe_extractor.py +85 -7
  5. {codmap-0.0.18 → codmap-0.0.20}/codemap/query.py +154 -14
  6. {codmap-0.0.18 → codmap-0.0.20}/codemap/scope.py +4 -1
  7. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/api_surface.py +45 -2
  8. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/architecture.py +70 -21
  9. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/audit.py +16 -12
  10. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/check.py +6 -5
  11. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/livingdocs.py +10 -9
  12. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/session.py +5 -0
  13. {codmap-0.0.18 → codmap-0.0.20}/codmap.egg-info/PKG-INFO +2 -2
  14. {codmap-0.0.18 → codmap-0.0.20}/codmap.egg-info/SOURCES.txt +4 -0
  15. {codmap-0.0.18 → codmap-0.0.20}/pyproject.toml +1 -1
  16. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m16_architecture.py +4 -1
  17. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m1_query.py +16 -4
  18. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c29_lazy_imports.py +23 -12
  19. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c48_type_checking_imports.py +6 -6
  20. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c49_type_only_cycles.py +6 -5
  21. codmap-0.0.20/tests/test_r1c55_override_grade.py +118 -0
  22. codmap-0.0.20/tests/test_r1c56_stub_files.py +166 -0
  23. codmap-0.0.20/tests/test_r1c57_facade_package.py +136 -0
  24. codmap-0.0.20/tests/test_r1c58_cycle_tangles.py +151 -0
  25. {codmap-0.0.18 → codmap-0.0.20}/LICENSE +0 -0
  26. {codmap-0.0.18 → codmap-0.0.20}/codemap/__init__.py +0 -0
  27. {codmap-0.0.18 → codmap-0.0.20}/codemap/apidiff.py +0 -0
  28. {codmap-0.0.18 → codmap-0.0.20}/codemap/cli.py +0 -0
  29. {codmap-0.0.18 → codmap-0.0.20}/codemap/diagnostics.py +0 -0
  30. {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/__init__.py +0 -0
  31. {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/attrflow.py +0 -0
  32. {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/behavior.py +0 -0
  33. {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/dataflow.py +0 -0
  34. {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/dispatch.py +0 -0
  35. {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/gsource.py +0 -0
  36. {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/roots.py +0 -0
  37. {codmap-0.0.18 → codmap-0.0.20}/codemap/extract/union.py +0 -0
  38. {codmap-0.0.18 → codmap-0.0.20}/codemap/freshness.py +0 -0
  39. {codmap-0.0.18 → codmap-0.0.20}/codemap/incremental.py +0 -0
  40. {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/__init__.py +0 -0
  41. {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/base.py +0 -0
  42. {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/cocoindex.py +0 -0
  43. {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/gate.py +0 -0
  44. {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/gitnexus.py +0 -0
  45. {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/registry.py +0 -0
  46. {codmap-0.0.18 → codmap-0.0.20}/codemap/integrations/transport.py +0 -0
  47. {codmap-0.0.18 → codmap-0.0.20}/codemap/model.py +0 -0
  48. {codmap-0.0.18 → codmap-0.0.20}/codemap/provenance.py +0 -0
  49. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/__init__.py +0 -0
  50. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/_scip_pb2.py +0 -0
  51. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/apidiff.py +0 -0
  52. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/ctags.py +0 -0
  53. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/impact.py +0 -0
  54. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/limits.py +0 -0
  55. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/mcp_server.py +0 -0
  56. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/mermaid.py +0 -0
  57. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/pack.py +0 -0
  58. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/rag.py +0 -0
  59. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/review.py +0 -0
  60. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/scip.py +0 -0
  61. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/semantic.py +0 -0
  62. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/server.py +0 -0
  63. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/subsystems.py +0 -0
  64. {codmap-0.0.18 → codmap-0.0.20}/codemap/serve/vault.py +0 -0
  65. {codmap-0.0.18 → codmap-0.0.20}/codemap/store.py +0 -0
  66. {codmap-0.0.18 → codmap-0.0.20}/codemap/tomlio.py +0 -0
  67. {codmap-0.0.18 → codmap-0.0.20}/codemap/watch.py +0 -0
  68. {codmap-0.0.18 → codmap-0.0.20}/codmap.egg-info/dependency_links.txt +0 -0
  69. {codmap-0.0.18 → codmap-0.0.20}/codmap.egg-info/entry_points.txt +0 -0
  70. {codmap-0.0.18 → codmap-0.0.20}/codmap.egg-info/requires.txt +0 -0
  71. {codmap-0.0.18 → codmap-0.0.20}/codmap.egg-info/top_level.txt +0 -0
  72. {codmap-0.0.18 → codmap-0.0.20}/setup.cfg +0 -0
  73. {codmap-0.0.18 → codmap-0.0.20}/tests/test_epistemic.py +0 -0
  74. {codmap-0.0.18 → codmap-0.0.20}/tests/test_gitnexus_router.py +0 -0
  75. {codmap-0.0.18 → codmap-0.0.20}/tests/test_impact_depth.py +0 -0
  76. {codmap-0.0.18 → codmap-0.0.20}/tests/test_integrations.py +0 -0
  77. {codmap-0.0.18 → codmap-0.0.20}/tests/test_issue3_serve_freshness.py +0 -0
  78. {codmap-0.0.18 → codmap-0.0.20}/tests/test_livingdocs.py +0 -0
  79. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m0_api_surface.py +0 -0
  80. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m11_argcontract.py +0 -0
  81. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m12_dataflow.py +0 -0
  82. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m15_review.py +0 -0
  83. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m17_mcp.py +0 -0
  84. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m18_freshness.py +0 -0
  85. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m1_5_semantics.py +0 -0
  86. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m2_views.py +0 -0
  87. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m32_watch.py +0 -0
  88. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m3_serve.py +0 -0
  89. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m4_behavior.py +0 -0
  90. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m5_deep.py +0 -0
  91. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m6_repo_scope.py +0 -0
  92. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m7_dispatch.py +0 -0
  93. {codmap-0.0.18 → codmap-0.0.20}/tests/test_m9_family.py +0 -0
  94. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  95. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c13_grep_vs_graph.py +0 -0
  96. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c16_semantic.py +0 -0
  97. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c20_attribute_edges.py +0 -0
  98. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c21_flat_layout.py +0 -0
  99. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c22_source_visible_refs.py +0 -0
  100. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c23_hard_python.py +0 -0
  101. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c24_test_mapping.py +0 -0
  102. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c25_provenance.py +0 -0
  103. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c26_deep_union.py +0 -0
  104. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c27_config_honesty.py +0 -0
  105. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c28_limit_envelope.py +0 -0
  106. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c2_ctags.py +0 -0
  107. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c30_local_import_calls.py +0 -0
  108. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c30f1_reexport_calls.py +0 -0
  109. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c30f2_check_scope.py +0 -0
  110. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c31_path_origin.py +0 -0
  111. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c32_report_json.py +0 -0
  112. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c33_query_signature.py +0 -0
  113. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c34_signature_kinds.py +0 -0
  114. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c35_contract_location.py +0 -0
  115. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c36_target_resolution.py +0 -0
  116. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c37_rule_mutation.py +0 -0
  117. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c38_tool_drift.py +0 -0
  118. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c39_edge_resolution.py +0 -0
  119. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c3_arch_contract.py +0 -0
  120. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c3_dogfood.py +0 -0
  121. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c40_flow_reach.py +0 -0
  122. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c41_scope_membership.py +0 -0
  123. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c42_deep_tier_stability.py +0 -0
  124. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c43_incremental_splice.py +0 -0
  125. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c44_absent_answers.py +0 -0
  126. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c45_repeat_union.py +0 -0
  127. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c46_shadowed_definitions.py +0 -0
  128. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c47_incremental_resample.py +0 -0
  129. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c4_complexity.py +0 -0
  130. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c50_flow_entry_points.py +0 -0
  131. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c53_narrowing_declared.py +0 -0
  132. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c54_deterministic_rendering.py +0 -0
  133. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c5_apidiff.py +0 -0
  134. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c6_pack.py +0 -0
  135. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c7_edge_vocab.py +0 -0
  136. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c8_deadcode.py +0 -0
  137. {codmap-0.0.18 → codmap-0.0.20}/tests/test_r1c9_incremental.py +0 -0
  138. {codmap-0.0.18 → codmap-0.0.20}/tests/test_scip_export.py +0 -0
  139. {codmap-0.0.18 → codmap-0.0.20}/tests/test_scope.py +0 -0
  140. {codmap-0.0.18 → codmap-0.0.20}/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.20
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, **972 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, **972 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 &
@@ -133,6 +133,20 @@ def _core_layer_edges(query) -> list[tuple[str, str, str, str]]:
133
133
  return out
134
134
 
135
135
 
136
+
137
+ def _tangle_line(tangle: dict) -> str:
138
+ """One gate line per tangle: the example cycle, then the members (R1-C58).
139
+
140
+ The example comes first because it is what a reader acts on; the membership follows
141
+ because the tangle, not the cycle, is what has to be broken.
142
+ """
143
+ ex = tangle["example"]
144
+ head = " → ".join(ex) + " → " + ex[0]
145
+ if tangle["size"] <= len(ex):
146
+ return head
147
+ return f"{head} (tangle of {tangle['size']}: {', '.join(tangle['modules'])})"
148
+
149
+
136
150
  def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
137
151
  """Evaluate every rule against the graph; return the violations (empty = clean)."""
138
152
  if contract.is_empty():
@@ -192,13 +206,16 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
192
206
  # migrated into the gate. So: the gate stays eager, the *disclosure* is mandatory (see
193
207
  # `build_check`), and a contract that wants the coupling gated says so.
194
208
  if contract.no_cycles:
195
- cycles = query.import_cycles()
196
- if cycles:
197
- worst = sorted(cycles, key=lambda c: (len(c), c))
209
+ tangles = query.import_tangles()
210
+ if tangles:
211
+ # R1-C58: one violation per **tangle**, not per simple cycle. A tangle of 19
212
+ # modules used to produce 1080 violations that were all the same problem, and
213
+ # the gate's own output then took a thousand lines to say it once.
198
214
  violations.append(Violation(
199
215
  "no_cycles",
200
- f"{len(cycles)} import cycle(s)",
201
- modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
216
+ f"{len(tangles)} import tangle(s), "
217
+ f"{sum(t['size'] for t in tangles)} module(s)",
218
+ modules=tuple(_tangle_line(t) for t in tangles),
202
219
  ))
203
220
 
204
221
  # -- no_lazy_cycles: opt in to gating the coupling a lazy import hides -------
@@ -206,13 +223,13 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
206
223
  # not a gate, and a lazy import is the accepted way to break an import cycle — so this
207
224
  # is the contract owner's call to state, not a default to pick on their behalf.
208
225
  if contract.no_lazy_cycles:
209
- lazy = query.lazy_import_cycles()
226
+ lazy = query.lazy_import_tangles()
210
227
  if lazy:
211
- worst = sorted(lazy, key=lambda c: (len(c), c))
212
228
  violations.append(Violation(
213
229
  "no_lazy_cycles",
214
- f"{len(lazy)} dependency cycle(s) closed only by a function-local import",
215
- modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
230
+ f"{len(lazy)} tangle(s) closed only by a function-local import, "
231
+ f"{sum(t['size'] for t in lazy)} module(s)",
232
+ modules=tuple(_tangle_line(t) for t in lazy),
216
233
  ))
217
234
 
218
235
  # -- no_type_only_cycles: the third kind, and the one with no runtime dependency ----
@@ -223,14 +240,14 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
223
240
  # against lazy imports used as a way around `no_cycles`, and the standard typing
224
241
  # idiom is not that.
225
242
  if contract.no_type_only_cycles:
226
- type_only = query.type_only_import_cycles()
243
+ type_only = query.type_only_import_tangles()
227
244
  if type_only:
228
- worst = sorted(type_only, key=lambda c: (len(c), c))
229
245
  violations.append(Violation(
230
246
  "no_type_only_cycles",
231
- f"{len(type_only)} dependency cycle(s) closed only by an import under "
232
- f"`if TYPE_CHECKING:`",
233
- modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
247
+ f"{len(type_only)} tangle(s) closed only by an import that never runs "
248
+ f"(`if TYPE_CHECKING:` or a `.pyi`), "
249
+ f"{sum(t['size'] for t in type_only)} module(s)",
250
+ modules=tuple(_tangle_line(t) for t in type_only),
234
251
  ))
235
252
 
236
253
  # -- exhaustive: every core module's layer must be declared -----------------
@@ -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:
@@ -1079,16 +1129,103 @@ class Query:
1079
1129
 
1080
1130
  # -- graph-wide ----------------------------------------------------------
1081
1131
 
1132
+ def _tangle_sets(self, g) -> set[frozenset]:
1133
+ """Strongly connected components of ``g`` that are cycles (R1-C58).
1134
+
1135
+ A tangle — not a cycle — is the unit a reader acts on: *these N modules cannot be
1136
+ separated*. The number of **simple cycles** through them is combinatorial and says
1137
+ nothing more; on pytest's `_pytest` it was 1080 / 95 001 / 464 109 for one tangle
1138
+ of 78 modules, and enumerating them cost 10 s of a report that printed twenty.
1139
+ """
1140
+ out = {frozenset(c) for c in nx.strongly_connected_components(g) if len(c) > 1}
1141
+ out |= {frozenset({n}) for n in g.nodes if g.has_edge(n, n)}
1142
+ return out
1143
+
1144
+ def _example_cycle(self, g, modules: frozenset,
1145
+ via: set | None = None) -> tuple[list[str], tuple | None]:
1146
+ """One cycle through ``modules``, chosen deterministically (R1-C58).
1147
+
1148
+ Deliberately **not** promised to be the shortest — promising it would buy a
1149
+ narrowing we would then have to declare. It is an example, and it is chosen to
1150
+ pass through an edge of the class that *defines* the tangle (``via``): a lazy
1151
+ tangle must show a function-local edge, or the example reads like an eager cycle
1152
+ in the section about lazy ones. Returns the cycle and that edge, so the answer can
1153
+ name *which* import is the one holding the tangle together.
1154
+ """
1155
+ sub = g.subgraph(modules)
1156
+ chosen = sorted(e for e in sub.edges if via is None or e in via)
1157
+ edges = chosen or sorted(sub.edges)
1158
+ if not edges:
1159
+ return sorted(modules), None # a self-loop: the module is the whole cycle
1160
+ src, dst = edges[0]
1161
+ edge = (src, dst) if chosen else None
1162
+ if src == dst:
1163
+ return [src], edge
1164
+ try:
1165
+ path = nx.shortest_path(sub, dst, src)
1166
+ except nx.NetworkXNoPath: # cannot happen inside an SCC; not asserted
1167
+ return sorted(modules), edge
1168
+ return _canonical_cycles([path])[0], edge
1169
+
1170
+ def _tangles(self, g, weaker: set[frozenset], via: set | None = None) -> list[dict]:
1171
+ """Tangles of ``g`` that are not already reported in a stronger class."""
1172
+ out = []
1173
+ for tangle in self._tangle_sets(g) - weaker:
1174
+ example, edge = self._example_cycle(g, tangle, via)
1175
+ sub = g.subgraph(tangle)
1176
+ row = {"modules": sorted(tangle), "size": len(tangle),
1177
+ # R1-C58: the cycle rank (first Betti number) of the tangle — how many
1178
+ # **independent** loops it holds. This is the part of the old count that
1179
+ # was worth keeping: two loops sharing one module are two problems, and
1180
+ # collapsing them to "one tangle" would hide that. Unlike the number of
1181
+ # simple cycles it is linear to compute and does not explode: the 19-module
1182
+ # tangle in pytest has 1080 simple cycles and 31 independent loops.
1183
+ "loops": sub.number_of_edges() - len(tangle) + 1,
1184
+ "example": example}
1185
+ if edge:
1186
+ row["closed_by"] = list(edge)
1187
+ out.append(row)
1188
+ return sorted(out, key=lambda r: (r["size"], r["modules"]))
1189
+
1190
+ def import_tangles(self) -> list[dict]:
1191
+ """Mutually-dependent module groups in the **eager** import graph (R1-C58).
1192
+
1193
+ Each entry is ``{modules, size, example}``: the group that cannot be separated,
1194
+ and one cycle through it. The count of simple cycles is not reported — see
1195
+ :meth:`_tangle_sets`.
1196
+ """
1197
+ return self._tangles(self._imports_eager, set())
1198
+
1199
+ def lazy_import_tangles(self) -> list[dict]:
1200
+ """Tangles that exist only once function-local imports are counted."""
1201
+ return self._tangles(self._imports_runtime,
1202
+ self._tangle_sets(self._imports_eager),
1203
+ via={(e.source, e.target) for e in self.graph.edges
1204
+ if e.type == "imports"
1205
+ and e.extras.get("scope") == "function"})
1206
+
1207
+ def type_only_import_tangles(self) -> list[dict]:
1208
+ """Tangles that exist only once imports that never execute are counted."""
1209
+ return self._tangles(self._imports, self._tangle_sets(self._imports_runtime),
1210
+ via={(e.source, e.target) for e in self.graph.edges
1211
+ if e.type == "imports"
1212
+ and e.extras.get("scope") in ("type_checking", "stub")})
1213
+
1082
1214
  def import_cycles(self) -> list[list[str]]:
1083
- """Cycles that exist in the **eager** import graph — the import-order landmines.
1215
+ """One example cycle per **eager** tangle — the import-order landmines.
1084
1216
 
1085
1217
  R1-C29: deliberately *not* computed over every import edge. A function-local
1086
1218
  import does not run at import time, so a cycle closed only by one does not break
1087
1219
  on import — it is what a developer writes to stop it breaking. Counting it here
1088
1220
  would report someone's fix as their bug. Those cycles are still real coupling
1089
1221
  and are returned by :meth:`lazy_import_cycles`.
1222
+
1223
+ R1-C58 changed what one entry **means**: it used to be every simple cycle, which
1224
+ is a combinatorial quantity (1080 entries for one tangle of 78 modules), and is
1225
+ now one representative per tangle. :meth:`import_tangles` carries the group each
1226
+ example stands for.
1090
1227
  """
1091
- return _canonical_cycles(nx.simple_cycles(self._imports_eager))
1228
+ return _canonical_cycles(t["example"] for t in self.import_tangles())
1092
1229
 
1093
1230
  def lazy_import_cycles(self) -> list[list[str]]:
1094
1231
  """Dependency cycles that close **only** through a function-local import.
@@ -1105,22 +1242,24 @@ class Query:
1105
1242
  function-local import stays *lazy* however many type imports also run between
1106
1243
  them, or a tree could hide real coupling by adding one.
1107
1244
  """
1108
- eager = {frozenset(c) for c in nx.simple_cycles(self._imports_eager)}
1109
- return _canonical_cycles(c for c in nx.simple_cycles(self._imports_runtime)
1110
- if frozenset(c) not in eager)
1245
+ return _canonical_cycles(t["example"] for t in self.lazy_import_tangles())
1111
1246
 
1112
1247
  def type_only_import_cycles(self) -> list[list[str]]:
1113
- """Dependency cycles that close **only** with an import under ``if TYPE_CHECKING:``.
1248
+ """Dependency cycles that close **only** with an import that never executes.
1114
1249
 
1115
1250
  The third kind (R1-C49, issue #18). These modules name each other's types and have
1116
1251
  **no runtime dependency whatever**: neither import pulls the other at any moment of
1117
1252
  execution. That is why they are not gated by ``no_lazy_cycles`` — which exists
1118
1253
  against a lazy import used to walk *around* ``no_cycles`` — and get their own
1119
1254
  opt-in rule instead.
1255
+
1256
+ Two mechanisms, one consequence (R1-C56/D2): an import under ``if TYPE_CHECKING:``
1257
+ and an import written in a ``.pyi``, which Python does not execute at all. The
1258
+ class partitions by consequence — what a reader needs is whether the import can
1259
+ break — while the edge keeps the mechanism in ``extras.scope``. Measured on
1260
+ Pillow, whose *only* "hard" cycle was a stub declaring the module that imports it.
1120
1261
  """
1121
- runtime = {frozenset(c) for c in nx.simple_cycles(self._imports_runtime)}
1122
- return _canonical_cycles(c for c in nx.simple_cycles(self._imports)
1123
- if frozenset(c) not in runtime)
1262
+ return _canonical_cycles(t["example"] for t in self.type_only_import_tangles())
1124
1263
 
1125
1264
  def import_map(self) -> dict:
1126
1265
  """How much of the import graph each scope contributed (R1-C29).
@@ -1132,7 +1271,8 @@ class Query:
1132
1271
  """
1133
1272
  return {"module_level": self._import_scopes["module"],
1134
1273
  "function_local": self._import_scopes["function"],
1135
- "type_checking": self._import_scopes["type_checking"]}
1274
+ "type_checking": self._import_scopes["type_checking"],
1275
+ "stub": self._import_scopes["stub"]}
1136
1276
 
1137
1277
  def orphan_modules(self, root: str | None = None) -> list[str]:
1138
1278
  """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