codmap 0.0.13__tar.gz → 0.0.14__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 (131) hide show
  1. {codmap-0.0.13/codmap.egg-info → codmap-0.0.14}/PKG-INFO +2 -2
  2. {codmap-0.0.13 → codmap-0.0.14}/README.md +1 -1
  3. {codmap-0.0.13 → codmap-0.0.14}/codemap/arch.py +25 -4
  4. {codmap-0.0.13 → codmap-0.0.14}/codemap/extract/griffe_extractor.py +7 -5
  5. {codmap-0.0.13 → codmap-0.0.14}/codemap/query.py +33 -7
  6. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/architecture.py +26 -8
  7. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/audit.py +7 -5
  8. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/check.py +30 -19
  9. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/livingdocs.py +7 -3
  10. {codmap-0.0.13 → codmap-0.0.14/codmap.egg-info}/PKG-INFO +2 -2
  11. {codmap-0.0.13 → codmap-0.0.14}/codmap.egg-info/SOURCES.txt +1 -0
  12. {codmap-0.0.13 → codmap-0.0.14}/pyproject.toml +1 -1
  13. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m16_architecture.py +3 -1
  14. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m1_query.py +3 -1
  15. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c29_lazy_imports.py +2 -2
  16. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c30f2_check_scope.py +11 -3
  17. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c48_type_checking_imports.py +14 -8
  18. codmap-0.0.14/tests/test_r1c49_type_only_cycles.py +206 -0
  19. {codmap-0.0.13 → codmap-0.0.14}/LICENSE +0 -0
  20. {codmap-0.0.13 → codmap-0.0.14}/codemap/__init__.py +0 -0
  21. {codmap-0.0.13 → codmap-0.0.14}/codemap/apidiff.py +0 -0
  22. {codmap-0.0.13 → codmap-0.0.14}/codemap/cli.py +0 -0
  23. {codmap-0.0.13 → codmap-0.0.14}/codemap/diagnostics.py +0 -0
  24. {codmap-0.0.13 → codmap-0.0.14}/codemap/extract/__init__.py +0 -0
  25. {codmap-0.0.13 → codmap-0.0.14}/codemap/extract/attrflow.py +0 -0
  26. {codmap-0.0.13 → codmap-0.0.14}/codemap/extract/behavior.py +0 -0
  27. {codmap-0.0.13 → codmap-0.0.14}/codemap/extract/dataflow.py +0 -0
  28. {codmap-0.0.13 → codmap-0.0.14}/codemap/extract/dispatch.py +0 -0
  29. {codmap-0.0.13 → codmap-0.0.14}/codemap/extract/gsource.py +0 -0
  30. {codmap-0.0.13 → codmap-0.0.14}/codemap/extract/roots.py +0 -0
  31. {codmap-0.0.13 → codmap-0.0.14}/codemap/extract/union.py +0 -0
  32. {codmap-0.0.13 → codmap-0.0.14}/codemap/freshness.py +0 -0
  33. {codmap-0.0.13 → codmap-0.0.14}/codemap/incremental.py +0 -0
  34. {codmap-0.0.13 → codmap-0.0.14}/codemap/integrations/__init__.py +0 -0
  35. {codmap-0.0.13 → codmap-0.0.14}/codemap/integrations/base.py +0 -0
  36. {codmap-0.0.13 → codmap-0.0.14}/codemap/integrations/cocoindex.py +0 -0
  37. {codmap-0.0.13 → codmap-0.0.14}/codemap/integrations/gate.py +0 -0
  38. {codmap-0.0.13 → codmap-0.0.14}/codemap/integrations/gitnexus.py +0 -0
  39. {codmap-0.0.13 → codmap-0.0.14}/codemap/integrations/registry.py +0 -0
  40. {codmap-0.0.13 → codmap-0.0.14}/codemap/integrations/transport.py +0 -0
  41. {codmap-0.0.13 → codmap-0.0.14}/codemap/model.py +0 -0
  42. {codmap-0.0.13 → codmap-0.0.14}/codemap/provenance.py +0 -0
  43. {codmap-0.0.13 → codmap-0.0.14}/codemap/scope.py +0 -0
  44. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/__init__.py +0 -0
  45. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/_scip_pb2.py +0 -0
  46. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/api_surface.py +0 -0
  47. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/apidiff.py +0 -0
  48. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/ctags.py +0 -0
  49. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/impact.py +0 -0
  50. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/limits.py +0 -0
  51. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/mcp_server.py +0 -0
  52. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/mermaid.py +0 -0
  53. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/pack.py +0 -0
  54. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/rag.py +0 -0
  55. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/review.py +0 -0
  56. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/scip.py +0 -0
  57. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/semantic.py +0 -0
  58. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/server.py +0 -0
  59. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/session.py +0 -0
  60. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/subsystems.py +0 -0
  61. {codmap-0.0.13 → codmap-0.0.14}/codemap/serve/vault.py +0 -0
  62. {codmap-0.0.13 → codmap-0.0.14}/codemap/store.py +0 -0
  63. {codmap-0.0.13 → codmap-0.0.14}/codemap/tomlio.py +0 -0
  64. {codmap-0.0.13 → codmap-0.0.14}/codemap/watch.py +0 -0
  65. {codmap-0.0.13 → codmap-0.0.14}/codmap.egg-info/dependency_links.txt +0 -0
  66. {codmap-0.0.13 → codmap-0.0.14}/codmap.egg-info/entry_points.txt +0 -0
  67. {codmap-0.0.13 → codmap-0.0.14}/codmap.egg-info/requires.txt +0 -0
  68. {codmap-0.0.13 → codmap-0.0.14}/codmap.egg-info/top_level.txt +0 -0
  69. {codmap-0.0.13 → codmap-0.0.14}/setup.cfg +0 -0
  70. {codmap-0.0.13 → codmap-0.0.14}/tests/test_epistemic.py +0 -0
  71. {codmap-0.0.13 → codmap-0.0.14}/tests/test_gitnexus_router.py +0 -0
  72. {codmap-0.0.13 → codmap-0.0.14}/tests/test_impact_depth.py +0 -0
  73. {codmap-0.0.13 → codmap-0.0.14}/tests/test_integrations.py +0 -0
  74. {codmap-0.0.13 → codmap-0.0.14}/tests/test_issue3_serve_freshness.py +0 -0
  75. {codmap-0.0.13 → codmap-0.0.14}/tests/test_livingdocs.py +0 -0
  76. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m0_api_surface.py +0 -0
  77. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m11_argcontract.py +0 -0
  78. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m12_dataflow.py +0 -0
  79. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m15_review.py +0 -0
  80. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m17_mcp.py +0 -0
  81. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m18_freshness.py +0 -0
  82. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m1_5_semantics.py +0 -0
  83. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m2_views.py +0 -0
  84. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m32_watch.py +0 -0
  85. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m3_serve.py +0 -0
  86. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m4_behavior.py +0 -0
  87. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m5_deep.py +0 -0
  88. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m6_repo_scope.py +0 -0
  89. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m7_dispatch.py +0 -0
  90. {codmap-0.0.13 → codmap-0.0.14}/tests/test_m9_family.py +0 -0
  91. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  92. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c13_grep_vs_graph.py +0 -0
  93. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c16_semantic.py +0 -0
  94. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c20_attribute_edges.py +0 -0
  95. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c21_flat_layout.py +0 -0
  96. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c22_source_visible_refs.py +0 -0
  97. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c23_hard_python.py +0 -0
  98. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c24_test_mapping.py +0 -0
  99. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c25_provenance.py +0 -0
  100. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c26_deep_union.py +0 -0
  101. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c27_config_honesty.py +0 -0
  102. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c28_limit_envelope.py +0 -0
  103. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c2_ctags.py +0 -0
  104. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c30_local_import_calls.py +0 -0
  105. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c30f1_reexport_calls.py +0 -0
  106. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c31_path_origin.py +0 -0
  107. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c32_report_json.py +0 -0
  108. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c33_query_signature.py +0 -0
  109. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c34_signature_kinds.py +0 -0
  110. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c35_contract_location.py +0 -0
  111. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c36_target_resolution.py +0 -0
  112. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c37_rule_mutation.py +0 -0
  113. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c38_tool_drift.py +0 -0
  114. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c3_arch_contract.py +0 -0
  115. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c3_dogfood.py +0 -0
  116. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c41_scope_membership.py +0 -0
  117. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c42_deep_tier_stability.py +0 -0
  118. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c43_incremental_splice.py +0 -0
  119. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c44_absent_answers.py +0 -0
  120. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c45_repeat_union.py +0 -0
  121. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c46_shadowed_definitions.py +0 -0
  122. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c47_incremental_resample.py +0 -0
  123. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c4_complexity.py +0 -0
  124. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c5_apidiff.py +0 -0
  125. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c6_pack.py +0 -0
  126. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c7_edge_vocab.py +0 -0
  127. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c8_deadcode.py +0 -0
  128. {codmap-0.0.13 → codmap-0.0.14}/tests/test_r1c9_incremental.py +0 -0
  129. {codmap-0.0.13 → codmap-0.0.14}/tests/test_scip_export.py +0 -0
  130. {codmap-0.0.13 → codmap-0.0.14}/tests/test_scope.py +0 -0
  131. {codmap-0.0.13 → codmap-0.0.14}/tests/test_subsystems.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.13
3
+ Version: 0.0.14
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
@@ -85,7 +85,7 @@ layers core 9 · data 12 · indicators 16 · analysis 42 · visualizatio
85
85
  analysis → core 38 edges · indicators → core 22 · data → core 13
86
86
  violation ⚠ analysis ↔ core — one backward edge, written inside a function
87
87
  cycle pipeline → cache → pipeline — the classic Python import-order landmine
88
- lazy cycles 40 more, closed only by a non-eager import (function-local or `TYPE_CHECKING`) — not import-time failures,
88
+ lazy cycles 40 more, closed only by a function-local import — not import-time failures,
89
89
  still mutual coupling: neither module can be extracted without the other
90
90
  coupling core.logging_config Ca 96 — a breaking change here reaches 96 modules
91
91
  concentration ZoneVisualizer 35 methods, worst function CC 66 / MI 12.5
@@ -49,7 +49,7 @@ layers core 9 · data 12 · indicators 16 · analysis 42 · visualizatio
49
49
  analysis → core 38 edges · indicators → core 22 · data → core 13
50
50
  violation ⚠ analysis ↔ core — one backward edge, written inside a function
51
51
  cycle pipeline → cache → pipeline — the classic Python import-order landmine
52
- lazy cycles 40 more, closed only by a non-eager import (function-local or `TYPE_CHECKING`) — not import-time failures,
52
+ lazy cycles 40 more, closed only by a function-local import — not import-time failures,
53
53
  still mutual coupling: neither module can be extracted without the other
54
54
  coupling core.logging_config Ca 96 — a breaking change here reaches 96 modules
55
55
  concentration ZoneVisualizer 35 methods, worst function CC 66 / MI 12.5
@@ -21,6 +21,7 @@ integration gate reads). All rules operate on the **core** module import graph
21
21
  # also gate the coupling a lazy import hides: cycles closed only by an import
22
22
  # written inside a function. Off by default — see below.
23
23
  no_lazy_cycles = false
24
+ no_type_only_cycles = false
24
25
  # every core module's layer must appear in `layers` (catches a new,
25
26
  # undeclared top-level package slipping in).
26
27
  exhaustive = false
@@ -54,6 +55,7 @@ class ArchitectureContract:
54
55
  forbidden: tuple[tuple[str, str], ...] = ()
55
56
  no_cycles: bool = False
56
57
  no_lazy_cycles: bool = False
58
+ no_type_only_cycles: bool = False
57
59
  exhaustive: bool = False
58
60
  error: str | None = None
59
61
  # R1-C35: the file this contract was looked for in. "No contract found" is only
@@ -63,7 +65,8 @@ class ArchitectureContract:
63
65
 
64
66
  def is_empty(self) -> bool:
65
67
  return not (self.layers or self.independent or self.forbidden
66
- or self.no_cycles or self.no_lazy_cycles or self.exhaustive)
68
+ or self.no_cycles or self.no_lazy_cycles
69
+ or self.no_type_only_cycles or self.exhaustive)
67
70
 
68
71
 
69
72
  @dataclass(frozen=True)
@@ -71,7 +74,7 @@ class Violation:
71
74
  """One broken rule, with the concrete import edges (or cycle) that break it."""
72
75
 
73
76
  rule: str # layered | independent | forbidden | no_cycles | no_lazy_cycles
74
- # | exhaustive
77
+ # | no_type_only_cycles | exhaustive
75
78
  summary: str # human one-liner
76
79
  edges: tuple[tuple[str, str], ...] = field(default=()) # offending (importer, imported)
77
80
  modules: tuple[str, ...] = field(default=()) # for exhaustive / cycles
@@ -112,6 +115,7 @@ def parse_contract(section: dict) -> ArchitectureContract:
112
115
  forbidden=tuple(forbidden),
113
116
  no_cycles=bool(section.get("no_cycles", False)),
114
117
  no_lazy_cycles=bool(section.get("no_lazy_cycles", False)),
118
+ no_type_only_cycles=bool(section.get("no_type_only_cycles", False)),
115
119
  exhaustive=bool(section.get("exhaustive", False)),
116
120
  )
117
121
 
@@ -207,8 +211,25 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
207
211
  worst = sorted(lazy, key=lambda c: (len(c), c))
208
212
  violations.append(Violation(
209
213
  "no_lazy_cycles",
210
- f"{len(lazy)} dependency cycle(s) closed only by a non-eager import "
211
- f"(function-local, or under `if TYPE_CHECKING:`)",
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),
216
+ ))
217
+
218
+ # -- no_type_only_cycles: the third kind, and the one with no runtime dependency ----
219
+ # R1-C49 (issue #18): a cycle that needs an import under `if TYPE_CHECKING:` cannot
220
+ # break at import time and does not couple the modules at run time either — the two
221
+ # only name each other's types. Gating it is a style choice about the type layer, so
222
+ # it is opt-in and separate: the consumer who asked for this had `no_lazy_cycles` on
223
+ # against lazy imports used as a way around `no_cycles`, and the standard typing
224
+ # idiom is not that.
225
+ if contract.no_type_only_cycles:
226
+ type_only = query.type_only_import_cycles()
227
+ if type_only:
228
+ worst = sorted(type_only, key=lambda c: (len(c), c))
229
+ violations.append(Violation(
230
+ "no_type_only_cycles",
231
+ f"{len(type_only)} dependency cycle(s) closed only by an import under "
232
+ f"`if TYPE_CHECKING:`",
212
233
  modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
213
234
  ))
214
235
 
@@ -481,11 +481,13 @@ def _emit_decorated_by(graph, obj) -> None:
481
481
 
482
482
  # -- pass 2: resolve export + import edges against known nodes ----------------
483
483
 
484
- #: How early an import scope reaches its target: an edge carries the earliest (R1-C29 D2,
485
- #: R1-C48 D2). ``module`` runs at import time, ``type_checking`` never, ``function`` when
486
- #: the function runs — the middle one is ordered before ``function`` only so the label
487
- #: names the construct actually written at module level.
488
- _SCOPE_RANK = {"module": 0, "type_checking": 1, "function": 2}
484
+ #: How strongly an import scope reaches its target: an edge carries the strongest (R1-C29
485
+ #: D2, R1-C48 D2). ``module`` runs at import time, ``function`` when the function runs,
486
+ #: ``type_checking`` never. A pair reached by both a function-local and a ``TYPE_CHECKING``
487
+ #: import is a **function-local** dependency: it can execute, and labelling it by the
488
+ #: import that cannot would let a tree launder runtime coupling into the type layer
489
+ #: (R1-C49, and the test that says so).
490
+ _SCOPE_RANK = {"module": 0, "function": 1, "type_checking": 2}
489
491
 
490
492
 
491
493
  def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
@@ -119,20 +119,27 @@ class Query:
119
119
  # made an incomplete map read as a safety property.
120
120
  self._imports = nx.DiGraph()
121
121
  self._imports_eager = nx.DiGraph()
122
+ # R1-C49: between the two — everything that can execute. A `TYPE_CHECKING` import
123
+ # never does, so a cycle that needs one is not runtime coupling at all.
124
+ self._imports_runtime = nx.DiGraph()
122
125
  self._import_scopes = {"module": 0, "function": 0, "type_checking": 0}
123
126
  for n in graph.nodes.values():
124
127
  if n.kind == "module":
125
128
  self._imports.add_node(n.id)
126
129
  self._imports_eager.add_node(n.id)
130
+ self._imports_runtime.add_node(n.id)
127
131
  for e in graph.edges:
128
132
  if e.type == "imports":
129
133
  self._imports.add_edge(e.source, e.target)
130
134
  scope = e.extras.get("scope")
131
135
  if scope in ("function", "type_checking"):
132
136
  # R1-C29: runs when the function runs; R1-C48: never runs. Neither
133
- # is an import-time edge.
137
+ # is an import-time edge — but only the first is a runtime one.
134
138
  self._import_scopes[scope] += 1
139
+ if scope == "function":
140
+ self._imports_runtime.add_edge(e.source, e.target)
135
141
  else:
142
+ self._imports_runtime.add_edge(e.source, e.target)
136
143
  self._imports_eager.add_edge(e.source, e.target)
137
144
  self._import_scopes["module"] += 1
138
145
  # export edges: name -> [target definition paths]
@@ -888,16 +895,35 @@ class Query:
888
895
  return [c for c in nx.simple_cycles(self._imports_eager)]
889
896
 
890
897
  def lazy_import_cycles(self) -> list[list[str]]:
891
- """Dependency cycles that close **only** through a non-eager import — one
892
- written inside a function (R1-C29) or under ``if TYPE_CHECKING:`` (R1-C48).
898
+ """Dependency cycles that close **only** through a function-local import.
893
899
 
894
900
  Not an import-time failure, and not nothing: the modules still cannot be
895
- separated, and the lazy import is the evidence someone already hit this. Before
896
- R1-C29 these were invisible — on the target this project benchmarks on, eight of
897
- the nine cycles present were of this kind and the report said "1".
901
+ separated at run time, and the lazy import is the evidence someone already hit
902
+ this. Before R1-C29 these were invisible — on the target this project benchmarks
903
+ on, eight of the nine cycles present were of this kind and the report said "1".
904
+
905
+ R1-C49 narrowed this back after R1-C48 briefly widened it: a cycle that needs an
906
+ import under ``if TYPE_CHECKING:`` is not runtime coupling at all and is returned
907
+ by :meth:`type_only_import_cycles`. The partition is by the **weakest scope that
908
+ closes the cycle**, not by which scopes appear in it — a pair coupled through a
909
+ function-local import stays *lazy* however many type imports also run between
910
+ them, or a tree could hide real coupling by adding one.
898
911
  """
899
912
  eager = {frozenset(c) for c in nx.simple_cycles(self._imports_eager)}
900
- return [c for c in nx.simple_cycles(self._imports) if frozenset(c) not in eager]
913
+ return [c for c in nx.simple_cycles(self._imports_runtime)
914
+ if frozenset(c) not in eager]
915
+
916
+ def type_only_import_cycles(self) -> list[list[str]]:
917
+ """Dependency cycles that close **only** with an import under ``if TYPE_CHECKING:``.
918
+
919
+ The third kind (R1-C49, issue #18). These modules name each other's types and have
920
+ **no runtime dependency whatever**: neither import pulls the other at any moment of
921
+ execution. That is why they are not gated by ``no_lazy_cycles`` — which exists
922
+ against a lazy import used to walk *around* ``no_cycles`` — and get their own
923
+ opt-in rule instead.
924
+ """
925
+ runtime = {frozenset(c) for c in nx.simple_cycles(self._imports_runtime)}
926
+ return [c for c in nx.simple_cycles(self._imports) if frozenset(c) not in runtime]
901
927
 
902
928
  def import_map(self) -> dict:
903
929
  """How much of the import graph each scope contributed (R1-C29).
@@ -18,9 +18,10 @@ from codemap.query import Query
18
18
  def build_architecture(query: Query) -> dict:
19
19
  """Structured whole-system overview (cycles + layers + coupling + hotspots).
20
20
 
21
- R1-C29: ``cycles`` are the **import-time** ones and ``lazy_cycles`` the dependency
22
- cycles closed only by a non-eager import — function-local (R1-C29) or under
23
- ``if TYPE_CHECKING:`` (R1-C48). Splitting them is the point — a lazy
21
+ Three kinds, by the weakest scope that closes the cycle (R1-C49): ``cycles`` break at
22
+ import time, ``lazy_cycles`` need a function-local import (runtime coupling), and
23
+ ``type_only_cycles`` need one under ``if TYPE_CHECKING:`` (no runtime dependency at
24
+ all). Splitting them is the point — a lazy
24
25
  import is how a developer *fixes* an import cycle, so folding the two together would
25
26
  report someone's fix as their bug, while dropping the second (what this tool did
26
27
  until issue #11) hides that the modules are still inseparable. ``import_map`` is
@@ -31,6 +32,7 @@ def build_architecture(query: Query) -> dict:
31
32
  "target": query.graph.target,
32
33
  "cycles": query.import_cycles(),
33
34
  "lazy_cycles": query.lazy_import_cycles(),
35
+ "type_only_cycles": query.type_only_import_cycles(),
34
36
  "import_map": query.import_map(),
35
37
  "layers": query.layers(),
36
38
  "coupling": query.coupling(),
@@ -85,19 +87,35 @@ def render_architecture(query: Query) -> str:
85
87
  f"an import under `if TYPE_CHECKING:` never runs._")
86
88
  out.append("")
87
89
  if a["lazy_cycles"]:
88
- out.append(f"### Dependency cycles closed only by a non-eager import "
89
- f"(function-local, or under `if TYPE_CHECKING:`): {len(a['lazy_cycles'])}")
90
+ out.append(f"### Dependency cycles closed only by a function-local import: "
91
+ f"{len(a['lazy_cycles'])}")
90
92
  out.append("")
91
- out.append("_These do **not** break at import time — the non-eager import is what "
93
+ out.append("_These do **not** break at import time — the lazy import is what "
92
94
  "prevents that, and is usually deliberate. They are listed because "
93
- "the modules are still mutually dependent: neither can be extracted "
94
- "without the other._")
95
+ "the modules are still mutually dependent at run time: neither can be "
96
+ "extracted without the other._")
95
97
  out.append("")
96
98
  out.extend(f"- {' → '.join(c)} → {c[0]}" for c in
97
99
  sorted(a["lazy_cycles"], key=lambda c: (len(c), c))[:20])
98
100
  if len(a["lazy_cycles"]) > 20:
99
101
  out.append(f"- _… {len(a['lazy_cycles']) - 20} more_")
100
102
  out.append("")
103
+ if a["type_only_cycles"]:
104
+ # R1-C49: the third kind, kept apart from the second because the difference is the
105
+ # whole point — these modules have no runtime dependency on each other at all.
106
+ out.append(f"### Dependency cycles closed only by an import under "
107
+ f"`if TYPE_CHECKING:`: {len(a['type_only_cycles'])}")
108
+ out.append("")
109
+ out.append("_Neither module pulls the other at any moment of execution — they name "
110
+ "each other's types. Not an import-time failure and not runtime coupling; "
111
+ "`no_type_only_cycles = true` gates them if the type layer must not close "
112
+ "a cycle either._")
113
+ out.append("")
114
+ out.extend(f"- {' → '.join(c)} → {c[0]}" for c in
115
+ sorted(a["type_only_cycles"], key=lambda c: (len(c), c))[:20])
116
+ if len(a["type_only_cycles"]) > 20:
117
+ out.append(f"- _… {len(a['type_only_cycles']) - 20} more_")
118
+ out.append("")
101
119
 
102
120
  # -- coupling -----------------------------------------------------------
103
121
  out.append("## Coupling (top by afferent Ca)")
@@ -18,6 +18,7 @@ def render_dependencies(query: Query) -> str:
18
18
 
19
19
  cycles = query.import_cycles()
20
20
  lazy = query.lazy_import_cycles()
21
+ type_only = query.type_only_import_cycles()
21
22
  im = query.import_map()
22
23
  lines.append(f"## Import cycles: {len(cycles)}")
23
24
  lines.append("")
@@ -31,11 +32,10 @@ def render_dependencies(query: Query) -> str:
31
32
  lines.append("")
32
33
  lines.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
33
34
  f"function-local and {im['type_checking']} `TYPE_CHECKING` import(s); only "
34
- f"the first run at import time. "
35
- + (f"{len(lazy)} further cycle(s) close through a non-eager import "
36
- f"(function-local, or under `if TYPE_CHECKING:`) — real coupling, not "
37
- f"an import-time failure._" if lazy
38
- else "No cycle closes through a non-eager import._"))
35
+ f"the first run at import time. {len(lazy)} further cycle(s) close through "
36
+ f"a function-local import (runtime coupling, not an import-time failure) "
37
+ f"and {len(type_only)} through an import under `if TYPE_CHECKING:` (no "
38
+ f"runtime dependency at all)._")
39
39
  lines.append("")
40
40
 
41
41
  lines.append("## Most-depended-on modules (top 15)")
@@ -63,6 +63,8 @@ def build_dependencies(query: Query) -> dict:
63
63
  "import_cycles": [list(c) for c in sorted(query.import_cycles(), key=lambda c: (len(c), c))],
64
64
  "lazy_import_cycles": [list(c) for c in
65
65
  sorted(query.lazy_import_cycles(), key=lambda c: (len(c), c))],
66
+ "type_only_import_cycles": [list(c) for c in
67
+ sorted(query.type_only_import_cycles(), key=lambda c: (len(c), c))],
66
68
  "most_depended_on": [{"module": m, "imported_by": g.in_degree(m)}
67
69
  for m in ranked[:15] if g.in_degree(m)],
68
70
  "diagnostics": diagnostics(query.graph),
@@ -27,18 +27,29 @@ def _not_judged(query, contract: ArchitectureContract) -> list[dict]:
27
27
  a field that appears only when there is something to say forces a machine consumer to
28
28
  tell "nothing was skipped" from "this build does not report skips", and it cannot.
29
29
  """
30
- if not contract.no_cycles or contract.no_lazy_cycles:
31
- return [] # not enforced, or the contract opted into judging them too
32
- lazy = query.lazy_import_cycles()
30
+ if not contract.no_cycles:
31
+ return [] # not enforced
32
+ if contract.no_lazy_cycles and contract.no_type_only_cycles:
33
+ return [] # the contract judges all three kinds
34
+ # R1-C49: two kinds may be left out, and they are different things. Whichever of them
35
+ # this contract does not gate is what the reader must be told about — reporting one
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()
33
39
  return [{
34
40
  "rule": "no_cycles",
35
41
  "judged": "the eager import graph — imports that run at import time",
36
- "not_judged": "dependency cycles closed only by a non-eager import (function-local, or under `if TYPE_CHECKING:`)",
42
+ "not_judged": "dependency cycles that do not close at import time",
43
+ "lazy": len(lazy),
44
+ "type_only": len(type_only),
45
+ "lazy_gated": contract.no_lazy_cycles,
46
+ "type_only_gated": contract.no_type_only_cycles,
37
47
  "type_checking_imports": query.import_map()["type_checking"],
38
- "count": len(lazy),
39
- "note": ("a non-eager import does not run at import time, so such a cycle cannot break "
40
- "on import; it is still mutual coupling. Set `no_lazy_cycles = true` to "
41
- "gate these as well, or see `report architecture` for the list."),
48
+ "count": len(lazy) + len(type_only),
49
+ "note": ("`lazy` needs a function-local import — runtime coupling, gated by "
50
+ "`no_lazy_cycles = true`. `type_only` needs an import under "
51
+ "`if TYPE_CHECKING:` — no runtime dependency at all, gated by "
52
+ "`no_type_only_cycles = true`. `report architecture` lists both."),
42
53
  }]
43
54
 
44
55
 
@@ -132,15 +143,15 @@ def _render_scope(query, contract: ArchitectureContract) -> str:
132
143
  if not scope:
133
144
  return ""
134
145
  s = scope[0]
135
- if not s["count"]:
136
- return (f"\n_`no_cycles` judged the eager import graph; no dependency cycle is closed "
137
- f"only by a non-eager import (function-local, or under `if TYPE_CHECKING:`) "
138
- f"either. {s['type_checking_imports']} import(s) under `TYPE_CHECKING` read "
139
- f"as never running._\n")
146
+ parts = []
147
+ if not s["lazy_gated"]:
148
+ parts.append(f"**{s['lazy']}** cycle(s) closed only by a function-local import "
149
+ f"(runtime coupling — `no_lazy_cycles = true` gates them)")
150
+ if not s["type_only_gated"]:
151
+ parts.append(f"**{s['type_only']}** closed only by an import under "
152
+ f"`if TYPE_CHECKING:` (no runtime dependency at all — "
153
+ f"`no_type_only_cycles = true` gates them)")
140
154
  return (f"\n_`no_cycles` judged **the eager import graph only** — imports that run at "
141
- f"import time. **{s['count']}** dependency cycle(s) closed only by a "
142
- f"non-eager import (function-local, or under `if TYPE_CHECKING:`) were **not** "
143
- f"judged: such a cycle cannot break on import, but the coupling is real. "
144
- f"`report architecture` lists them; `no_lazy_cycles = true` gates them. "
145
- f"{s['type_checking_imports']} import(s) under `TYPE_CHECKING` read as never "
146
- f"running._\n")
155
+ f"import time. Not judged: {' and '.join(parts)}. `report architecture` lists "
156
+ f"them. {s['type_checking_imports']} import(s) under `TYPE_CHECKING` read as "
157
+ f"never running._\n")
@@ -144,6 +144,7 @@ def render_docs(query: Query) -> str:
144
144
  # -- architecture caveats (the honest health section) -------------------
145
145
  cycles = query.import_cycles()
146
146
  lazy = query.lazy_import_cycles()
147
+ type_only = query.type_only_import_cycles()
147
148
  lay = query.layers()
148
149
  gods = query.hotspots()["god_classes"]
149
150
  out.append("## Architecture notes")
@@ -156,9 +157,12 @@ def render_docs(query: Query) -> str:
156
157
  # proof of acyclicity, which this map cannot support.
157
158
  out.append("- No import cycle found in the eager import graph.")
158
159
  if lazy:
159
- out.append(f"- **{len(lazy)} dependency cycle(s) closed only by a non-eager "
160
- f"import** (function-local, or under `if TYPE_CHECKING:`) — deliberate, "
161
- f"and still mutual coupling.")
160
+ out.append(f"- **{len(lazy)} dependency cycle(s) closed only by a function-local "
161
+ f"import** — deliberate, and still runtime coupling.")
162
+ if type_only:
163
+ out.append(f"- **{len(type_only)} dependency cycle(s) closed only by an import under "
164
+ f"`if TYPE_CHECKING:`** — the modules name each other's types and have no "
165
+ f"runtime dependency.")
162
166
  if lay["violations"]:
163
167
  out.append("- **Layer violations (mutual dependency):** "
164
168
  + ", ".join(f"{a} ↔ {b}" for a, b in lay["violations"]))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.13
3
+ Version: 0.0.14
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
@@ -85,7 +85,7 @@ layers core 9 · data 12 · indicators 16 · analysis 42 · visualizatio
85
85
  analysis → core 38 edges · indicators → core 22 · data → core 13
86
86
  violation ⚠ analysis ↔ core — one backward edge, written inside a function
87
87
  cycle pipeline → cache → pipeline — the classic Python import-order landmine
88
- lazy cycles 40 more, closed only by a non-eager import (function-local or `TYPE_CHECKING`) — not import-time failures,
88
+ lazy cycles 40 more, closed only by a function-local import — not import-time failures,
89
89
  still mutual coupling: neither module can be extracted without the other
90
90
  coupling core.logging_config Ca 96 — a breaking change here reaches 96 modules
91
91
  concentration ZoneVisualizer 35 methods, worst function CC 66 / MI 12.5
@@ -117,6 +117,7 @@ tests/test_r1c45_repeat_union.py
117
117
  tests/test_r1c46_shadowed_definitions.py
118
118
  tests/test_r1c47_incremental_resample.py
119
119
  tests/test_r1c48_type_checking_imports.py
120
+ tests/test_r1c49_type_only_cycles.py
120
121
  tests/test_r1c4_complexity.py
121
122
  tests/test_r1c5_apidiff.py
122
123
  tests/test_r1c6_pack.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.13"
12
+ version = "0.0.14"
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
@@ -77,8 +77,10 @@ def test_build_architecture_has_all_sections(q):
77
77
  a = build_architecture(q)
78
78
  # R1-C29 added two: `lazy_cycles` (dependency cycles closed only by a function-local
79
79
  # import — real coupling, not an import-time failure) and `import_map` (how many
80
+ # imports each scope contributed). R1-C49 added `type_only_cycles`, the third kind —
81
+ # closed only by an import under `if TYPE_CHECKING:`, i.e. no runtime dependency at all —
80
82
  # imports of each scope the map was built from, always, zero included).
81
- assert set(a) == {"target", "cycles", "lazy_cycles", "import_map",
83
+ assert set(a) == {"target", "cycles", "lazy_cycles", "type_only_cycles", "import_map",
82
84
  "layers", "coupling", "hotspots"}
83
85
  assert a["target"] == "dispatchpkg"
84
86
 
@@ -70,7 +70,9 @@ def test_cycle_detection(q):
70
70
  pair = frozenset({PIPELINE, "bquant.analysis.zones.cache"})
71
71
  assert pair not in {frozenset(c) for c in q.import_cycles()}, \
72
72
  "an import under TYPE_CHECKING must not close an eager cycle"
73
- assert pair in {frozenset(c) for c in q.lazy_import_cycles()}
73
+ assert pair not in {frozenset(c) for c in q.lazy_import_cycles()}, \
74
+ "nor a runtime one: R1-C49, the pair has no runtime dependency at all"
75
+ assert pair in {frozenset(c) for c in q.type_only_import_cycles()}
74
76
  assert q.import_map()["type_checking"] >= 1
75
77
 
76
78
 
@@ -163,13 +163,13 @@ def test_architecture_never_claims_acyclic(lazy_cycle):
163
163
  assert "acyclic" not in md, "a partial map cannot support a property claim"
164
164
  assert "none found in the eager import graph" in md
165
165
  assert "function-local and 0 `TYPE_CHECKING` import(s)" in md
166
- assert "Dependency cycles closed only by a non-eager import (function-local, or under `if TYPE_CHECKING:`): 1" in md
166
+ assert "Dependency cycles closed only by a function-local import: 1" in md
167
167
 
168
168
 
169
169
  def test_dependencies_report_never_claims_acyclic(lazy_cycle):
170
170
  md = render_dependencies(Query(extract(str(lazy_cycle))))
171
171
  assert "acyclic" not in md
172
- assert "close through a non-eager import" in md
172
+ assert "close through a function-local import" in md
173
173
 
174
174
 
175
175
  def test_architecture_payload_carries_both_kinds_and_the_counts(lazy_cycle):
@@ -75,7 +75,7 @@ def test_the_passing_report_names_what_was_not_judged(lazy_cycle):
75
75
  out = render_check(lazy_cycle, contract, violations)
76
76
  assert "Contract satisfied" in out
77
77
  assert "eager import graph only" in out
78
- assert "**1** dependency cycle(s)" in out
78
+ assert "**1** cycle(s) closed only by a function-local import" in out
79
79
 
80
80
 
81
81
  def test_the_structured_payload_carries_it_too(lazy_cycle):
@@ -92,8 +92,9 @@ def test_it_is_declared_even_when_there_is_nothing_to_declare(eager_only):
92
92
  contract, violations = _run(eager_only, {"no_cycles": True})
93
93
  scope = build_check(eager_only, contract, violations)["scope"]
94
94
  assert len(scope) == 1 and scope[0]["count"] == 0
95
- assert "no dependency cycle is closed only by a non-eager import" \
96
- in render_check(eager_only, contract, violations)
95
+ md = render_check(eager_only, contract, violations)
96
+ assert "**0** cycle(s) closed only by a function-local import" in md
97
+ assert "**0** closed only by an import under `if TYPE_CHECKING:`" in md
97
98
 
98
99
 
99
100
  def test_a_failing_run_carries_the_same_disclosure(lazy_cycle):
@@ -129,7 +130,14 @@ def test_the_opt_in_alone_is_a_contract(lazy_cycle):
129
130
 
130
131
 
131
132
  def test_opting_in_removes_the_disclaimer_because_nothing_is_left_out(lazy_cycle):
133
+ """R1-C49: there are three kinds now, so "nothing is left out" needs all three rules.
134
+ With only `no_lazy_cycles` added, the type-only cycles are still unjudged and the
135
+ disclosure must keep saying so — a gated kind drops out of it, the others do not."""
132
136
  contract, violations = _run(lazy_cycle, {"no_cycles": True, "no_lazy_cycles": True})
137
+ scope = build_check(lazy_cycle, contract, violations)["scope"]
138
+ assert len(scope) == 1 and scope[0]["lazy_gated"] is True and scope[0]["lazy"] == 0
139
+ contract, violations = _run(lazy_cycle, {"no_cycles": True, "no_lazy_cycles": True,
140
+ "no_type_only_cycles": True})
133
141
  assert build_check(lazy_cycle, contract, violations)["scope"] == []
134
142
  assert "not** judged" not in render_check(lazy_cycle, contract, violations)
135
143
 
@@ -127,10 +127,13 @@ def test_a_pair_imported_both_ways_reads_eager(tree):
127
127
 
128
128
  # -- D4 / D5: the graphs and the map ----------------------------------------------------
129
129
 
130
- def test_the_cycle_is_lazy_not_eager(tree):
130
+ def test_the_cycle_is_not_eager(tree):
131
+ """R1-C49 moved it one further: not eager (this fix), and not lazy either — the pair
132
+ has no runtime dependency at all, so it is the third kind."""
131
133
  q = tree[2]
132
134
  assert q.import_cycles() == [], "the gate's defect: this used to be the one eager cycle"
133
- assert [sorted(c) for c in q.lazy_import_cycles()] == [["tcpkg.cache", "tcpkg.pipeline"]]
135
+ assert q.lazy_import_cycles() == []
136
+ assert [sorted(c) for c in q.type_only_import_cycles()] == [["tcpkg.cache", "tcpkg.pipeline"]]
134
137
 
135
138
 
136
139
  def test_import_map_names_the_scope_always(tree):
@@ -148,9 +151,9 @@ def test_every_consumer_says_which_imports_it_did_not_judge(tree):
148
151
  assert a["import_map"]["type_checking"] == 3
149
152
  md = render_architecture(q)
150
153
  assert "3 `TYPE_CHECKING` import(s)" in md
151
- assert "closed only by a non-eager import (function-local, or under `if TYPE_CHECKING:`): 1" in md
154
+ assert "closed only by an import under `if TYPE_CHECKING:`: 1" in md
152
155
  assert "`TYPE_CHECKING` import(s)" in render_dependencies(q)
153
- assert "non-eager import" in render_docs(q)
156
+ assert "closed only by an import under `if TYPE_CHECKING:`" in render_docs(q)
154
157
 
155
158
 
156
159
  def test_the_gate_is_green_and_names_what_it_read(tree):
@@ -161,15 +164,18 @@ def test_the_gate_is_green_and_names_what_it_read(tree):
161
164
  payload = build_check(q, contract, violations)
162
165
  assert payload["ok"] is True
163
166
  assert payload["scope"][0]["count"] == 1
167
+ assert payload["scope"][0]["type_only"] == 1 and payload["scope"][0]["lazy"] == 0
164
168
  assert payload["scope"][0]["type_checking_imports"] == 3
165
169
  md = render_check(q, contract, violations)
166
170
  assert "3 import(s) under `TYPE_CHECKING` read as never running" in md
167
171
 
168
172
 
169
- def test_no_lazy_cycles_still_gates_the_coupling(tree):
170
- contract = arch.ArchitectureContract(no_lazy_cycles=True)
171
- v = arch.check_contract(tree[2], contract)
172
- assert [x.rule for x in v] == ["no_lazy_cycles"]
173
+ def test_the_type_only_rule_gates_the_coupling(tree):
174
+ """R1-C49: `no_lazy_cycles` is about a lazy import used to walk around `no_cycles`,
175
+ which this is not; the opt-in rule for the type layer is the one that fires."""
176
+ assert arch.check_contract(tree[2], arch.ArchitectureContract(no_lazy_cycles=True)) == []
177
+ v = arch.check_contract(tree[2], arch.ArchitectureContract(no_type_only_cycles=True))
178
+ assert [x.rule for x in v] == ["no_type_only_cycles"]
173
179
  assert "under `if TYPE_CHECKING:`" in v[0].summary
174
180
 
175
181