codmap 0.0.12__tar.gz → 0.0.13__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 (130) hide show
  1. {codmap-0.0.12/codmap.egg-info → codmap-0.0.13}/PKG-INFO +2 -2
  2. {codmap-0.0.12 → codmap-0.0.13}/README.md +1 -1
  3. {codmap-0.0.12 → codmap-0.0.13}/codemap/arch.py +2 -1
  4. {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/griffe_extractor.py +74 -15
  5. {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/union.py +22 -9
  6. {codmap-0.0.12 → codmap-0.0.13}/codemap/query.py +10 -5
  7. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/architecture.py +9 -7
  8. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/audit.py +7 -5
  9. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/check.py +12 -7
  10. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/livingdocs.py +3 -2
  11. {codmap-0.0.12 → codmap-0.0.13/codmap.egg-info}/PKG-INFO +2 -2
  12. {codmap-0.0.12 → codmap-0.0.13}/codmap.egg-info/SOURCES.txt +1 -0
  13. {codmap-0.0.12 → codmap-0.0.13}/pyproject.toml +1 -1
  14. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m1_query.py +9 -4
  15. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c29_lazy_imports.py +5 -5
  16. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c30f2_check_scope.py +1 -1
  17. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c45_repeat_union.py +49 -0
  18. codmap-0.0.13/tests/test_r1c48_type_checking_imports.py +187 -0
  19. {codmap-0.0.12 → codmap-0.0.13}/LICENSE +0 -0
  20. {codmap-0.0.12 → codmap-0.0.13}/codemap/__init__.py +0 -0
  21. {codmap-0.0.12 → codmap-0.0.13}/codemap/apidiff.py +0 -0
  22. {codmap-0.0.12 → codmap-0.0.13}/codemap/cli.py +0 -0
  23. {codmap-0.0.12 → codmap-0.0.13}/codemap/diagnostics.py +0 -0
  24. {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/__init__.py +0 -0
  25. {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/attrflow.py +0 -0
  26. {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/behavior.py +0 -0
  27. {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/dataflow.py +0 -0
  28. {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/dispatch.py +0 -0
  29. {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/gsource.py +0 -0
  30. {codmap-0.0.12 → codmap-0.0.13}/codemap/extract/roots.py +0 -0
  31. {codmap-0.0.12 → codmap-0.0.13}/codemap/freshness.py +0 -0
  32. {codmap-0.0.12 → codmap-0.0.13}/codemap/incremental.py +0 -0
  33. {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/__init__.py +0 -0
  34. {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/base.py +0 -0
  35. {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/cocoindex.py +0 -0
  36. {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/gate.py +0 -0
  37. {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/gitnexus.py +0 -0
  38. {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/registry.py +0 -0
  39. {codmap-0.0.12 → codmap-0.0.13}/codemap/integrations/transport.py +0 -0
  40. {codmap-0.0.12 → codmap-0.0.13}/codemap/model.py +0 -0
  41. {codmap-0.0.12 → codmap-0.0.13}/codemap/provenance.py +0 -0
  42. {codmap-0.0.12 → codmap-0.0.13}/codemap/scope.py +0 -0
  43. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/__init__.py +0 -0
  44. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/_scip_pb2.py +0 -0
  45. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/api_surface.py +0 -0
  46. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/apidiff.py +0 -0
  47. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/ctags.py +0 -0
  48. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/impact.py +0 -0
  49. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/limits.py +0 -0
  50. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/mcp_server.py +0 -0
  51. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/mermaid.py +0 -0
  52. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/pack.py +0 -0
  53. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/rag.py +0 -0
  54. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/review.py +0 -0
  55. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/scip.py +0 -0
  56. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/semantic.py +0 -0
  57. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/server.py +0 -0
  58. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/session.py +0 -0
  59. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/subsystems.py +0 -0
  60. {codmap-0.0.12 → codmap-0.0.13}/codemap/serve/vault.py +0 -0
  61. {codmap-0.0.12 → codmap-0.0.13}/codemap/store.py +0 -0
  62. {codmap-0.0.12 → codmap-0.0.13}/codemap/tomlio.py +0 -0
  63. {codmap-0.0.12 → codmap-0.0.13}/codemap/watch.py +0 -0
  64. {codmap-0.0.12 → codmap-0.0.13}/codmap.egg-info/dependency_links.txt +0 -0
  65. {codmap-0.0.12 → codmap-0.0.13}/codmap.egg-info/entry_points.txt +0 -0
  66. {codmap-0.0.12 → codmap-0.0.13}/codmap.egg-info/requires.txt +0 -0
  67. {codmap-0.0.12 → codmap-0.0.13}/codmap.egg-info/top_level.txt +0 -0
  68. {codmap-0.0.12 → codmap-0.0.13}/setup.cfg +0 -0
  69. {codmap-0.0.12 → codmap-0.0.13}/tests/test_epistemic.py +0 -0
  70. {codmap-0.0.12 → codmap-0.0.13}/tests/test_gitnexus_router.py +0 -0
  71. {codmap-0.0.12 → codmap-0.0.13}/tests/test_impact_depth.py +0 -0
  72. {codmap-0.0.12 → codmap-0.0.13}/tests/test_integrations.py +0 -0
  73. {codmap-0.0.12 → codmap-0.0.13}/tests/test_issue3_serve_freshness.py +0 -0
  74. {codmap-0.0.12 → codmap-0.0.13}/tests/test_livingdocs.py +0 -0
  75. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m0_api_surface.py +0 -0
  76. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m11_argcontract.py +0 -0
  77. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m12_dataflow.py +0 -0
  78. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m15_review.py +0 -0
  79. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m16_architecture.py +0 -0
  80. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m17_mcp.py +0 -0
  81. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m18_freshness.py +0 -0
  82. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m1_5_semantics.py +0 -0
  83. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m2_views.py +0 -0
  84. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m32_watch.py +0 -0
  85. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m3_serve.py +0 -0
  86. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m4_behavior.py +0 -0
  87. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m5_deep.py +0 -0
  88. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m6_repo_scope.py +0 -0
  89. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m7_dispatch.py +0 -0
  90. {codmap-0.0.12 → codmap-0.0.13}/tests/test_m9_family.py +0 -0
  91. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  92. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c13_grep_vs_graph.py +0 -0
  93. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c16_semantic.py +0 -0
  94. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c20_attribute_edges.py +0 -0
  95. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c21_flat_layout.py +0 -0
  96. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c22_source_visible_refs.py +0 -0
  97. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c23_hard_python.py +0 -0
  98. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c24_test_mapping.py +0 -0
  99. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c25_provenance.py +0 -0
  100. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c26_deep_union.py +0 -0
  101. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c27_config_honesty.py +0 -0
  102. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c28_limit_envelope.py +0 -0
  103. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c2_ctags.py +0 -0
  104. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c30_local_import_calls.py +0 -0
  105. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c30f1_reexport_calls.py +0 -0
  106. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c31_path_origin.py +0 -0
  107. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c32_report_json.py +0 -0
  108. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c33_query_signature.py +0 -0
  109. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c34_signature_kinds.py +0 -0
  110. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c35_contract_location.py +0 -0
  111. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c36_target_resolution.py +0 -0
  112. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c37_rule_mutation.py +0 -0
  113. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c38_tool_drift.py +0 -0
  114. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c3_arch_contract.py +0 -0
  115. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c3_dogfood.py +0 -0
  116. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c41_scope_membership.py +0 -0
  117. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c42_deep_tier_stability.py +0 -0
  118. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c43_incremental_splice.py +0 -0
  119. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c44_absent_answers.py +0 -0
  120. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c46_shadowed_definitions.py +0 -0
  121. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c47_incremental_resample.py +0 -0
  122. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c4_complexity.py +0 -0
  123. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c5_apidiff.py +0 -0
  124. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c6_pack.py +0 -0
  125. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c7_edge_vocab.py +0 -0
  126. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c8_deadcode.py +0 -0
  127. {codmap-0.0.12 → codmap-0.0.13}/tests/test_r1c9_incremental.py +0 -0
  128. {codmap-0.0.12 → codmap-0.0.13}/tests/test_scip_export.py +0 -0
  129. {codmap-0.0.12 → codmap-0.0.13}/tests/test_scope.py +0 -0
  130. {codmap-0.0.12 → codmap-0.0.13}/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.13
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 function-local import — not import-time failures,
88
+ lazy cycles 40 more, closed only by a non-eager import (function-local or `TYPE_CHECKING`) — 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 function-local import — not import-time failures,
52
+ lazy cycles 40 more, closed only by a non-eager import (function-local or `TYPE_CHECKING`) — 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
@@ -207,7 +207,8 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
207
207
  worst = sorted(lazy, key=lambda c: (len(c), c))
208
208
  violations.append(Violation(
209
209
  "no_lazy_cycles",
210
- f"{len(lazy)} dependency cycle(s) closed only by a function-local import",
210
+ f"{len(lazy)} dependency cycle(s) closed only by a non-eager import "
211
+ f"(function-local, or under `if TYPE_CHECKING:`)",
211
212
  modules=tuple(" → ".join(c) + " → " + c[0] for c in worst),
212
213
  ))
213
214
 
@@ -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,13 @@ def _emit_decorated_by(graph, obj) -> None:
429
481
 
430
482
  # -- pass 2: resolve export + import edges against known nodes ----------------
431
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}
489
+
490
+
432
491
  def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
433
492
  module_ids = sorted(
434
493
  (n.id for n in graph.nodes.values() if n.kind == "module"), key=len, reverse=True
@@ -457,7 +516,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
457
516
  # the eager import it is. `scope` only ever *weakens* to "function" for a pair that
458
517
  # has no module-level import at all — the edge says how the dependency is reached at
459
518
  # its earliest, never how it happens to appear last in the walk.
460
- ordered = sorted(imports, key=lambda t: t[2] == "function")
519
+ ordered = sorted(imports, key=lambda t: _SCOPE_RANK.get(t[2], 0))
461
520
 
462
521
  # pass A — package-qualified targets. Exact, and run first so that a pair reachable
463
522
  # both ways is recorded as exact rather than inferred.
@@ -472,7 +531,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
472
531
  if key in seen:
473
532
  continue
474
533
  seen.add(key)
475
- extras = {"scope": "function"} if scope == "function" else {}
534
+ extras = {"scope": scope} if scope != "module" else {}
476
535
  graph.add_edge(Edge("imports", src_module, tgt_module, extras=extras))
477
536
 
478
537
  # pass B — flat layout (R1-C21): sibling modules importing each other by bare name
@@ -489,8 +548,8 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
489
548
  continue
490
549
  seen.add(key)
491
550
  extras = {"resolution": "flat"}
492
- if scope == "function":
493
- extras["scope"] = "function"
551
+ if scope != "module":
552
+ extras["scope"] = scope
494
553
  graph.add_edge(Edge("imports", src_module, tgt_module, extras=extras))
495
554
 
496
555
 
@@ -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,7 +119,7 @@ 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
+ self._import_scopes = {"module": 0, "function": 0, "type_checking": 0}
123
123
  for n in graph.nodes.values():
124
124
  if n.kind == "module":
125
125
  self._imports.add_node(n.id)
@@ -127,8 +127,11 @@ class Query:
127
127
  for e in graph.edges:
128
128
  if e.type == "imports":
129
129
  self._imports.add_edge(e.source, e.target)
130
- if e.extras.get("scope") == "function":
131
- self._import_scopes["function"] += 1
130
+ scope = e.extras.get("scope")
131
+ if scope in ("function", "type_checking"):
132
+ # R1-C29: runs when the function runs; R1-C48: never runs. Neither
133
+ # is an import-time edge.
134
+ self._import_scopes[scope] += 1
132
135
  else:
133
136
  self._imports_eager.add_edge(e.source, e.target)
134
137
  self._import_scopes["module"] += 1
@@ -885,7 +888,8 @@ class Query:
885
888
  return [c for c in nx.simple_cycles(self._imports_eager)]
886
889
 
887
890
  def lazy_import_cycles(self) -> list[list[str]]:
888
- """Dependency cycles that close **only** through a function-local import.
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).
889
893
 
890
894
  Not an import-time failure, and not nothing: the modules still cannot be
891
895
  separated, and the lazy import is the evidence someone already hit this. Before
@@ -904,7 +908,8 @@ class Query:
904
908
  did not look for them".
905
909
  """
906
910
  return {"module_level": self._import_scopes["module"],
907
- "function_local": self._import_scopes["function"]}
911
+ "function_local": self._import_scopes["function"],
912
+ "type_checking": self._import_scopes["type_checking"]}
908
913
 
909
914
  def orphan_modules(self, root: str | None = None) -> list[str]:
910
915
  """Modules with no incoming imports (dead-code candidates — heuristic).
@@ -19,7 +19,8 @@ def build_architecture(query: Query) -> dict:
19
19
  """Structured whole-system overview (cycles + layers + coupling + hotspots).
20
20
 
21
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
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
23
24
  import is how a developer *fixes* an import cycle, so folding the two together would
24
25
  report someone's fix as their bug, while dropping the second (what this tool did
25
26
  until issue #11) hides that the modules are still inseparable. ``import_map`` is
@@ -78,15 +79,16 @@ def render_architecture(query: Query) -> str:
78
79
  sorted(a["cycles"], key=lambda c: (len(c), c))]
79
80
  or ["_none found in the eager import graph._"])
80
81
  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._")
82
+ out.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
83
+ f"function-local and {im['type_checking']} `TYPE_CHECKING` import(s). Only "
84
+ f"module-level imports run at import time, so only they can break on import; "
85
+ f"an import under `if TYPE_CHECKING:` never runs._")
84
86
  out.append("")
85
87
  if a["lazy_cycles"]:
86
- out.append(f"### Dependency cycles closed only by a function-local import: "
87
- f"{len(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'])}")
88
90
  out.append("")
89
- out.append("_These do **not** break at import time — the lazy import is what "
91
+ out.append("_These do **not** break at import time — the non-eager import is what "
90
92
  "prevents that, and is usually deliberate. They are listed because "
91
93
  "the modules are still mutually dependent: neither can be extracted "
92
94
  "without the other._")
@@ -29,11 +29,13 @@ def render_dependencies(query: Query) -> str:
29
29
  # map that would have to be complete to support it demonstrably is not.
30
30
  lines.append("_none found in the eager import graph._")
31
31
  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._"))
32
+ lines.append(f"_Read {im['module_level']} module-level, {im['function_local']} "
33
+ 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._"))
37
39
  lines.append("")
38
40
 
39
41
  lines.append("## Most-depended-on modules (top 15)")
@@ -33,9 +33,10 @@ def _not_judged(query, contract: ArchitectureContract) -> list[dict]:
33
33
  return [{
34
34
  "rule": "no_cycles",
35
35
  "judged": "the eager import graph — imports that run at import time",
36
- "not_judged": "dependency cycles closed only by a function-local import",
36
+ "not_judged": "dependency cycles closed only by a non-eager import (function-local, or under `if TYPE_CHECKING:`)",
37
+ "type_checking_imports": query.import_map()["type_checking"],
37
38
  "count": len(lazy),
38
- "note": ("a lazy import does not run at import time, so such a cycle cannot break "
39
+ "note": ("a non-eager import does not run at import time, so such a cycle cannot break "
39
40
  "on import; it is still mutual coupling. Set `no_lazy_cycles = true` to "
40
41
  "gate these as well, or see `report architecture` for the list."),
41
42
  }]
@@ -132,10 +133,14 @@ def _render_scope(query, contract: ArchitectureContract) -> str:
132
133
  return ""
133
134
  s = scope[0]
134
135
  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")
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")
137
140
  return (f"\n_`no_cycles` judged **the eager import graph only** — imports that run at "
138
141
  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")
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")
@@ -156,8 +156,9 @@ def render_docs(query: Query) -> str:
156
156
  # proof of acyclicity, which this map cannot support.
157
157
  out.append("- No import cycle found in the eager import graph.")
158
158
  if lazy:
159
- out.append(f"- **{len(lazy)} dependency cycle(s) closed only by a function-local "
160
- f"import** — deliberate, and still mutual coupling.")
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.")
161
162
  if lay["violations"]:
162
163
  out.append("- **Layer violations (mutual dependency):** "
163
164
  + ", ".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.13
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 function-local import — not import-time failures,
88
+ lazy cycles 40 more, closed only by a non-eager import (function-local or `TYPE_CHECKING`) — 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
@@ -116,6 +116,7 @@ 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
119
120
  tests/test_r1c4_complexity.py
120
121
  tests/test_r1c5_apidiff.py
121
122
  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.13"
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
@@ -63,10 +63,15 @@ def test_dependencies_both_ways(q):
63
63
 
64
64
 
65
65
  def test_cycle_detection(q):
66
- cycles = q.import_cycles()
67
- # bquant has a real pipeline<->cache circular dependency.
68
- flat = {frozenset(c) for c in cycles}
69
- assert frozenset({PIPELINE, "bquant.analysis.zones.cache"}) in flat
66
+ # bquant's pipeline<->cache pair is mutually dependent, and the cache side reaches
67
+ # pipeline only through an import under `if TYPE_CHECKING:` — which never runs. Until
68
+ # R1-C48 (issue #18) that edge was counted as eager and this test pinned the defect:
69
+ # the pair is a *dependency* cycle, and not an import-time one.
70
+ pair = frozenset({PIPELINE, "bquant.analysis.zones.cache"})
71
+ assert pair not in {frozenset(c) for c in q.import_cycles()}, \
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()}
74
+ assert q.import_map()["type_checking"] >= 1
70
75
 
71
76
 
72
77
  def test_orphan_modules(q):
@@ -153,7 +153,7 @@ def test_import_map_is_emitted_even_when_nothing_is_lazy(tmp_path):
153
153
  to tell "no lazy imports here" from "this build did not look for them"."""
154
154
  pkg = _pkg(tmp_path, {"a.py": "x = 1\n", "b.py": "from pkg.a import x\n"})
155
155
  im = Query(extract(str(pkg))).import_map()
156
- assert im == {"module_level": 1, "function_local": 0}
156
+ assert im == {"module_level": 1, "function_local": 0, "type_checking": 0}
157
157
 
158
158
 
159
159
  # -- and no consumer states acyclicity as a property -------------------------
@@ -162,21 +162,21 @@ def test_architecture_never_claims_acyclic(lazy_cycle):
162
162
  md = render_architecture(Query(extract(str(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
- assert "function-local import(s)" in md
166
- assert "Dependency cycles closed only by a function-local import: 1" in md
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
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 lazy import" in md
172
+ assert "close through a non-eager import" in md
173
173
 
174
174
 
175
175
  def test_architecture_payload_carries_both_kinds_and_the_counts(lazy_cycle):
176
176
  a = build_architecture(Query(extract(str(lazy_cycle))))
177
177
  assert a["cycles"] == []
178
178
  assert [sorted(c) for c in a["lazy_cycles"]] == [["pkg.a", "pkg.b"]]
179
- assert a["import_map"] == {"module_level": 1, "function_local": 1}
179
+ assert a["import_map"] == {"module_level": 1, "function_local": 1, "type_checking": 0}
180
180
 
181
181
 
182
182
  def test_no_renderer_states_acyclicity_anywhere(tmp_path):
@@ -92,7 +92,7 @@ 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 function-local import" \
95
+ assert "no dependency cycle is closed only by a non-eager import" \
96
96
  in render_check(eager_only, contract, violations)
97
97
 
98
98
 
@@ -72,6 +72,31 @@ def test_the_order_of_runs_does_not_change_the_result():
72
72
  assert _edges(merge_samples([shallow, deep])[0]) == _edges(merge_samples([deep, shallow])[0])
73
73
 
74
74
 
75
+ def test_the_same_samples_in_any_order_merge_to_the_same_bytes():
76
+ """codemap#17, the consumer's hint: where two candidates are equally legitimate, the
77
+ choice must not depend on the order the samples arrived in. The three places the
78
+ rule 'deep wins, else first run' could see a tie are fed here — two `deep` variants
79
+ of one call, two non-deep variants of one call, and a counter tie — and every
80
+ permutation of the same samples must serialize to the same bytes (provenance is
81
+ the first sample's by contract and is left out)."""
82
+ import itertools
83
+ a = _graph([("calls", "pkg.a", "pkg.B", {"resolution": "deep", "callsites": 1, "via": "B"}),
84
+ ("calls", "pkg.a", "pkg.B.m", {"resolution": "imported", "callsites": 1, "via": "m"})],
85
+ {"attr_access": {"out": 4, "resolved": 3, "unresolved": 1, "external": 0}})
86
+ b = _graph([("calls", "pkg.a", "pkg.B", {"resolution": "deep", "callsites": 2, "via": "B"}),
87
+ ("calls", "pkg.a", "pkg.B.m", {"resolution": "imported", "callsites": 2, "via": "m"})],
88
+ {"attr_access": {"out": 4, "resolved": 3, "unresolved": 1, "external": 1}})
89
+ c = _graph([("calls", "pkg.a", "pkg.B", {"resolution": "deep", "callsites": 1, "via": "B"})])
90
+
91
+ def bytes_of(samples):
92
+ d = json.loads(store.dumps(merge_samples(list(samples))[0]))
93
+ d.pop("provenance", None)
94
+ return json.dumps(d, sort_keys=True)
95
+
96
+ outcomes = {bytes_of(p) for p in itertools.permutations([a, b, c])}
97
+ assert len(outcomes) == 1, f"{len(outcomes)} distinct graphs from the same three samples"
98
+
99
+
75
100
  def test_a_read_and_a_write_of_one_attribute_are_two_edges_not_one():
76
101
  """`(type, source, target)` would collapse these; 96 such pairs exist in one bquant build."""
77
102
  pair = [("accesses", "pkg.a", "pkg.B.f", {"access": "read", "resolution": "self"}),
@@ -82,6 +107,30 @@ def test_a_read_and_a_write_of_one_attribute_are_two_edges_not_one():
82
107
  assert stats == {"runs": 2, "unstable": 0}
83
108
 
84
109
 
110
+ def test_the_third_trees_three_pairs_survive_the_merge_and_deep_wins_only_by_resolution():
111
+ """codemap#17: on the consumer's 219-file tree 17 logical triples carry two records on
112
+ the deep tier — a read and a write, a name as annotation and as value, a dict-literal
113
+ and a subscript write of one column. Fed literally: every pair must come out as two
114
+ edges, and only an `imported`/`deep` pair on a call may collapse — by `resolution`,
115
+ not by the rest of `extras`."""
116
+ pairs = [
117
+ ("accesses", "pkg.a", "pkg.B.by_k", {"access": "read", "resolution": "self"}),
118
+ ("accesses", "pkg.a", "pkg.B.by_k", {"access": "write", "resolution": "self"}),
119
+ ("references", "pkg.a", "pkg.B", {"resolution": "annotation", "sites": 1}),
120
+ ("references", "pkg.a", "pkg.B", {"resolution": "name", "sites": 1}),
121
+ ("writes", "pkg.a", "column:n_zones", {"access": "dict-literal", "resolution": "string-key"}),
122
+ ("writes", "pkg.a", "column:n_zones", {"access": "subscript", "resolution": "string-key"}),
123
+ ("calls", "pkg.a", "pkg.B.m", {"resolution": "imported", "via": "B.m", "callsites": 1}),
124
+ ]
125
+ deeper = pairs[:-1] + [("calls", "pkg.a", "pkg.B.m", {"resolution": "deep", "via": "B.m", "callsites": 2})]
126
+ merged, stats = merge_samples([_graph(pairs), _graph(deeper), _graph(pairs)])
127
+ assert len(merged.edges) == 7, "six facts in three pairs plus one call — nothing collapsed"
128
+ assert all("seen" not in e.extras for e in merged.edges), "every fact was in every run"
129
+ call = next(e for e in merged.edges if e.type == "calls")
130
+ assert call.extras == {"resolution": "deep", "via": "B.m", "callsites": 2}
131
+ assert stats == {"runs": 3, "unstable": 0}
132
+
133
+
85
134
  def test_a_construct_write_and_a_deep_write_are_two_sites_and_both_survive():
86
135
  pair = [("accesses", "pkg.a", "pkg.B.f", {"access": "write", "resolution": "construct"}),
87
136
  ("accesses", "pkg.a", "pkg.B.f", {"access": "write", "resolution": "deep"})]