codmap 0.0.12__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.12/codmap.egg-info → codmap-0.0.14}/PKG-INFO +1 -1
  2. {codmap-0.0.12 → codmap-0.0.14}/codemap/arch.py +24 -2
  3. {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/griffe_extractor.py +76 -15
  4. {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/union.py +22 -9
  5. {codmap-0.0.12 → codmap-0.0.14}/codemap/query.py +39 -8
  6. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/architecture.py +27 -7
  7. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/audit.py +9 -5
  8. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/check.py +31 -15
  9. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/livingdocs.py +6 -1
  10. {codmap-0.0.12 → codmap-0.0.14/codmap.egg-info}/PKG-INFO +1 -1
  11. {codmap-0.0.12 → codmap-0.0.14}/codmap.egg-info/SOURCES.txt +2 -0
  12. {codmap-0.0.12 → codmap-0.0.14}/pyproject.toml +1 -1
  13. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m16_architecture.py +3 -1
  14. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m1_query.py +11 -4
  15. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c29_lazy_imports.py +4 -4
  16. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c30f2_check_scope.py +11 -3
  17. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c45_repeat_union.py +49 -0
  18. codmap-0.0.14/tests/test_r1c48_type_checking_imports.py +193 -0
  19. codmap-0.0.14/tests/test_r1c49_type_only_cycles.py +206 -0
  20. {codmap-0.0.12 → codmap-0.0.14}/LICENSE +0 -0
  21. {codmap-0.0.12 → codmap-0.0.14}/README.md +0 -0
  22. {codmap-0.0.12 → codmap-0.0.14}/codemap/__init__.py +0 -0
  23. {codmap-0.0.12 → codmap-0.0.14}/codemap/apidiff.py +0 -0
  24. {codmap-0.0.12 → codmap-0.0.14}/codemap/cli.py +0 -0
  25. {codmap-0.0.12 → codmap-0.0.14}/codemap/diagnostics.py +0 -0
  26. {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/__init__.py +0 -0
  27. {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/attrflow.py +0 -0
  28. {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/behavior.py +0 -0
  29. {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/dataflow.py +0 -0
  30. {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/dispatch.py +0 -0
  31. {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/gsource.py +0 -0
  32. {codmap-0.0.12 → codmap-0.0.14}/codemap/extract/roots.py +0 -0
  33. {codmap-0.0.12 → codmap-0.0.14}/codemap/freshness.py +0 -0
  34. {codmap-0.0.12 → codmap-0.0.14}/codemap/incremental.py +0 -0
  35. {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/__init__.py +0 -0
  36. {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/base.py +0 -0
  37. {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/cocoindex.py +0 -0
  38. {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/gate.py +0 -0
  39. {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/gitnexus.py +0 -0
  40. {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/registry.py +0 -0
  41. {codmap-0.0.12 → codmap-0.0.14}/codemap/integrations/transport.py +0 -0
  42. {codmap-0.0.12 → codmap-0.0.14}/codemap/model.py +0 -0
  43. {codmap-0.0.12 → codmap-0.0.14}/codemap/provenance.py +0 -0
  44. {codmap-0.0.12 → codmap-0.0.14}/codemap/scope.py +0 -0
  45. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/__init__.py +0 -0
  46. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/_scip_pb2.py +0 -0
  47. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/api_surface.py +0 -0
  48. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/apidiff.py +0 -0
  49. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/ctags.py +0 -0
  50. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/impact.py +0 -0
  51. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/limits.py +0 -0
  52. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/mcp_server.py +0 -0
  53. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/mermaid.py +0 -0
  54. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/pack.py +0 -0
  55. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/rag.py +0 -0
  56. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/review.py +0 -0
  57. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/scip.py +0 -0
  58. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/semantic.py +0 -0
  59. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/server.py +0 -0
  60. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/session.py +0 -0
  61. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/subsystems.py +0 -0
  62. {codmap-0.0.12 → codmap-0.0.14}/codemap/serve/vault.py +0 -0
  63. {codmap-0.0.12 → codmap-0.0.14}/codemap/store.py +0 -0
  64. {codmap-0.0.12 → codmap-0.0.14}/codemap/tomlio.py +0 -0
  65. {codmap-0.0.12 → codmap-0.0.14}/codemap/watch.py +0 -0
  66. {codmap-0.0.12 → codmap-0.0.14}/codmap.egg-info/dependency_links.txt +0 -0
  67. {codmap-0.0.12 → codmap-0.0.14}/codmap.egg-info/entry_points.txt +0 -0
  68. {codmap-0.0.12 → codmap-0.0.14}/codmap.egg-info/requires.txt +0 -0
  69. {codmap-0.0.12 → codmap-0.0.14}/codmap.egg-info/top_level.txt +0 -0
  70. {codmap-0.0.12 → codmap-0.0.14}/setup.cfg +0 -0
  71. {codmap-0.0.12 → codmap-0.0.14}/tests/test_epistemic.py +0 -0
  72. {codmap-0.0.12 → codmap-0.0.14}/tests/test_gitnexus_router.py +0 -0
  73. {codmap-0.0.12 → codmap-0.0.14}/tests/test_impact_depth.py +0 -0
  74. {codmap-0.0.12 → codmap-0.0.14}/tests/test_integrations.py +0 -0
  75. {codmap-0.0.12 → codmap-0.0.14}/tests/test_issue3_serve_freshness.py +0 -0
  76. {codmap-0.0.12 → codmap-0.0.14}/tests/test_livingdocs.py +0 -0
  77. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m0_api_surface.py +0 -0
  78. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m11_argcontract.py +0 -0
  79. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m12_dataflow.py +0 -0
  80. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m15_review.py +0 -0
  81. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m17_mcp.py +0 -0
  82. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m18_freshness.py +0 -0
  83. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m1_5_semantics.py +0 -0
  84. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m2_views.py +0 -0
  85. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m32_watch.py +0 -0
  86. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m3_serve.py +0 -0
  87. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m4_behavior.py +0 -0
  88. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m5_deep.py +0 -0
  89. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m6_repo_scope.py +0 -0
  90. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m7_dispatch.py +0 -0
  91. {codmap-0.0.12 → codmap-0.0.14}/tests/test_m9_family.py +0 -0
  92. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  93. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c13_grep_vs_graph.py +0 -0
  94. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c16_semantic.py +0 -0
  95. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c20_attribute_edges.py +0 -0
  96. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c21_flat_layout.py +0 -0
  97. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c22_source_visible_refs.py +0 -0
  98. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c23_hard_python.py +0 -0
  99. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c24_test_mapping.py +0 -0
  100. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c25_provenance.py +0 -0
  101. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c26_deep_union.py +0 -0
  102. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c27_config_honesty.py +0 -0
  103. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c28_limit_envelope.py +0 -0
  104. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c2_ctags.py +0 -0
  105. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c30_local_import_calls.py +0 -0
  106. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c30f1_reexport_calls.py +0 -0
  107. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c31_path_origin.py +0 -0
  108. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c32_report_json.py +0 -0
  109. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c33_query_signature.py +0 -0
  110. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c34_signature_kinds.py +0 -0
  111. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c35_contract_location.py +0 -0
  112. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c36_target_resolution.py +0 -0
  113. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c37_rule_mutation.py +0 -0
  114. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c38_tool_drift.py +0 -0
  115. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c3_arch_contract.py +0 -0
  116. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c3_dogfood.py +0 -0
  117. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c41_scope_membership.py +0 -0
  118. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c42_deep_tier_stability.py +0 -0
  119. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c43_incremental_splice.py +0 -0
  120. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c44_absent_answers.py +0 -0
  121. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c46_shadowed_definitions.py +0 -0
  122. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c47_incremental_resample.py +0 -0
  123. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c4_complexity.py +0 -0
  124. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c5_apidiff.py +0 -0
  125. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c6_pack.py +0 -0
  126. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c7_edge_vocab.py +0 -0
  127. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c8_deadcode.py +0 -0
  128. {codmap-0.0.12 → codmap-0.0.14}/tests/test_r1c9_incremental.py +0 -0
  129. {codmap-0.0.12 → codmap-0.0.14}/tests/test_scip_export.py +0 -0
  130. {codmap-0.0.12 → codmap-0.0.14}/tests/test_scope.py +0 -0
  131. {codmap-0.0.12 → 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.12
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
@@ -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
 
@@ -211,6 +215,24 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
211
215
  modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
212
216
  ))
213
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:`",
233
+ modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
234
+ ))
235
+
214
236
  # -- exhaustive: every core module's layer must be declared -----------------
215
237
  if contract.exhaustive and contract.layers:
216
238
  declared = set(contract.layers)
@@ -265,8 +265,9 @@ def _input_report(graph, pkg_dir: Path, root: Path, walk) -> dict:
265
265
  return report
266
266
 
267
267
 
268
- def _source_import_targets(module) -> list[tuple[str, str]]:
269
- """``(target, scope)`` for the imports griffe's module-level map does not carry.
268
+ def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
269
+ """``([(target, scope)], eager targets)`` — the imports griffe's module-level map does
270
+ not carry, and the targets it carries correctly.
270
271
 
271
272
  Two families, one traversal, one parse — griffe records neither, and both used to be
272
273
  invisible to the import graph:
@@ -282,6 +283,11 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
282
283
  - **imports written in a class body** → scope ``"module"``. They run at
283
284
  class-definition time, i.e. at import time, so they are ordinary eager dependencies
284
285
  and *can* close a real import cycle. griffe does not record them either (measured).
286
+ - **imports under `if TYPE_CHECKING:`** (R1-C48 / issue #18) → scope
287
+ ``"type_checking"``. griffe *does* record these, as module-level — it has no notion
288
+ of a condition that is always false at run time — so the second value returned is
289
+ the set of targets imported at plain module level: the caller demotes griffe's entry
290
+ for a target that appears only under ``TYPE_CHECKING``.
285
291
 
286
292
  Cost discipline, and it is not theoretical: the first version of this walked every
287
293
  function's subtree separately, which is quadratic in nesting and cost the dogfood
@@ -293,13 +299,13 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
293
299
  try:
294
300
  src = module.source
295
301
  except Exception: # no source (namespace dir, synthetic)
296
- return []
302
+ return [], set()
297
303
  if not NESTED_IMPORT_HINT.search(src):
298
- return []
304
+ return [], set()
299
305
  try:
300
306
  tree = ast.parse(src)
301
307
  except (SyntaxError, ValueError):
302
- return [] # unreadable: D2's report owns this file
308
+ return [], set() # unreadable: D2's report owns this file
303
309
  modpath = module.canonical_path
304
310
  f = module_file(module)
305
311
  is_pkg = f is not None and f.name == "__init__.py"
@@ -312,9 +318,11 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
312
318
  return node.module or ""
313
319
 
314
320
  out: list[tuple[str, str]] = []
321
+ eager: set[str] = set() # targets imported at plain module level (griffe has them)
315
322
 
316
323
  def visit(node, scope: str | None) -> None:
317
- """``scope`` is None at module level (griffe has those), else module|function."""
324
+ """``scope`` is None at module level (griffe has those), else
325
+ module|function|type_checking."""
318
326
  for child in ast.iter_child_nodes(node):
319
327
  if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
320
328
  visit(child, "function")
@@ -322,9 +330,21 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
322
330
  # A class body runs at import time — unless we are already inside a
323
331
  # function, in which case the whole thing does not.
324
332
  visit(child, "function" if scope == "function" else "module")
333
+ elif isinstance(child, ast.If) and scope in (None, "type_checking") \
334
+ and (tc := _type_checking_branches(child)) is not None:
335
+ # R1-C48 (issue #18): `if TYPE_CHECKING:` never runs. Its body is a third
336
+ # scope; its `else` is ordinary module level. Inside a function the whole
337
+ # `if` is function-local already and is not looked at.
338
+ body_scope, else_scope = tc
339
+ for stmt in child.body:
340
+ visit_stmt(stmt, body_scope if body_scope else scope)
341
+ for stmt in child.orelse:
342
+ visit_stmt(stmt, else_scope if else_scope else scope)
325
343
  elif isinstance(child, ast.Import):
326
344
  if scope is not None:
327
345
  out.extend((a.name, scope) for a in child.names)
346
+ else:
347
+ eager.update(a.name for a in child.names)
328
348
  elif isinstance(child, ast.ImportFrom):
329
349
  target = resolve(child)
330
350
  if not target:
@@ -333,6 +353,8 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
333
353
  if scope is None:
334
354
  if star: # the D3 case: griffe records nothing
335
355
  out.append((target, "module"))
356
+ eager.add(target)
357
+ eager.update(f"{target}.{a.name}" for a in child.names if a.name != "*")
336
358
  continue
337
359
  # `from pkg.mod import name` → keep the member paths so the resolver walks
338
360
  # down to the containing module exactly as it does for the module-level
@@ -343,19 +365,49 @@ def _source_import_targets(module) -> list[tuple[str, str]]:
343
365
  else:
344
366
  visit(child, scope)
345
367
 
368
+ def visit_stmt(stmt, scope: str | None) -> None:
369
+ """Route one statement through ``visit`` as if it were the only child."""
370
+ holder = ast.Module(body=[stmt], type_ignores=[])
371
+ visit(holder, scope)
372
+
346
373
  visit(tree, None)
347
- return out
374
+ return out, eager
375
+
376
+
377
+ def _type_checking_branches(node: ast.If) -> tuple[str | None, str | None] | None:
378
+ """``(scope of body, scope of else)`` for a recognised ``TYPE_CHECKING`` test, else None.
379
+
380
+ Recognised, narrowly (design D3): the bare name ``TYPE_CHECKING``, any attribute named
381
+ ``TYPE_CHECKING`` (``typing.TYPE_CHECKING``, ``t.TYPE_CHECKING``), and ``not`` of either,
382
+ which swaps the branches. A compound test (``TYPE_CHECKING or X``) is *not* recognised
383
+ and stays eager: a condition the tool cannot read is judged strictly, never leniently.
384
+ ``None`` in a slot means "the enclosing scope" (module level).
385
+ """
386
+ test, negated = node.test, False
387
+ if isinstance(test, ast.UnaryOp) and isinstance(test.op, ast.Not):
388
+ test, negated = test.operand, True
389
+ is_tc = ((isinstance(test, ast.Name) and test.id == "TYPE_CHECKING")
390
+ or (isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING"))
391
+ if not is_tc:
392
+ return None
393
+ return (None, "type_checking") if negated else ("type_checking", None)
348
394
 
349
395
 
350
396
  def _collect(graph, obj, root, target_pkg, walk) -> None:
351
397
  if obj.kind.value == "module":
352
398
  _claim(obj, walk) # the root claims its own path before any member is walked
353
399
  _add_node(graph, obj, root)
400
+ # R1-C23/D3 (star imports) + R1-C29 (function-local and class-body imports) +
401
+ # R1-C48 (`if TYPE_CHECKING:`): everything griffe's module-level map does not
402
+ # carry, or carries under the wrong scope, in one parse.
403
+ nested, eager = _source_import_targets(obj)
404
+ type_checking = {tgt for tgt, scope in nested if scope == "type_checking"}
354
405
  for name, tgt in (obj.imports or {}).items():
355
- walk.imports.append((obj.canonical_path, tgt, "module"))
356
- # R1-C23/D3 (star imports) + R1-C29 (function-local and class-body imports):
357
- # everything griffe's module-level map does not carry, in one parse.
358
- for tgt, scope in _source_import_targets(obj):
406
+ # griffe files an import under `if TYPE_CHECKING:` as module-level; it never
407
+ # runs. Demote it unless the same target is also imported eagerly (D2).
408
+ scope = "type_checking" if tgt in type_checking and tgt not in eager else "module"
409
+ walk.imports.append((obj.canonical_path, tgt, scope))
410
+ for tgt, scope in nested:
359
411
  walk.imports.append((obj.canonical_path, tgt, scope))
360
412
  for name, member in obj.members.items():
361
413
  if member.is_alias:
@@ -429,6 +481,15 @@ def _emit_decorated_by(graph, obj) -> None:
429
481
 
430
482
  # -- pass 2: resolve export + import edges against known nodes ----------------
431
483
 
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}
491
+
492
+
432
493
  def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
433
494
  module_ids = sorted(
434
495
  (n.id for n in graph.nodes.values() if n.kind == "module"), key=len, reverse=True
@@ -457,7 +518,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
457
518
  # the eager import it is. `scope` only ever *weakens* to "function" for a pair that
458
519
  # has no module-level import at all — the edge says how the dependency is reached at
459
520
  # its earliest, never how it happens to appear last in the walk.
460
- ordered = sorted(imports, key=lambda t: t[2] == "function")
521
+ ordered = sorted(imports, key=lambda t: _SCOPE_RANK.get(t[2], 0))
461
522
 
462
523
  # pass A — package-qualified targets. Exact, and run first so that a pair reachable
463
524
  # both ways is recorded as exact rather than inferred.
@@ -472,7 +533,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
472
533
  if key in seen:
473
534
  continue
474
535
  seen.add(key)
475
- extras = {"scope": "function"} if scope == "function" else {}
536
+ extras = {"scope": scope} if scope != "module" else {}
476
537
  graph.add_edge(Edge("imports", src_module, tgt_module, extras=extras))
477
538
 
478
539
  # pass B — flat layout (R1-C21): sibling modules importing each other by bare name
@@ -489,8 +550,8 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
489
550
  continue
490
551
  seen.add(key)
491
552
  extras = {"resolution": "flat"}
492
- if scope == "function":
493
- extras["scope"] = "function"
553
+ if scope != "module":
554
+ extras["scope"] = scope
494
555
  graph.add_edge(Edge("imports", src_module, tgt_module, extras=extras))
495
556
 
496
557
 
@@ -36,7 +36,10 @@ An edge seen in fewer than N runs carries ``extras.seen: k``; nothing is written
36
36
  ``k == N``, so a single-sample build's edges are byte-identical to what they were. Node
37
37
  counters (``calls`` / ``attr_access``) count *sites*, which dedupe into edges — the
38
38
  measured node had three counter variants behind one flapping edge — so per node the
39
- variant with the fewest ``unresolved`` is kept (tie: most ``resolved``, tie: first run).
39
+ variant with the fewest ``unresolved`` is kept (tie: most ``resolved``, tie: the serialized
40
+ counter). Every choice between variants of one key is a **total order** over the candidates
41
+ (:func:`_rank`, :func:`_counter_rank`): the merged graph is a function of the set of
42
+ samples, and the same samples in any order produce the same bytes.
40
43
 
41
44
  **Every sample is a fresh process.** The share above was measured across processes —
42
45
  the consumer's 175 builds and the eight here. Repeating the behavioural layer *inside*
@@ -74,20 +77,30 @@ def _identity(e: Edge) -> tuple:
74
77
  json.dumps(e.extras, sort_keys=True, ensure_ascii=False))
75
78
 
76
79
 
77
- def _deeper(new: Edge, current: Edge) -> bool:
78
- return (new.extras.get("resolution") == "deep"
79
- and current.extras.get("resolution") != "deep")
80
+ def _rank(e: Edge) -> tuple:
81
+ """Total order among the variants of one identity key: ``deep`` beats any other
82
+ resolution, more call sites beat fewer, and the serialized ``extras`` break what is
83
+ left — so the winner is a function of the *set* of samples, never of the order they
84
+ arrived in. The consumer's hint on codemap#17: where two candidates are equally
85
+ legitimate, 'first one wins' is a dependence on arrival order, and one that a probe
86
+ permuting the *output* can never see."""
87
+ ex = e.extras
88
+ return (ex.get("resolution") == "deep", ex.get("callsites", 0),
89
+ json.dumps(ex, sort_keys=True, ensure_ascii=False))
90
+
91
+
92
+ def _counter_rank(c: dict) -> tuple:
93
+ return (-c.get("unresolved", 0), c.get("resolved", 0), json.dumps(c, sort_keys=True))
80
94
 
81
95
 
82
96
  def _better_counter(a: dict | None, b: dict | None) -> dict | None:
83
- """The counter variant that resolved more sites (``None`` never beats a value)."""
97
+ """The counter variant that resolved more sites (``None`` never beats a value);
98
+ a tie is broken by the serialized counter, not by which sample came first."""
84
99
  if a is None:
85
100
  return b
86
101
  if b is None:
87
102
  return a
88
- ka = (-a.get("unresolved", 0), a.get("resolved", 0))
89
- kb = (-b.get("unresolved", 0), b.get("resolved", 0))
90
- return b if kb > ka else a
103
+ return b if _counter_rank(b) > _counter_rank(a) else a
91
104
 
92
105
 
93
106
  def merge_samples(samples: list[Graph]) -> tuple[Graph, dict]:
@@ -116,7 +129,7 @@ def merge_samples(samples: list[Graph]) -> tuple[Graph, dict]:
116
129
  else:
117
130
  if key not in seen_this_run:
118
131
  entry[0] += 1
119
- if _deeper(e, entry[1]):
132
+ if _rank(e) > _rank(entry[1]):
120
133
  entry[1] = e
121
134
  seen_this_run.add(key)
122
135
 
@@ -119,17 +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
- self._import_scopes = {"module": 0, "function": 0}
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()
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
- if e.extras.get("scope") == "function":
131
- self._import_scopes["function"] += 1
134
+ scope = e.extras.get("scope")
135
+ if scope in ("function", "type_checking"):
136
+ # R1-C29: runs when the function runs; R1-C48: never runs. Neither
137
+ # is an import-time edge — but only the first is a runtime one.
138
+ self._import_scopes[scope] += 1
139
+ if scope == "function":
140
+ self._imports_runtime.add_edge(e.source, e.target)
132
141
  else:
142
+ self._imports_runtime.add_edge(e.source, e.target)
133
143
  self._imports_eager.add_edge(e.source, e.target)
134
144
  self._import_scopes["module"] += 1
135
145
  # export edges: name -> [target definition paths]
@@ -888,12 +898,32 @@ class Query:
888
898
  """Dependency cycles that close **only** through a function-local import.
889
899
 
890
900
  Not an import-time failure, and not nothing: the modules still cannot be
891
- separated, and the lazy import is the evidence someone already hit this. Before
892
- R1-C29 these were invisible — on the target this project benchmarks on, eight of
893
- 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.
894
911
  """
895
912
  eager = {frozenset(c) for c in nx.simple_cycles(self._imports_eager)}
896
- 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]
897
927
 
898
928
  def import_map(self) -> dict:
899
929
  """How much of the import graph each scope contributed (R1-C29).
@@ -904,7 +934,8 @@ class Query:
904
934
  did not look for them".
905
935
  """
906
936
  return {"module_level": self._import_scopes["module"],
907
- "function_local": self._import_scopes["function"]}
937
+ "function_local": self._import_scopes["function"],
938
+ "type_checking": self._import_scopes["type_checking"]}
908
939
 
909
940
  def orphan_modules(self, root: str | None = None) -> list[str]:
910
941
  """Modules with no incoming imports (dead-code candidates — heuristic).
@@ -18,8 +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 function-local import. 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
23
25
  import is how a developer *fixes* an import cycle, so folding the two together would
24
26
  report someone's fix as their bug, while dropping the second (what this tool did
25
27
  until issue #11) hides that the modules are still inseparable. ``import_map`` is
@@ -30,6 +32,7 @@ def build_architecture(query: Query) -> dict:
30
32
  "target": query.graph.target,
31
33
  "cycles": query.import_cycles(),
32
34
  "lazy_cycles": query.lazy_import_cycles(),
35
+ "type_only_cycles": query.type_only_import_cycles(),
33
36
  "import_map": query.import_map(),
34
37
  "layers": query.layers(),
35
38
  "coupling": query.coupling(),
@@ -78,9 +81,10 @@ def render_architecture(query: Query) -> str:
78
81
  sorted(a["cycles"], key=lambda c: (len(c), c))]
79
82
  or ["_none found in the eager import graph._"])
80
83
  out.append("")
81
- out.append(f"_Read {im['module_level']} module-level and {im['function_local']} "
82
- f"function-local import(s). Only module-level imports run at import time, "
83
- f"so only they can break on import._")
84
+ out.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
85
+ f"function-local and {im['type_checking']} `TYPE_CHECKING` import(s). Only "
86
+ f"module-level imports run at import time, so only they can break on import; "
87
+ f"an import under `if TYPE_CHECKING:` never runs._")
84
88
  out.append("")
85
89
  if a["lazy_cycles"]:
86
90
  out.append(f"### Dependency cycles closed only by a function-local import: "
@@ -88,14 +92,30 @@ def render_architecture(query: Query) -> str:
88
92
  out.append("")
89
93
  out.append("_These do **not** break at import time — the lazy import is what "
90
94
  "prevents that, and is usually deliberate. They are listed because "
91
- "the modules are still mutually dependent: neither can be extracted "
92
- "without the other._")
95
+ "the modules are still mutually dependent at run time: neither can be "
96
+ "extracted without the other._")
93
97
  out.append("")
94
98
  out.extend(f"- {' → '.join(c)} → {c[0]}" for c in
95
99
  sorted(a["lazy_cycles"], key=lambda c: (len(c), c))[:20])
96
100
  if len(a["lazy_cycles"]) > 20:
97
101
  out.append(f"- _… {len(a['lazy_cycles']) - 20} more_")
98
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("")
99
119
 
100
120
  # -- coupling -----------------------------------------------------------
101
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("")
@@ -29,11 +30,12 @@ def render_dependencies(query: Query) -> str:
29
30
  # map that would have to be complete to support it demonstrably is not.
30
31
  lines.append("_none found in the eager import graph._")
31
32
  lines.append("")
32
- lines.append(f"_Read {im['module_level']} module-level and {im['function_local']} "
33
- f"function-local import(s); only the former run at import time. "
34
- + (f"{len(lazy)} further cycle(s) close through a lazy import — real "
35
- f"coupling, not an import-time failure._" if lazy
36
- else "No cycle closes through a lazy import._"))
33
+ lines.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
34
+ f"function-local and {im['type_checking']} `TYPE_CHECKING` import(s); only "
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)._")
37
39
  lines.append("")
38
40
 
39
41
  lines.append("## Most-depended-on modules (top 15)")
@@ -61,6 +63,8 @@ def build_dependencies(query: Query) -> dict:
61
63
  "import_cycles": [list(c) for c in sorted(query.import_cycles(), key=lambda c: (len(c), c))],
62
64
  "lazy_import_cycles": [list(c) for c in
63
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))],
64
68
  "most_depended_on": [{"module": m, "imported_by": g.in_degree(m)}
65
69
  for m in ranked[:15] if g.in_degree(m)],
66
70
  "diagnostics": diagnostics(query.graph),
@@ -27,17 +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 function-local import",
37
- "count": len(lazy),
38
- "note": ("a lazy import does not run at import time, so such a cycle cannot break "
39
- "on import; it is still mutual coupling. Set `no_lazy_cycles = true` to "
40
- "gate these as well, or see `report architecture` for the list."),
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,
47
+ "type_checking_imports": query.import_map()["type_checking"],
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."),
41
53
  }]
42
54
 
43
55
 
@@ -131,11 +143,15 @@ def _render_scope(query, contract: ArchitectureContract) -> str:
131
143
  if not scope:
132
144
  return ""
133
145
  s = scope[0]
134
- if not s["count"]:
135
- return ("\n_`no_cycles` judged the eager import graph; no dependency cycle is closed "
136
- "only by a function-local import either._\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)")
137
154
  return (f"\n_`no_cycles` judged **the eager import graph only** — imports that run at "
138
- f"import time. **{s['count']}** dependency cycle(s) closed only by a "
139
- f"function-local import were **not** judged: such a cycle cannot break on "
140
- f"import, but the coupling is real. `report architecture` lists them; "
141
- f"`no_lazy_cycles = true` gates them._\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")
@@ -157,7 +158,11 @@ def render_docs(query: Query) -> str:
157
158
  out.append("- No import cycle found in the eager import graph.")
158
159
  if lazy:
159
160
  out.append(f"- **{len(lazy)} dependency cycle(s) closed only by a function-local "
160
- f"import** — deliberate, and still mutual coupling.")
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.")
161
166
  if lay["violations"]:
162
167
  out.append("- **Layer violations (mutual dependency):** "
163
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.12
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
@@ -116,6 +116,8 @@ tests/test_r1c44_absent_answers.py
116
116
  tests/test_r1c45_repeat_union.py
117
117
  tests/test_r1c46_shadowed_definitions.py
118
118
  tests/test_r1c47_incremental_resample.py
119
+ tests/test_r1c48_type_checking_imports.py
120
+ tests/test_r1c49_type_only_cycles.py
119
121
  tests/test_r1c4_complexity.py
120
122
  tests/test_r1c5_apidiff.py
121
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.12"
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