codmap 0.0.19__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.19 → codmap-0.0.20}/PKG-INFO +2 -2
  2. {codmap-0.0.19 → codmap-0.0.20}/README.md +1 -1
  3. {codmap-0.0.19 → codmap-0.0.20}/codemap/arch.py +31 -14
  4. {codmap-0.0.19 → codmap-0.0.20}/codemap/query.py +91 -8
  5. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/architecture.py +65 -17
  6. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/audit.py +12 -8
  7. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/check.py +2 -2
  8. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/livingdocs.py +8 -7
  9. {codmap-0.0.19 → codmap-0.0.20}/codmap.egg-info/PKG-INFO +2 -2
  10. {codmap-0.0.19 → codmap-0.0.20}/codmap.egg-info/SOURCES.txt +1 -0
  11. {codmap-0.0.19 → codmap-0.0.20}/pyproject.toml +1 -1
  12. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m16_architecture.py +4 -1
  13. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c29_lazy_imports.py +20 -9
  14. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c48_type_checking_imports.py +1 -1
  15. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c49_type_only_cycles.py +1 -1
  16. codmap-0.0.20/tests/test_r1c58_cycle_tangles.py +151 -0
  17. {codmap-0.0.19 → codmap-0.0.20}/LICENSE +0 -0
  18. {codmap-0.0.19 → codmap-0.0.20}/codemap/__init__.py +0 -0
  19. {codmap-0.0.19 → codmap-0.0.20}/codemap/apidiff.py +0 -0
  20. {codmap-0.0.19 → codmap-0.0.20}/codemap/cli.py +0 -0
  21. {codmap-0.0.19 → codmap-0.0.20}/codemap/diagnostics.py +0 -0
  22. {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/__init__.py +0 -0
  23. {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/attrflow.py +0 -0
  24. {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/behavior.py +0 -0
  25. {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/dataflow.py +0 -0
  26. {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/dispatch.py +0 -0
  27. {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/griffe_extractor.py +0 -0
  28. {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/gsource.py +0 -0
  29. {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/roots.py +0 -0
  30. {codmap-0.0.19 → codmap-0.0.20}/codemap/extract/union.py +0 -0
  31. {codmap-0.0.19 → codmap-0.0.20}/codemap/freshness.py +0 -0
  32. {codmap-0.0.19 → codmap-0.0.20}/codemap/incremental.py +0 -0
  33. {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/__init__.py +0 -0
  34. {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/base.py +0 -0
  35. {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/cocoindex.py +0 -0
  36. {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/gate.py +0 -0
  37. {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/gitnexus.py +0 -0
  38. {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/registry.py +0 -0
  39. {codmap-0.0.19 → codmap-0.0.20}/codemap/integrations/transport.py +0 -0
  40. {codmap-0.0.19 → codmap-0.0.20}/codemap/model.py +0 -0
  41. {codmap-0.0.19 → codmap-0.0.20}/codemap/provenance.py +0 -0
  42. {codmap-0.0.19 → codmap-0.0.20}/codemap/scope.py +0 -0
  43. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/__init__.py +0 -0
  44. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/_scip_pb2.py +0 -0
  45. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/api_surface.py +0 -0
  46. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/apidiff.py +0 -0
  47. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/ctags.py +0 -0
  48. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/impact.py +0 -0
  49. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/limits.py +0 -0
  50. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/mcp_server.py +0 -0
  51. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/mermaid.py +0 -0
  52. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/pack.py +0 -0
  53. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/rag.py +0 -0
  54. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/review.py +0 -0
  55. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/scip.py +0 -0
  56. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/semantic.py +0 -0
  57. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/server.py +0 -0
  58. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/session.py +0 -0
  59. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/subsystems.py +0 -0
  60. {codmap-0.0.19 → codmap-0.0.20}/codemap/serve/vault.py +0 -0
  61. {codmap-0.0.19 → codmap-0.0.20}/codemap/store.py +0 -0
  62. {codmap-0.0.19 → codmap-0.0.20}/codemap/tomlio.py +0 -0
  63. {codmap-0.0.19 → codmap-0.0.20}/codemap/watch.py +0 -0
  64. {codmap-0.0.19 → codmap-0.0.20}/codmap.egg-info/dependency_links.txt +0 -0
  65. {codmap-0.0.19 → codmap-0.0.20}/codmap.egg-info/entry_points.txt +0 -0
  66. {codmap-0.0.19 → codmap-0.0.20}/codmap.egg-info/requires.txt +0 -0
  67. {codmap-0.0.19 → codmap-0.0.20}/codmap.egg-info/top_level.txt +0 -0
  68. {codmap-0.0.19 → codmap-0.0.20}/setup.cfg +0 -0
  69. {codmap-0.0.19 → codmap-0.0.20}/tests/test_epistemic.py +0 -0
  70. {codmap-0.0.19 → codmap-0.0.20}/tests/test_gitnexus_router.py +0 -0
  71. {codmap-0.0.19 → codmap-0.0.20}/tests/test_impact_depth.py +0 -0
  72. {codmap-0.0.19 → codmap-0.0.20}/tests/test_integrations.py +0 -0
  73. {codmap-0.0.19 → codmap-0.0.20}/tests/test_issue3_serve_freshness.py +0 -0
  74. {codmap-0.0.19 → codmap-0.0.20}/tests/test_livingdocs.py +0 -0
  75. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m0_api_surface.py +0 -0
  76. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m11_argcontract.py +0 -0
  77. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m12_dataflow.py +0 -0
  78. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m15_review.py +0 -0
  79. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m17_mcp.py +0 -0
  80. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m18_freshness.py +0 -0
  81. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m1_5_semantics.py +0 -0
  82. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m1_query.py +0 -0
  83. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m2_views.py +0 -0
  84. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m32_watch.py +0 -0
  85. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m3_serve.py +0 -0
  86. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m4_behavior.py +0 -0
  87. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m5_deep.py +0 -0
  88. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m6_repo_scope.py +0 -0
  89. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m7_dispatch.py +0 -0
  90. {codmap-0.0.19 → codmap-0.0.20}/tests/test_m9_family.py +0 -0
  91. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  92. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c13_grep_vs_graph.py +0 -0
  93. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c16_semantic.py +0 -0
  94. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c20_attribute_edges.py +0 -0
  95. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c21_flat_layout.py +0 -0
  96. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c22_source_visible_refs.py +0 -0
  97. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c23_hard_python.py +0 -0
  98. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c24_test_mapping.py +0 -0
  99. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c25_provenance.py +0 -0
  100. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c26_deep_union.py +0 -0
  101. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c27_config_honesty.py +0 -0
  102. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c28_limit_envelope.py +0 -0
  103. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c2_ctags.py +0 -0
  104. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c30_local_import_calls.py +0 -0
  105. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c30f1_reexport_calls.py +0 -0
  106. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c30f2_check_scope.py +0 -0
  107. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c31_path_origin.py +0 -0
  108. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c32_report_json.py +0 -0
  109. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c33_query_signature.py +0 -0
  110. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c34_signature_kinds.py +0 -0
  111. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c35_contract_location.py +0 -0
  112. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c36_target_resolution.py +0 -0
  113. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c37_rule_mutation.py +0 -0
  114. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c38_tool_drift.py +0 -0
  115. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c39_edge_resolution.py +0 -0
  116. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c3_arch_contract.py +0 -0
  117. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c3_dogfood.py +0 -0
  118. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c40_flow_reach.py +0 -0
  119. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c41_scope_membership.py +0 -0
  120. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c42_deep_tier_stability.py +0 -0
  121. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c43_incremental_splice.py +0 -0
  122. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c44_absent_answers.py +0 -0
  123. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c45_repeat_union.py +0 -0
  124. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c46_shadowed_definitions.py +0 -0
  125. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c47_incremental_resample.py +0 -0
  126. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c4_complexity.py +0 -0
  127. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c50_flow_entry_points.py +0 -0
  128. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c53_narrowing_declared.py +0 -0
  129. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c54_deterministic_rendering.py +0 -0
  130. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c55_override_grade.py +0 -0
  131. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c56_stub_files.py +0 -0
  132. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c57_facade_package.py +0 -0
  133. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c5_apidiff.py +0 -0
  134. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c6_pack.py +0 -0
  135. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c7_edge_vocab.py +0 -0
  136. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c8_deadcode.py +0 -0
  137. {codmap-0.0.19 → codmap-0.0.20}/tests/test_r1c9_incremental.py +0 -0
  138. {codmap-0.0.19 → codmap-0.0.20}/tests/test_scip_export.py +0 -0
  139. {codmap-0.0.19 → codmap-0.0.20}/tests/test_scope.py +0 -0
  140. {codmap-0.0.19 → 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.19
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, **961 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, **961 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 -----------------
@@ -1129,16 +1129,103 @@ class Query:
1129
1129
 
1130
1130
  # -- graph-wide ----------------------------------------------------------
1131
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
+
1132
1214
  def import_cycles(self) -> list[list[str]]:
1133
- """Cycles that exist in the **eager** import graph — the import-order landmines.
1215
+ """One example cycle per **eager** tangle — the import-order landmines.
1134
1216
 
1135
1217
  R1-C29: deliberately *not* computed over every import edge. A function-local
1136
1218
  import does not run at import time, so a cycle closed only by one does not break
1137
1219
  on import — it is what a developer writes to stop it breaking. Counting it here
1138
1220
  would report someone's fix as their bug. Those cycles are still real coupling
1139
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.
1140
1227
  """
1141
- return _canonical_cycles(nx.simple_cycles(self._imports_eager))
1228
+ return _canonical_cycles(t["example"] for t in self.import_tangles())
1142
1229
 
1143
1230
  def lazy_import_cycles(self) -> list[list[str]]:
1144
1231
  """Dependency cycles that close **only** through a function-local import.
@@ -1155,9 +1242,7 @@ class Query:
1155
1242
  function-local import stays *lazy* however many type imports also run between
1156
1243
  them, or a tree could hide real coupling by adding one.
1157
1244
  """
1158
- eager = {frozenset(c) for c in nx.simple_cycles(self._imports_eager)}
1159
- return _canonical_cycles(c for c in nx.simple_cycles(self._imports_runtime)
1160
- if frozenset(c) not in eager)
1245
+ return _canonical_cycles(t["example"] for t in self.lazy_import_tangles())
1161
1246
 
1162
1247
  def type_only_import_cycles(self) -> list[list[str]]:
1163
1248
  """Dependency cycles that close **only** with an import that never executes.
@@ -1174,9 +1259,7 @@ class Query:
1174
1259
  break — while the edge keeps the mechanism in ``extras.scope``. Measured on
1175
1260
  Pillow, whose *only* "hard" cycle was a stub declaring the module that imports it.
1176
1261
  """
1177
- runtime = {frozenset(c) for c in nx.simple_cycles(self._imports_runtime)}
1178
- return _canonical_cycles(c for c in nx.simple_cycles(self._imports)
1179
- if frozenset(c) not in runtime)
1262
+ return _canonical_cycles(t["example"] for t in self.type_only_import_tangles())
1180
1263
 
1181
1264
  def import_map(self) -> dict:
1182
1265
  """How much of the import graph each scope contributed (R1-C29).
@@ -15,6 +15,36 @@ from codemap.diagnostics import render_lines
15
15
  from codemap.query import Query
16
16
 
17
17
 
18
+ def _tangle_head(tangles: list[dict]) -> str:
19
+ """`N tangle(s) (M modules)` — the count that is stable under adding an edge.
20
+
21
+ R1-C58: the previous head counted **simple cycles**, which is combinatorial. A tangle
22
+ of three mutually-dependent modules has one head here and produced five entries there;
23
+ pytest's `_pytest` produced 1080, 95 001 and 464 109 for one tangle each.
24
+ """
25
+ if not tangles:
26
+ return "0"
27
+ return (f"{len(tangles)} tangle(s), {sum(t['size'] for t in tangles)} module(s), "
28
+ f"{sum(t['loops'] for t in tangles)} independent loop(s)")
29
+
30
+
31
+ def _tangle_lines(tangles: list[dict]) -> list[str]:
32
+ """One block per tangle: its members, and one cycle through it as an example."""
33
+ out: list[str] = []
34
+ for tg in tangles:
35
+ if tg["size"] == 1:
36
+ out.append(f"- `{tg['modules'][0]}` — imports itself")
37
+ continue
38
+ out.append(f"- **{tg['size']} modules, {tg['loops']} independent loop(s):** "
39
+ + ", ".join(f"`{m}`" for m in tg["modules"]))
40
+ ex = tg["example"]
41
+ line = f" - e.g. {' → '.join(ex)} → {ex[0]}"
42
+ if tg.get("closed_by"):
43
+ line += f" — held together by `{tg['closed_by'][0]}` → `{tg['closed_by'][1]}`"
44
+ out.append(line)
45
+ return out
46
+
47
+
18
48
  def build_architecture(query: Query) -> dict:
19
49
  """Structured whole-system overview (cycles + layers + coupling + hotspots).
20
50
 
@@ -30,9 +60,16 @@ def build_architecture(query: Query) -> dict:
30
60
  """
31
61
  return {
32
62
  "target": query.graph.target,
63
+ # R1-C58: the unit is the tangle (a strongly connected group of modules). The
64
+ # `*_cycles` keys stay, carrying **one example per tangle** — they used to carry
65
+ # every simple cycle, a combinatorial quantity that reached 464 109 entries for a
66
+ # single tangle of 78 modules and said nothing the tangle does not.
33
67
  "cycles": query.import_cycles(),
34
68
  "lazy_cycles": query.lazy_import_cycles(),
35
69
  "type_only_cycles": query.type_only_import_cycles(),
70
+ "tangles": query.import_tangles(),
71
+ "lazy_tangles": query.lazy_import_tangles(),
72
+ "type_only_tangles": query.type_only_import_tangles(),
36
73
  "import_map": query.import_map(),
37
74
  "layers": query.layers(),
38
75
  "coupling": query.coupling(),
@@ -54,15 +91,27 @@ def render_architecture(query: Query) -> str:
54
91
 
55
92
  # -- layers -------------------------------------------------------------
56
93
  lay = a["layers"]
94
+ # R1-C58/D4: a layer is the first path segment under the root. A package with no
95
+ # subpackages therefore has one layer per module, and the section reads as an
96
+ # architectural overview while saying only "this package is flat". Measured on
97
+ # Pillow: "Layers (105)" over 105 modules. Say it instead of implying structure.
98
+ degenerate = bool(lay["layers"]) and all(len(m) == 1 for m in lay["layers"].values())
57
99
  out.append(f"## Layers ({len(lay['layers'])})")
58
100
  out.append("")
101
+ if degenerate:
102
+ out.append("_This package has no subpackages, so **layer = module** here: the "
103
+ "grouping below is the module list, and the inter-layer view would "
104
+ "repeat the import graph edge for edge. Not a statement about "
105
+ "structure — a statement that there is none to report._")
106
+ out.append("")
59
107
  for name, mods in lay["layers"].items():
60
108
  out.append(f"- **{name}** — {len(mods)} module(s)")
61
109
  out.append("")
62
- out.append("### Inter-layer dependencies")
63
- out.append("")
64
- out.extend([f"- {edge} ({n})" for edge, n in lay["edges"].items()] or ["_none._"])
65
- out.append("")
110
+ if not degenerate:
111
+ out.append("### Inter-layer dependencies")
112
+ out.append("")
113
+ out.extend([f"- {edge} ({n})" for edge, n in lay["edges"].items()] or ["_none._"])
114
+ out.append("")
66
115
  if lay["violations"]:
67
116
  out.append("### ⚠ Layer violations (mutual dependency)")
68
117
  out.append("")
@@ -75,10 +124,15 @@ def render_architecture(query: Query) -> str:
75
124
  # R1-C29: never state acyclicity as a property. The map is only as complete as the
76
125
  # imports it read, and the reader cannot see which those were unless we say so.
77
126
  im = a["import_map"]
78
- out.append(f"## Import cycles: {len(a['cycles'])}")
127
+ out.append(f"## Import cycles: {_tangle_head(a['tangles'])}")
128
+ out.append("")
129
+ out.append("_A **tangle** is a group of modules that cannot be separated — the unit you "
130
+ "would act on — and its **independent loops** are how many distinct ways it "
131
+ "closes (the cycle rank). The number of *simple* cycles is combinatorial (one "
132
+ "19-module tangle of a real package has 1080, and its 78-module tangle has "
133
+ "464 109) and is deliberately not reported; one example cycle per tangle is._")
79
134
  out.append("")
80
- out.extend([f"- {' → '.join(c)} → {c[0]}" for c in
81
- sorted(a["cycles"], key=lambda c: (len(c), c))]
135
+ out.extend(_tangle_lines(a["tangles"])
82
136
  or ["_none found in the eager import graph._"])
83
137
  out.append("")
84
138
  out.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
@@ -89,33 +143,27 @@ def render_architecture(query: Query) -> str:
89
143
  out.append("")
90
144
  if a["lazy_cycles"]:
91
145
  out.append(f"### Dependency cycles closed only by a function-local import: "
92
- f"{len(a['lazy_cycles'])}")
146
+ f"{_tangle_head(a['lazy_tangles'])}")
93
147
  out.append("")
94
148
  out.append("_These do **not** break at import time — the lazy import is what "
95
149
  "prevents that, and is usually deliberate. They are listed because "
96
150
  "the modules are still mutually dependent at run time: neither can be "
97
151
  "extracted without the other._")
98
152
  out.append("")
99
- out.extend(f"- {' → '.join(c)} → {c[0]}" for c in
100
- sorted(a["lazy_cycles"], key=lambda c: (len(c), c))[:20])
101
- if len(a["lazy_cycles"]) > 20:
102
- out.append(f"- _… {len(a['lazy_cycles']) - 20} more_")
153
+ out.extend(_tangle_lines(a["lazy_tangles"]))
103
154
  out.append("")
104
155
  if a["type_only_cycles"]:
105
156
  # R1-C49: the third kind, kept apart from the second because the difference is the
106
157
  # whole point — these modules have no runtime dependency on each other at all.
107
158
  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'])}")
159
+ f"(`if TYPE_CHECKING:` or a `.pyi`): {_tangle_head(a['type_only_tangles'])}")
109
160
  out.append("")
110
161
  out.append("_Neither module pulls the other at any moment of execution — they name "
111
162
  "each other's types. Not an import-time failure and not runtime coupling; "
112
163
  "`no_type_only_cycles = true` gates them if the type layer must not close "
113
164
  "a cycle either._")
114
165
  out.append("")
115
- out.extend(f"- {' → '.join(c)} → {c[0]}" for c in
116
- sorted(a["type_only_cycles"], key=lambda c: (len(c), c))[:20])
117
- if len(a["type_only_cycles"]) > 20:
118
- out.append(f"- _… {len(a['type_only_cycles']) - 20} more_")
166
+ out.extend(_tangle_lines(a["type_only_tangles"]))
119
167
  out.append("")
120
168
 
121
169
  # -- coupling -----------------------------------------------------------
@@ -17,15 +17,19 @@ def render_dependencies(query: Query) -> str:
17
17
  # it; each check supplies its own consequence, or none (issue #8).
18
18
  lines.extend(render_lines(query.graph))
19
19
 
20
- cycles = query.import_cycles()
21
- lazy = query.lazy_import_cycles()
22
- type_only = query.type_only_import_cycles()
20
+ tangles = query.import_tangles()
21
+ lazy = query.lazy_import_tangles()
22
+ type_only = query.type_only_import_tangles()
23
23
  im = query.import_map()
24
- lines.append(f"## Import cycles: {len(cycles)}")
24
+ # R1-C58: tangles, not simple cycles — the count of the latter is combinatorial.
25
+ lines.append(f"## Import cycles: {len(tangles)} tangle(s)"
26
+ + (f", {sum(t['size'] for t in tangles)} module(s)" if tangles else ""))
25
27
  lines.append("")
26
- if cycles:
27
- for cyc in sorted(cycles, key=lambda c: (len(c), c)):
28
- lines.append(f"- {' → '.join(cyc)} → {cyc[0]}")
28
+ if tangles:
29
+ for tg in tangles:
30
+ ex = tg["example"]
31
+ lines.append(f"- **{tg['size']} modules** — e.g. "
32
+ f"{' → '.join(ex)} → {ex[0]}")
29
33
  else:
30
34
  # R1-C29: "none found" is what was measured; "acyclic" is a property, and the
31
35
  # map that would have to be complete to support it demonstrably is not.
@@ -34,7 +38,7 @@ def render_dependencies(query: Query) -> str:
34
38
  lines.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
35
39
  f"function-local, {im['type_checking']} `TYPE_CHECKING` and {im['stub']} "
36
40
  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 "
41
+ f"tangle(s) close through a function-local import (runtime coupling, not an "
38
42
  f"import-time failure) and {len(type_only)} through an import that never "
39
43
  f"runs — `if TYPE_CHECKING:` or a `.pyi` (no runtime dependency at all)._")
40
44
  lines.append("")
@@ -34,8 +34,8 @@ def _not_judged(query, contract: ArchitectureContract) -> list[dict]:
34
34
  # R1-C49: two kinds may be left out, and they are different things. Whichever of them
35
35
  # this contract does not gate is what the reader must be told about — reporting one
36
36
  # sum over both is the defect the third consumer filed.
37
- lazy = [] if contract.no_lazy_cycles else query.lazy_import_cycles()
38
- type_only = [] if contract.no_type_only_cycles else query.type_only_import_cycles()
37
+ lazy = [] if contract.no_lazy_cycles else query.lazy_import_tangles()
38
+ type_only = [] if contract.no_type_only_cycles else query.type_only_import_tangles()
39
39
  return [{
40
40
  "rule": "no_cycles",
41
41
  "judged": "the eager import graph — imports that run at import time",
@@ -142,25 +142,26 @@ def render_docs(query: Query) -> str:
142
142
  out.append("")
143
143
 
144
144
  # -- architecture caveats (the honest health section) -------------------
145
- cycles = query.import_cycles()
146
- lazy = query.lazy_import_cycles()
147
- type_only = query.type_only_import_cycles()
145
+ cycles = query.import_tangles()
146
+ lazy = query.lazy_import_tangles()
147
+ type_only = query.type_only_import_tangles()
148
148
  lay = query.layers()
149
149
  gods = query.hotspots()["god_classes"]
150
150
  out.append("## Architecture notes")
151
151
  out.append("")
152
152
  if cycles:
153
- out.append(f"- **{len(cycles)} import cycle(s)** — e.g. "
154
- + "; ".join(" → ".join(c) for c in sorted(cycles, key=len)[:3]))
153
+ out.append(f"- **{len(cycles)} import tangle(s)**, {sum(c['size'] for c in cycles)} "
154
+ f"module(s) that cannot be separated — e.g. "
155
+ + "; ".join(" → ".join(c["example"]) for c in cycles[:3]))
155
156
  else:
156
157
  # R1-C29: no cycle *found*, over the imports that run at import time — not a
157
158
  # proof of acyclicity, which this map cannot support.
158
159
  out.append("- No import cycle found in the eager import graph.")
159
160
  if lazy:
160
- out.append(f"- **{len(lazy)} dependency cycle(s) closed only by a function-local "
161
+ out.append(f"- **{len(lazy)} tangle(s) closed only by a function-local "
161
162
  f"import** — deliberate, and still runtime coupling.")
162
163
  if type_only:
163
- out.append(f"- **{len(type_only)} dependency cycle(s) closed only by an import that "
164
+ out.append(f"- **{len(type_only)} tangle(s) closed only by an import that "
164
165
  f"never runs** (`if TYPE_CHECKING:` or a `.pyi`) — the modules name each "
165
166
  f"other's types and have no runtime dependency.")
166
167
  if lay["violations"]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.19
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, **961 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 &
@@ -127,6 +127,7 @@ tests/test_r1c54_deterministic_rendering.py
127
127
  tests/test_r1c55_override_grade.py
128
128
  tests/test_r1c56_stub_files.py
129
129
  tests/test_r1c57_facade_package.py
130
+ tests/test_r1c58_cycle_tangles.py
130
131
  tests/test_r1c5_apidiff.py
131
132
  tests/test_r1c6_pack.py
132
133
  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.19"
12
+ version = "0.0.20"
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
@@ -80,7 +80,10 @@ def test_build_architecture_has_all_sections(q):
80
80
  # imports each scope contributed). R1-C49 added `type_only_cycles`, the third kind —
81
81
  # closed only by an import under `if TYPE_CHECKING:`, i.e. no runtime dependency at all —
82
82
  # imports of each scope the map was built from, always, zero included).
83
- assert set(a) == {"target", "cycles", "lazy_cycles", "type_only_cycles", "import_map",
83
+ assert set(a) == {"target", "cycles", "lazy_cycles", "type_only_cycles",
84
+ # R1-C58: the tangle is the unit; the `*_cycles` keys stay and carry
85
+ # one example per tangle, because consumers parse them.
86
+ "tangles", "lazy_tangles", "type_only_tangles", "import_map",
84
87
  "layers", "coupling", "hotspots"}
85
88
  assert a["target"] == "dispatchpkg"
86
89
 
@@ -116,16 +116,23 @@ def test_an_eager_cycle_is_still_an_import_cycle(tmp_path):
116
116
  assert q.lazy_import_cycles() == [], "an eager cycle is not also a lazy one"
117
117
 
118
118
 
119
- def test_a_longer_cycle_whose_lazy_edge_closes_it_is_enumerated(tmp_path):
119
+ def test_two_loops_sharing_a_module_are_two_loops(tmp_path):
120
120
  """Three modules, the lazy import at the far end — the shape both audit scripts lost.
121
121
 
122
122
  The reporter of #11 verified the fix on their tree and found codemap reporting **three**
123
123
  cycles where their issue had claimed two. Their scan collected DFS back-edges instead
124
124
  of enumerating simple cycles, so a 3-node cycle was swallowed once its nodes were
125
125
  coloured; ours mis-anchored relative imports. Two independent scripts written to audit
126
- a tool, both less careful than the tool, on the same day. This test pins the property
127
- that made codemap right here: every elementary cycle, not one representative per
128
- strongly-connected blob.
126
+ a tool, both less careful than the tool, on the same day.
127
+
128
+ **R1-C58 reversed the decision this test used to pin** — "every elementary cycle, not
129
+ one representative per strongly-connected blob" — because enumerating them is
130
+ combinatorial: one 78-module group of a real package has 464 109 of them, and printing
131
+ twenty took ten seconds. What must survive the reversal is the property #11 was about:
132
+ *nothing is swallowed*. Two loops that share a module are still two, and every module
133
+ in a cycle is still named. That is now carried by the tangle's **cycle rank** (its
134
+ independent loops) and its membership, instead of by a list whose length is a
135
+ combinatorial artefact.
129
136
  """
130
137
  pkg = _pkg(tmp_path, {
131
138
  "a.py": "from pkg.b import beta\n\n\ndef alpha():\n return 1\n",
@@ -141,11 +148,15 @@ def test_a_longer_cycle_whose_lazy_edge_closes_it_is_enumerated(tmp_path):
141
148
  " from pkg.e import eps\n return eps()\n")
142
149
  q = Query(extract(str(pkg)))
143
150
  assert q.import_cycles() == [], "every cycle here is closed by a lazy import"
144
- found = {frozenset(c) for c in q.lazy_import_cycles()}
145
- assert frozenset({"pkg.a", "pkg.b", "pkg.c"}) in found
146
- assert frozenset({"pkg.a", "pkg.d", "pkg.e"}) in found, (
147
- "a second cycle sharing a node was swallowed — this is back-edge collection, "
148
- "not simple-cycle enumeration")
151
+ tangles = q.lazy_import_tangles()
152
+ assert len(tangles) == 1, "the two loops share pkg.a, so they are one inseparable group"
153
+ tangle = tangles[0]
154
+ assert tangle["modules"] == ["pkg.a", "pkg.b", "pkg.c", "pkg.d", "pkg.e"], (
155
+ "a module of a cycle was swallowed — the failure #11 was about")
156
+ assert tangle["loops"] == 2, (
157
+ "two loops sharing a module are two problems: breaking a → b does not free "
158
+ "a → d → e → a, and a single count of 'one tangle' would hide that")
159
+ assert len(q.lazy_import_cycles()) == 1, "one example cycle per tangle"
149
160
 
150
161
 
151
162
  def test_import_map_is_emitted_even_when_nothing_is_lazy(tmp_path):
@@ -176,7 +176,7 @@ def test_the_type_only_rule_gates_the_coupling(tree):
176
176
  assert arch.check_contract(tree[2], arch.ArchitectureContract(no_lazy_cycles=True)) == []
177
177
  v = arch.check_contract(tree[2], arch.ArchitectureContract(no_type_only_cycles=True))
178
178
  assert [x.rule for x in v] == ["no_type_only_cycles"]
179
- assert "under `if TYPE_CHECKING:`" in v[0].summary
179
+ assert "an import that never runs (`if TYPE_CHECKING:` or a `.pyi`)" in v[0].summary
180
180
 
181
181
 
182
182
  # -- the mutation ---------------------------------------------------------------------------
@@ -195,7 +195,7 @@ def test_the_reports_carry_both_kinds_and_say_zero(both, tmp_path):
195
195
  assert ("Dependency cycles closed only by an import that never runs "
196
196
  "(`if TYPE_CHECKING:` or a `.pyi`): 1") in md
197
197
  dep = render_dependencies(both)
198
- assert "1 further cycle(s) close through a function-local import" in dep
198
+ assert "1 further tangle(s) close through a function-local import" in dep
199
199
  assert "1 through an import that never runs" in dep
200
200
  assert _flat(build_dependencies(both)["type_only_import_cycles"]) == [["bp.typed_a", "bp.typed_b"]]
201
201
  docs = render_docs(both)