codmap 0.0.19__tar.gz → 0.0.21__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 (142) hide show
  1. {codmap-0.0.19 → codmap-0.0.21}/PKG-INFO +2 -2
  2. {codmap-0.0.19 → codmap-0.0.21}/README.md +1 -1
  3. {codmap-0.0.19 → codmap-0.0.21}/codemap/arch.py +89 -15
  4. {codmap-0.0.19 → codmap-0.0.21}/codemap/cli.py +26 -9
  5. {codmap-0.0.19 → codmap-0.0.21}/codemap/model.py +18 -0
  6. {codmap-0.0.19 → codmap-0.0.21}/codemap/query.py +91 -8
  7. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/architecture.py +65 -17
  8. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/audit.py +12 -8
  9. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/check.py +56 -8
  10. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/livingdocs.py +8 -7
  11. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/session.py +7 -2
  12. {codmap-0.0.19 → codmap-0.0.21}/codmap.egg-info/PKG-INFO +2 -2
  13. {codmap-0.0.19 → codmap-0.0.21}/codmap.egg-info/SOURCES.txt +3 -0
  14. {codmap-0.0.19 → codmap-0.0.21}/pyproject.toml +1 -1
  15. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m16_architecture.py +4 -1
  16. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c29_lazy_imports.py +20 -9
  17. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c48_type_checking_imports.py +1 -1
  18. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c49_type_only_cycles.py +1 -1
  19. codmap-0.0.21/tests/test_r1c58_cycle_tangles.py +151 -0
  20. codmap-0.0.21/tests/test_r1c62_phantom_rules.py +167 -0
  21. codmap-0.0.21/tests/test_r1c63_answer_format.py +148 -0
  22. {codmap-0.0.19 → codmap-0.0.21}/LICENSE +0 -0
  23. {codmap-0.0.19 → codmap-0.0.21}/codemap/__init__.py +0 -0
  24. {codmap-0.0.19 → codmap-0.0.21}/codemap/apidiff.py +0 -0
  25. {codmap-0.0.19 → codmap-0.0.21}/codemap/diagnostics.py +0 -0
  26. {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/__init__.py +0 -0
  27. {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/attrflow.py +0 -0
  28. {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/behavior.py +0 -0
  29. {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/dataflow.py +0 -0
  30. {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/dispatch.py +0 -0
  31. {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/griffe_extractor.py +0 -0
  32. {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/gsource.py +0 -0
  33. {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/roots.py +0 -0
  34. {codmap-0.0.19 → codmap-0.0.21}/codemap/extract/union.py +0 -0
  35. {codmap-0.0.19 → codmap-0.0.21}/codemap/freshness.py +0 -0
  36. {codmap-0.0.19 → codmap-0.0.21}/codemap/incremental.py +0 -0
  37. {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/__init__.py +0 -0
  38. {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/base.py +0 -0
  39. {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/cocoindex.py +0 -0
  40. {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/gate.py +0 -0
  41. {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/gitnexus.py +0 -0
  42. {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/registry.py +0 -0
  43. {codmap-0.0.19 → codmap-0.0.21}/codemap/integrations/transport.py +0 -0
  44. {codmap-0.0.19 → codmap-0.0.21}/codemap/provenance.py +0 -0
  45. {codmap-0.0.19 → codmap-0.0.21}/codemap/scope.py +0 -0
  46. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/__init__.py +0 -0
  47. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/_scip_pb2.py +0 -0
  48. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/api_surface.py +0 -0
  49. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/apidiff.py +0 -0
  50. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/ctags.py +0 -0
  51. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/impact.py +0 -0
  52. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/limits.py +0 -0
  53. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/mcp_server.py +0 -0
  54. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/mermaid.py +0 -0
  55. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/pack.py +0 -0
  56. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/rag.py +0 -0
  57. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/review.py +0 -0
  58. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/scip.py +0 -0
  59. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/semantic.py +0 -0
  60. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/server.py +0 -0
  61. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/subsystems.py +0 -0
  62. {codmap-0.0.19 → codmap-0.0.21}/codemap/serve/vault.py +0 -0
  63. {codmap-0.0.19 → codmap-0.0.21}/codemap/store.py +0 -0
  64. {codmap-0.0.19 → codmap-0.0.21}/codemap/tomlio.py +0 -0
  65. {codmap-0.0.19 → codmap-0.0.21}/codemap/watch.py +0 -0
  66. {codmap-0.0.19 → codmap-0.0.21}/codmap.egg-info/dependency_links.txt +0 -0
  67. {codmap-0.0.19 → codmap-0.0.21}/codmap.egg-info/entry_points.txt +0 -0
  68. {codmap-0.0.19 → codmap-0.0.21}/codmap.egg-info/requires.txt +0 -0
  69. {codmap-0.0.19 → codmap-0.0.21}/codmap.egg-info/top_level.txt +0 -0
  70. {codmap-0.0.19 → codmap-0.0.21}/setup.cfg +0 -0
  71. {codmap-0.0.19 → codmap-0.0.21}/tests/test_epistemic.py +0 -0
  72. {codmap-0.0.19 → codmap-0.0.21}/tests/test_gitnexus_router.py +0 -0
  73. {codmap-0.0.19 → codmap-0.0.21}/tests/test_impact_depth.py +0 -0
  74. {codmap-0.0.19 → codmap-0.0.21}/tests/test_integrations.py +0 -0
  75. {codmap-0.0.19 → codmap-0.0.21}/tests/test_issue3_serve_freshness.py +0 -0
  76. {codmap-0.0.19 → codmap-0.0.21}/tests/test_livingdocs.py +0 -0
  77. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m0_api_surface.py +0 -0
  78. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m11_argcontract.py +0 -0
  79. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m12_dataflow.py +0 -0
  80. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m15_review.py +0 -0
  81. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m17_mcp.py +0 -0
  82. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m18_freshness.py +0 -0
  83. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m1_5_semantics.py +0 -0
  84. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m1_query.py +0 -0
  85. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m2_views.py +0 -0
  86. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m32_watch.py +0 -0
  87. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m3_serve.py +0 -0
  88. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m4_behavior.py +0 -0
  89. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m5_deep.py +0 -0
  90. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m6_repo_scope.py +0 -0
  91. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m7_dispatch.py +0 -0
  92. {codmap-0.0.19 → codmap-0.0.21}/tests/test_m9_family.py +0 -0
  93. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  94. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c13_grep_vs_graph.py +0 -0
  95. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c16_semantic.py +0 -0
  96. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c20_attribute_edges.py +0 -0
  97. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c21_flat_layout.py +0 -0
  98. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c22_source_visible_refs.py +0 -0
  99. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c23_hard_python.py +0 -0
  100. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c24_test_mapping.py +0 -0
  101. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c25_provenance.py +0 -0
  102. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c26_deep_union.py +0 -0
  103. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c27_config_honesty.py +0 -0
  104. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c28_limit_envelope.py +0 -0
  105. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c2_ctags.py +0 -0
  106. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c30_local_import_calls.py +0 -0
  107. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c30f1_reexport_calls.py +0 -0
  108. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c30f2_check_scope.py +0 -0
  109. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c31_path_origin.py +0 -0
  110. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c32_report_json.py +0 -0
  111. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c33_query_signature.py +0 -0
  112. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c34_signature_kinds.py +0 -0
  113. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c35_contract_location.py +0 -0
  114. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c36_target_resolution.py +0 -0
  115. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c37_rule_mutation.py +0 -0
  116. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c38_tool_drift.py +0 -0
  117. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c39_edge_resolution.py +0 -0
  118. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c3_arch_contract.py +0 -0
  119. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c3_dogfood.py +0 -0
  120. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c40_flow_reach.py +0 -0
  121. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c41_scope_membership.py +0 -0
  122. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c42_deep_tier_stability.py +0 -0
  123. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c43_incremental_splice.py +0 -0
  124. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c44_absent_answers.py +0 -0
  125. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c45_repeat_union.py +0 -0
  126. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c46_shadowed_definitions.py +0 -0
  127. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c47_incremental_resample.py +0 -0
  128. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c4_complexity.py +0 -0
  129. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c50_flow_entry_points.py +0 -0
  130. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c53_narrowing_declared.py +0 -0
  131. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c54_deterministic_rendering.py +0 -0
  132. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c55_override_grade.py +0 -0
  133. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c56_stub_files.py +0 -0
  134. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c57_facade_package.py +0 -0
  135. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c5_apidiff.py +0 -0
  136. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c6_pack.py +0 -0
  137. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c7_edge_vocab.py +0 -0
  138. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c8_deadcode.py +0 -0
  139. {codmap-0.0.19 → codmap-0.0.21}/tests/test_r1c9_incremental.py +0 -0
  140. {codmap-0.0.19 → codmap-0.0.21}/tests/test_scip_export.py +0 -0
  141. {codmap-0.0.19 → codmap-0.0.21}/tests/test_scope.py +0 -0
  142. {codmap-0.0.19 → codmap-0.0.21}/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.21
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, **990 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, **990 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 &
@@ -57,6 +57,11 @@ class ArchitectureContract:
57
57
  no_lazy_cycles: bool = False
58
58
  no_type_only_cycles: bool = False
59
59
  exhaustive: bool = False
60
+ # R1-C62: the mirror of `exhaustive`. That one asks "is every layer of the code
61
+ # declared?"; this asks "does every declared name exist?". Opt-in, because a contract
62
+ # written ahead of the code is a documented, legitimate use (see the module docstring)
63
+ # and failing on it by default would turn that practice red on somebody else's tree.
64
+ no_phantom_rules: bool = False
60
65
  error: str | None = None
61
66
  # R1-C35: the file this contract was looked for in. "No contract found" is only
62
67
  # actionable next to *where* we looked — a reader in the wrong directory cannot tell
@@ -66,7 +71,8 @@ class ArchitectureContract:
66
71
  def is_empty(self) -> bool:
67
72
  return not (self.layers or self.independent or self.forbidden
68
73
  or self.no_cycles or self.no_lazy_cycles
69
- or self.no_type_only_cycles or self.exhaustive)
74
+ or self.no_type_only_cycles or self.exhaustive
75
+ or self.no_phantom_rules)
70
76
 
71
77
 
72
78
  @dataclass(frozen=True)
@@ -117,9 +123,46 @@ def parse_contract(section: dict) -> ArchitectureContract:
117
123
  no_lazy_cycles=bool(section.get("no_lazy_cycles", False)),
118
124
  no_type_only_cycles=bool(section.get("no_type_only_cycles", False)),
119
125
  exhaustive=bool(section.get("exhaustive", False)),
126
+ no_phantom_rules=bool(section.get("no_phantom_rules", False)),
120
127
  )
121
128
 
122
129
 
130
+ def _graph_layers(query) -> set[str]:
131
+ """Layer names that exist in the core import graph."""
132
+ return {query._layer_of(m) for m in query.import_graph.nodes
133
+ if query.root_of(m) == "core"}
134
+
135
+
136
+ def applicability(query, contract: ArchitectureContract) -> dict:
137
+ """Per-rule *declared* vs *applicable* counts, plus the names that do not exist (R1-C62).
138
+
139
+ A rule naming a layer the graph does not contain is **inert by design** — no module, no
140
+ edge, nothing to break — which is what lets a contract be written ahead of the code. The
141
+ defect that made this function necessary is not the inertness: it is that the gate
142
+ counted such a rule as *enforced*, so a renamed layer left a green tick over a rule that
143
+ could no longer fire.
144
+
145
+ Applicability is presence of the **name**, nothing cleverer: `layers` needs the layer,
146
+ `independent` needs at least two members of the group (one cannot import itself), and
147
+ `forbidden` needs both ends. Whether a violation is even possible between two existing
148
+ layers is not asked, and should not be.
149
+ """
150
+ present = _graph_layers(query)
151
+ named = set(contract.layers) | {m for grp in contract.independent for m in grp}
152
+ named |= {n for pair in contract.forbidden for n in pair}
153
+ return {
154
+ "layers": {"declared": len(contract.layers),
155
+ "applicable": sum(1 for x in contract.layers if x in present)},
156
+ "independent": {"declared": len(contract.independent),
157
+ "applicable": sum(1 for g in contract.independent
158
+ if len([x for x in g if x in present]) >= 2)},
159
+ "forbidden": {"declared": len(contract.forbidden),
160
+ "applicable": sum(1 for (a, b) in contract.forbidden
161
+ if a in present and b in present)},
162
+ "absent_names": tuple(sorted(n for n in named if n not in present)),
163
+ }
164
+
165
+
123
166
  def _core_layer_edges(query) -> list[tuple[str, str, str, str]]:
124
167
  """Cross-layer core→core import edges as (importer, imported, layer_i, layer_j)."""
125
168
  ig = query.import_graph
@@ -133,6 +176,20 @@ def _core_layer_edges(query) -> list[tuple[str, str, str, str]]:
133
176
  return out
134
177
 
135
178
 
179
+
180
+ def _tangle_line(tangle: dict) -> str:
181
+ """One gate line per tangle: the example cycle, then the members (R1-C58).
182
+
183
+ The example comes first because it is what a reader acts on; the membership follows
184
+ because the tangle, not the cycle, is what has to be broken.
185
+ """
186
+ ex = tangle["example"]
187
+ head = " → ".join(ex) + " → " + ex[0]
188
+ if tangle["size"] <= len(ex):
189
+ return head
190
+ return f"{head} (tangle of {tangle['size']}: {', '.join(tangle['modules'])})"
191
+
192
+
136
193
  def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
137
194
  """Evaluate every rule against the graph; return the violations (empty = clean)."""
138
195
  if contract.is_empty():
@@ -192,13 +249,16 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
192
249
  # migrated into the gate. So: the gate stays eager, the *disclosure* is mandatory (see
193
250
  # `build_check`), and a contract that wants the coupling gated says so.
194
251
  if contract.no_cycles:
195
- cycles = query.import_cycles()
196
- if cycles:
197
- worst = sorted(cycles, key=lambda c: (len(c), c))
252
+ tangles = query.import_tangles()
253
+ if tangles:
254
+ # R1-C58: one violation per **tangle**, not per simple cycle. A tangle of 19
255
+ # modules used to produce 1080 violations that were all the same problem, and
256
+ # the gate's own output then took a thousand lines to say it once.
198
257
  violations.append(Violation(
199
258
  "no_cycles",
200
- f"{len(cycles)} import cycle(s)",
201
- modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
259
+ f"{len(tangles)} import tangle(s), "
260
+ f"{sum(t['size'] for t in tangles)} module(s)",
261
+ modules=tuple(_tangle_line(t) for t in tangles),
202
262
  ))
203
263
 
204
264
  # -- no_lazy_cycles: opt in to gating the coupling a lazy import hides -------
@@ -206,13 +266,13 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
206
266
  # not a gate, and a lazy import is the accepted way to break an import cycle — so this
207
267
  # is the contract owner's call to state, not a default to pick on their behalf.
208
268
  if contract.no_lazy_cycles:
209
- lazy = query.lazy_import_cycles()
269
+ lazy = query.lazy_import_tangles()
210
270
  if lazy:
211
- worst = sorted(lazy, key=lambda c: (len(c), c))
212
271
  violations.append(Violation(
213
272
  "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),
273
+ f"{len(lazy)} tangle(s) closed only by a function-local import, "
274
+ f"{sum(t['size'] for t in lazy)} module(s)",
275
+ modules=tuple(_tangle_line(t) for t in lazy),
216
276
  ))
217
277
 
218
278
  # -- no_type_only_cycles: the third kind, and the one with no runtime dependency ----
@@ -223,14 +283,14 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
223
283
  # against lazy imports used as a way around `no_cycles`, and the standard typing
224
284
  # idiom is not that.
225
285
  if contract.no_type_only_cycles:
226
- type_only = query.type_only_import_cycles()
286
+ type_only = query.type_only_import_tangles()
227
287
  if type_only:
228
- worst = sorted(type_only, key=lambda c: (len(c), c))
229
288
  violations.append(Violation(
230
289
  "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),
290
+ f"{len(type_only)} tangle(s) closed only by an import that never runs "
291
+ f"(`if TYPE_CHECKING:` or a `.pyi`), "
292
+ f"{sum(t['size'] for t in type_only)} module(s)",
293
+ modules=tuple(_tangle_line(t) for t in type_only),
234
294
  ))
235
295
 
236
296
  # -- exhaustive: every core module's layer must be declared -----------------
@@ -249,4 +309,18 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
249
309
  modules=tuple(undeclared),
250
310
  ))
251
311
 
312
+ # -- no_phantom_rules: every name the contract mentions must exist -----------
313
+ # R1-C62, the mirror of `exhaustive`. Off by default on purpose: inert rules are how a
314
+ # contract gets written ahead of the code. On, it catches the drift the count alone
315
+ # only *discloses* — a layer renamed while the contract kept guarding the old name.
316
+ if contract.no_phantom_rules:
317
+ absent = applicability(query, contract)["absent_names"]
318
+ if absent:
319
+ violations.append(Violation(
320
+ "no_phantom_rules",
321
+ f"{len(absent)} name(s) in the contract are absent from the graph, "
322
+ "so the rules naming them cannot apply",
323
+ modules=absent,
324
+ ))
325
+
252
326
  return violations
@@ -73,6 +73,23 @@ _REPORTS = {
73
73
  _REPORT_KINDS = sorted(_REPORTS) + ["impact", "communities", "flows"] # extra args
74
74
 
75
75
 
76
+ def _emit_answer(text: str) -> None:
77
+ """Print a markdown answer with the answer-format trailer (R1-C63).
78
+
79
+ One seam rather than a line in every renderer: the renderers stay pure functions of the
80
+ graph, and there is one place to forget rather than nine. The trailer is what a consumer
81
+ diffing our output sees when the *shape* of an answer changes deliberately — 0.0.20
82
+ changed the gate's lines while the graph stayed byte-identical, and on a text diff that
83
+ looked exactly like the #20 defect, where the text moved between runs of one version. The
84
+ only thing that told them apart was a message written by hand.
85
+ """
86
+ from codemap.model import ANSWER_FORMAT, SCHEMA_VERSION
87
+ print(text, end="")
88
+ if not text.endswith("\n"):
89
+ print()
90
+ print(f"_answer format {ANSWER_FORMAT} · schema {SCHEMA_VERSION}_")
91
+
92
+
76
93
  def _graph_from(args):
77
94
  if getattr(args, "build", None):
78
95
  return extract(args.build, deep=getattr(args, "deep", False))
@@ -507,28 +524,28 @@ def _cmd_report(args) -> int:
507
524
  if args.kind == "impact":
508
525
  if not args.symbol:
509
526
  raise SystemExit("error: report impact needs --symbol <name>")
510
- print(render_impact(Query(graph), args.symbol, depth=args.depth,
511
- flow_depth=args.flow_depth), end="")
527
+ _emit_answer(render_impact(Query(graph), args.symbol, depth=args.depth,
528
+ flow_depth=args.flow_depth))
512
529
  return 0
513
530
  if args.kind in ("communities", "flows"):
514
531
  from codemap.serve.subsystems import render_communities, render_flows
515
532
  q = Query(graph)
516
533
  out = (render_communities(q) if args.kind == "communities"
517
534
  else render_flows(q, args.symbol, depth=args.depth))
518
- print(out, end="")
535
+ _emit_answer(out)
519
536
  return 0
520
537
  if args.kind == "dead-code":
521
538
  from codemap.serve.audit import load_dead_code_whitelist
522
539
  root = getattr(args, "source_root", None) or os.getcwd()
523
540
  whitelist, wl_error = load_dead_code_whitelist(root)
524
- print(render_dead_code(Query(graph),
541
+ _emit_answer(render_dead_code(Query(graph),
525
542
  whitelist=whitelist,
526
543
  min_confidence=args.min_confidence,
527
- whitelist_error=wl_error), end="")
544
+ whitelist_error=wl_error))
528
545
  return 0
529
546
  renderer = _REPORTS[args.kind]
530
547
  payload = renderer(graph) if args.kind == "api-surface" else renderer(Query(graph))
531
- print(payload, end="")
548
+ _emit_answer(payload)
532
549
  return 0
533
550
 
534
551
 
@@ -755,13 +772,13 @@ def _cmd_check(args) -> int:
755
772
  # would sort an unreadable contract into the success branch of every `if rc == 2` that
756
773
  # already exists in someone's pipeline.
757
774
  if contract.error:
758
- print(render_check(q, contract, []), end="")
775
+ _emit_answer(render_check(q, contract, []))
759
776
  return 2
760
777
  if contract.is_empty() and args.require_contract:
761
- print(render_check(q, contract, []), end="")
778
+ _emit_answer(render_check(q, contract, []))
762
779
  raise SystemExit("error: no [architecture] contract found (--require-contract)")
763
780
  violations = check_contract(q, contract)
764
- print(render_check(q, contract, violations), end="")
781
+ _emit_answer(render_check(q, contract, violations))
765
782
  return 2 if violations else 0
766
783
 
767
784
 
@@ -77,6 +77,24 @@ from typing import Any
77
77
  # (they had ``lineno`` alone, so `search` answered a line number with no file).
78
78
  SCHEMA_VERSION = "0.13"
79
79
 
80
+ # R1-C63: the *answer* has a shape too, and consumers diff it. `SCHEMA_VERSION` versions the
81
+ # artifact; 0.0.20 left the graph byte-identical and changed the architecture report and the
82
+ # gate's lines completely (a cycle answer became a tangle), which on a text diff is
83
+ # indistinguishable from the #20 defect — where the text moved between runs of one version,
84
+ # without anyone deciding it. The only thing that told the consumer apart was a message I
85
+ # wrote by hand.
86
+ #
87
+ # Bump on a change to the **structure a consumer sees**: a block appears or vanishes, a
88
+ # counted unit changes meaning, a verdict or heading changes shape, a field in a structured
89
+ # answer changes type or semantics. Do **not** bump for wording, typos or added prose inside
90
+ # an existing note — a version that ticks on whitespace stops being read, which is worse than
91
+ # not having one. Independent of SCHEMA_VERSION by design: the graph can be identical while
92
+ # this moves, and that is the case it exists for.
93
+ #
94
+ # Starts at 1 with 0.0.21 and makes no claim about earlier releases: the answer format changed
95
+ # many times before it, and numbering that retroactively would be inventing a history.
96
+ ANSWER_FORMAT = 1
97
+
80
98
  # Closed vocabulary of edge types (R1-C7). Node ``kind`` is deliberately an OPEN set
81
99
  # (DESIGN §2 — new entity kinds may appear), but edges are TYPED: every relationship
82
100
  # codemap emits is one of these, each with a fixed meaning. This is the machine-
@@ -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("")