codmap 0.0.27__tar.gz → 0.0.28__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 (161) hide show
  1. {codmap-0.0.27/codmap.egg-info → codmap-0.0.28}/PKG-INFO +2 -2
  2. {codmap-0.0.27 → codmap-0.0.28}/README.md +1 -1
  3. {codmap-0.0.27 → codmap-0.0.28}/codemap/cli.py +12 -3
  4. {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/roots.py +16 -2
  5. {codmap-0.0.27 → codmap-0.0.28}/codemap/model.py +6 -1
  6. {codmap-0.0.27 → codmap-0.0.28}/codemap/query.py +87 -23
  7. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/mcp_server.py +10 -6
  8. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/session.py +2 -1
  9. {codmap-0.0.27 → codmap-0.0.28/codmap.egg-info}/PKG-INFO +2 -2
  10. {codmap-0.0.27 → codmap-0.0.28}/codmap.egg-info/SOURCES.txt +1 -0
  11. {codmap-0.0.27 → codmap-0.0.28}/pyproject.toml +1 -1
  12. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c63_answer_format.py +45 -0
  13. codmap-0.0.28/tests/test_r1c80_tests_through_helpers.py +120 -0
  14. {codmap-0.0.27 → codmap-0.0.28}/LICENSE +0 -0
  15. {codmap-0.0.27 → codmap-0.0.28}/codemap/__init__.py +0 -0
  16. {codmap-0.0.27 → codmap-0.0.28}/codemap/apidiff.py +0 -0
  17. {codmap-0.0.27 → codmap-0.0.28}/codemap/arch.py +0 -0
  18. {codmap-0.0.27 → codmap-0.0.28}/codemap/diagnostics.py +0 -0
  19. {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/__init__.py +0 -0
  20. {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/attrflow.py +0 -0
  21. {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/behavior.py +0 -0
  22. {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/dataflow.py +0 -0
  23. {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/dispatch.py +0 -0
  24. {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/griffe_extractor.py +0 -0
  25. {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/gsource.py +0 -0
  26. {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/probe.py +0 -0
  27. {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/union.py +0 -0
  28. {codmap-0.0.27 → codmap-0.0.28}/codemap/freshness.py +0 -0
  29. {codmap-0.0.27 → codmap-0.0.28}/codemap/incremental.py +0 -0
  30. {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/__init__.py +0 -0
  31. {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/base.py +0 -0
  32. {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/cocoindex.py +0 -0
  33. {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/gate.py +0 -0
  34. {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/gitnexus.py +0 -0
  35. {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/registry.py +0 -0
  36. {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/transport.py +0 -0
  37. {codmap-0.0.27 → codmap-0.0.28}/codemap/provenance.py +0 -0
  38. {codmap-0.0.27 → codmap-0.0.28}/codemap/scope.py +0 -0
  39. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/__init__.py +0 -0
  40. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/_scip_pb2.py +0 -0
  41. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/api_surface.py +0 -0
  42. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/apidiff.py +0 -0
  43. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/architecture.py +0 -0
  44. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/audit.py +0 -0
  45. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/check.py +0 -0
  46. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/ctags.py +0 -0
  47. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/currency.py +0 -0
  48. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/impact.py +0 -0
  49. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/limits.py +0 -0
  50. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/livingdocs.py +0 -0
  51. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/mermaid.py +0 -0
  52. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/pack.py +0 -0
  53. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/rag.py +0 -0
  54. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/review.py +0 -0
  55. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/scip.py +0 -0
  56. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/semantic.py +0 -0
  57. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/server.py +0 -0
  58. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/subsystems.py +0 -0
  59. {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/vault.py +0 -0
  60. {codmap-0.0.27 → codmap-0.0.28}/codemap/store.py +0 -0
  61. {codmap-0.0.27 → codmap-0.0.28}/codemap/tomlio.py +0 -0
  62. {codmap-0.0.27 → codmap-0.0.28}/codemap/watch.py +0 -0
  63. {codmap-0.0.27 → codmap-0.0.28}/codmap.egg-info/dependency_links.txt +0 -0
  64. {codmap-0.0.27 → codmap-0.0.28}/codmap.egg-info/entry_points.txt +0 -0
  65. {codmap-0.0.27 → codmap-0.0.28}/codmap.egg-info/requires.txt +0 -0
  66. {codmap-0.0.27 → codmap-0.0.28}/codmap.egg-info/top_level.txt +0 -0
  67. {codmap-0.0.27 → codmap-0.0.28}/setup.cfg +0 -0
  68. {codmap-0.0.27 → codmap-0.0.28}/tests/test_epistemic.py +0 -0
  69. {codmap-0.0.27 → codmap-0.0.28}/tests/test_gitnexus_router.py +0 -0
  70. {codmap-0.0.27 → codmap-0.0.28}/tests/test_impact_depth.py +0 -0
  71. {codmap-0.0.27 → codmap-0.0.28}/tests/test_integrations.py +0 -0
  72. {codmap-0.0.27 → codmap-0.0.28}/tests/test_issue3_serve_freshness.py +0 -0
  73. {codmap-0.0.27 → codmap-0.0.28}/tests/test_livingdocs.py +0 -0
  74. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m0_api_surface.py +0 -0
  75. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m11_argcontract.py +0 -0
  76. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m12_dataflow.py +0 -0
  77. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m15_review.py +0 -0
  78. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m16_architecture.py +0 -0
  79. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m17_mcp.py +0 -0
  80. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m18_freshness.py +0 -0
  81. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m1_5_semantics.py +0 -0
  82. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m1_query.py +0 -0
  83. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m2_views.py +0 -0
  84. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m32_watch.py +0 -0
  85. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m3_serve.py +0 -0
  86. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m4_behavior.py +0 -0
  87. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m5_deep.py +0 -0
  88. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m6_repo_scope.py +0 -0
  89. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m7_dispatch.py +0 -0
  90. {codmap-0.0.27 → codmap-0.0.28}/tests/test_m9_family.py +0 -0
  91. {codmap-0.0.27 → codmap-0.0.28}/tests/test_pack_honesty.py +0 -0
  92. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  93. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c13_grep_vs_graph.py +0 -0
  94. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c16_semantic.py +0 -0
  95. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c20_attribute_edges.py +0 -0
  96. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c21_flat_layout.py +0 -0
  97. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c22_source_visible_refs.py +0 -0
  98. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c23_hard_python.py +0 -0
  99. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c24_test_mapping.py +0 -0
  100. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c25_provenance.py +0 -0
  101. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c26_deep_union.py +0 -0
  102. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c27_config_honesty.py +0 -0
  103. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c28_limit_envelope.py +0 -0
  104. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c29_lazy_imports.py +0 -0
  105. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c2_ctags.py +0 -0
  106. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c30_local_import_calls.py +0 -0
  107. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c30f1_reexport_calls.py +0 -0
  108. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c30f2_check_scope.py +0 -0
  109. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c31_path_origin.py +0 -0
  110. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c32_report_json.py +0 -0
  111. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c33_query_signature.py +0 -0
  112. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c34_signature_kinds.py +0 -0
  113. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c35_contract_location.py +0 -0
  114. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c36_target_resolution.py +0 -0
  115. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c37_rule_mutation.py +0 -0
  116. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c38_tool_drift.py +0 -0
  117. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c39_edge_resolution.py +0 -0
  118. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c3_arch_contract.py +0 -0
  119. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c3_dogfood.py +0 -0
  120. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c40_flow_reach.py +0 -0
  121. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c41_scope_membership.py +0 -0
  122. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c42_deep_tier_stability.py +0 -0
  123. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c43_incremental_splice.py +0 -0
  124. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c44_absent_answers.py +0 -0
  125. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c45_repeat_union.py +0 -0
  126. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c46_shadowed_definitions.py +0 -0
  127. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c47_incremental_resample.py +0 -0
  128. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c48_type_checking_imports.py +0 -0
  129. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c49_type_only_cycles.py +0 -0
  130. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c4_complexity.py +0 -0
  131. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c50_flow_entry_points.py +0 -0
  132. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c53_narrowing_declared.py +0 -0
  133. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c54_deterministic_rendering.py +0 -0
  134. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c55_override_grade.py +0 -0
  135. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c56_stub_files.py +0 -0
  136. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c57_facade_package.py +0 -0
  137. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c58_cycle_tangles.py +0 -0
  138. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c59_completeness_drop.py +0 -0
  139. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c5_apidiff.py +0 -0
  140. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c60_nested_definitions.py +0 -0
  141. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c62_phantom_rules.py +0 -0
  142. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c64_dependency_probe.py +0 -0
  143. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c66_pack_direction.py +0 -0
  144. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c68_dotted_imports.py +0 -0
  145. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c69_contract_validation.py +0 -0
  146. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c6_pack.py +0 -0
  147. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c70_check_graph_currency.py +0 -0
  148. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c71_attribute_chain_calls.py +0 -0
  149. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c72_flat_inference_in_packages.py +0 -0
  150. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c73_cli_version.py +0 -0
  151. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c74_call_site_conservation.py +0 -0
  152. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c75_reexported_class_calls.py +0 -0
  153. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c76_contract_table_typo.py +0 -0
  154. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c77_gate_probe_battery.py +0 -0
  155. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c78_forbidden_chains.py +0 -0
  156. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c7_edge_vocab.py +0 -0
  157. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c8_deadcode.py +0 -0
  158. {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c9_incremental.py +0 -0
  159. {codmap-0.0.27 → codmap-0.0.28}/tests/test_scip_export.py +0 -0
  160. {codmap-0.0.27 → codmap-0.0.28}/tests/test_scope.py +0 -0
  161. {codmap-0.0.27 → codmap-0.0.28}/tests/test_subsystems.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.27
3
+ Version: 0.0.28
4
4
  Summary: Static analyzer that turns a package's source into a queryable code graph.
5
5
  Author-email: kogriv <kogriv@gmail.com>
6
6
  License-Expression: MIT
@@ -44,7 +44,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
44
44
 
45
45
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
46
46
 
47
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1180 tests with no failures on
47
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1189 tests with no failures on
48
48
  Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
49
49
  wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
50
50
  as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
@@ -8,7 +8,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
8
8
 
9
9
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
10
10
 
11
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1180 tests with no failures on
11
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1189 tests with no failures on
12
12
  Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
13
13
  wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
14
14
  as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
@@ -495,8 +495,10 @@ def _cmd_tests(args) -> int:
495
495
  session = Session(_graph_from(args))
496
496
  op = "covers" if args.covers else "tests"
497
497
  key = "test" if args.covers else "symbol"
498
- env = session.handle({"op": op, "args": {key: args.name,
499
- "depth": args.depth, "cap": args.cap}})
498
+ payload = {key: args.name, "depth": args.depth, "cap": args.cap}
499
+ if not args.covers:
500
+ payload["all_bands"] = args.all_bands
501
+ env = session.handle({"op": op, "args": payload})
500
502
  if not env.get("ok"):
501
503
  print(env.get("error", "not found"), file=sys.stderr)
502
504
  return 1
@@ -512,7 +514,12 @@ def _cmd_tests(args) -> int:
512
514
  print(f"# tests for {r['symbol']} — confidence: {r['confidence']}"
513
515
  + (f", {r['distance']} hop(s) away" if r["distance"] else ""))
514
516
  for row in r["tests"]:
515
- print(f" {row['node_id']}")
517
+ far = f" (distance {row['distance']})" if args.all_bands else ""
518
+ print(f" {row['node_id']}{far}")
519
+ nb = r.get("next_band")
520
+ if nb and not args.all_bands: # R1-C80 D2
521
+ print(f" … {nb['count']} more test(s) at distance {nb['distance']} — "
522
+ "`--all-bands` lists them")
516
523
  for c in r["caveats"]:
517
524
  print(f" · {c}", file=sys.stderr)
518
525
  ids = [row["node_id"] for row in r.get("tests", []) if row.get("node_id")]
@@ -1081,6 +1088,8 @@ def build_parser() -> argparse.ArgumentParser:
1081
1088
  help="Hops to search back. Beyond 3 the answer is low-confidence "
1082
1089
  "(measured: precision 1.00 at 3 hops, 0.33 at 5).")
1083
1090
  t.add_argument("--cap", type=int, default=25, help="Max tests listed (default 25).")
1091
+ t.add_argument("--all-bands", action="store_true",
1092
+ help="List every band within --depth, not only the nearest (R1-C80).")
1084
1093
  t.add_argument("--format", choices=["text", "json"], default="text")
1085
1094
  t.set_defaults(func=_cmd_tests)
1086
1095
 
@@ -269,6 +269,14 @@ def _scan_consumer_module(graph, py, base, label, tree, index, mode, origin=None
269
269
  # R1-C46: a use inside a body that a later definition replaced is not a use — it
270
270
  # would attribute a reference (or a call) to code that never runs, in either mode.
271
271
  dead = _shadow_map(tree)[2]
272
+ # R1-C80 D0: in full mode, a use of the module's own top-level function or class is an
273
+ # edge too — a test calling `_vector` in its own module. Without it the walk from a core
274
+ # symbol stopped at the helper. Names that an import also binds are left to the import.
275
+ local_defs = set()
276
+ if mode == "full":
277
+ local_defs = {n.name for n in tree.body
278
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))}
279
+ local_defs -= set(symbol_map) | set(module_aliases)
272
280
 
273
281
  for node in ast.walk(tree):
274
282
  line = getattr(node, "lineno", None)
@@ -276,8 +284,12 @@ def _scan_consumer_module(graph, py, base, label, tree, index, mode, origin=None
276
284
  continue
277
285
  target = None
278
286
  use_node = node
287
+ local = False
279
288
  if isinstance(node, ast.Name) and node.id in symbol_map:
280
289
  target = index.resolve(symbol_map[node.id])
290
+ elif (isinstance(node, ast.Name) and node.id in local_defs
291
+ and isinstance(node.ctx, ast.Load)):
292
+ target, local = f"{mod_id}.{node.id}", True
281
293
  elif isinstance(node, ast.Attribute) and id(node) not in inner_attr_ids:
282
294
  dotted = _dotted(node)
283
295
  if dotted:
@@ -288,7 +300,9 @@ def _scan_consumer_module(graph, py, base, label, tree, index, mode, origin=None
288
300
  if not target:
289
301
  continue
290
302
  src = _source_for(use_node, mod_id, func_ranges) if mode == "full" else mod_id
291
- entry = uses.setdefault((src, target), {"called": False, "shapes": []})
303
+ if local and (src == target or target not in graph.nodes):
304
+ continue # recursion, or a def the shadow rule dropped
305
+ entry = uses.setdefault((src, target), {"called": False, "shapes": [], "local": local})
292
306
  call_node = call_by_func.get(id(use_node))
293
307
  if call_node is not None:
294
308
  entry["called"] = True
@@ -296,7 +310,7 @@ def _scan_consumer_module(graph, py, base, label, tree, index, mode, origin=None
296
310
 
297
311
  for (src, target), entry in sorted(uses.items()):
298
312
  etype = "calls" if entry["called"] else "references"
299
- extras = {"resolution": "imported"}
313
+ extras = {"resolution": "local" if entry["local"] else "imported"}
300
314
  if etype == "calls" and entry["shapes"]:
301
315
  extras.update(_arg_contract(entry["shapes"]))
302
316
  graph.add_edge(Edge(etype, src, target, extras=extras))
@@ -113,7 +113,12 @@ SCHEMA_VERSION = "0.13"
113
113
  # nested functions, module-level code and class bodies, plus sites in shadowed
114
114
  # bodies that are not walked; the structured answer gains `by_scope` and
115
115
  # `shadowed_not_walked` (R1-C74, docs/design/call_site_conservation.md).
116
- ANSWER_FORMAT = 4
116
+ # 5 — `tests` names the band after the nearest (`next_band`, a "… N more test(s) at distance d"
117
+ # line) and says the nearest band closed the answer instead of "searched N hop(s) back";
118
+ # `--all-bands` lists every band, each row carrying its distance and confidence; a helper
119
+ # function inside a collected test module costs no hop in `tests` and `covers`, so
120
+ # distances move where one sits on the path (R1-C80, docs/design/tests_through_helpers.md).
121
+ ANSWER_FORMAT = 5
117
122
 
118
123
  # Closed vocabulary of edge types (R1-C7). Node ``kind`` is deliberately an OPEN set
119
124
  # (DESIGN §2 — new entity kinds may appear), but edges are TYPED: every relationship
@@ -262,6 +262,16 @@ class Query:
262
262
  self._outbound.setdefault(e.source, []).append((e.target, e.type))
263
263
  self._module_ids = [n.id for n in graph.nodes.values() if n.kind == "module"]
264
264
  self._test_ids = {i for i in self.graph.nodes if self._is_test(i)}
265
+ # R1-C80 D1: non-test functions in a collected test module — transparent to distance.
266
+ self._test_helper_ids = {i for i in self.graph.nodes if self._is_test_helper(i)}
267
+ self._test_function_ids = self._test_ids | self._test_helper_ids
268
+ # D1: a nested def in a test module → the test or helper that contains it.
269
+ self._test_parent = {e.target: e.source for e in graph.edges
270
+ if e.type == "contains" and e.target in self._test_helper_ids
271
+ and e.source in self._test_function_ids}
272
+ self._test_children: dict[str, list[str]] = {}
273
+ for child, parent in sorted(self._test_parent.items()):
274
+ self._test_children.setdefault(parent, []).append(child)
265
275
 
266
276
  # -- lookups -------------------------------------------------------------
267
277
 
@@ -1538,14 +1548,8 @@ class Query:
1538
1548
  the artifact does not carry one framework's naming convention, and so an existing
1539
1549
  graph gains the feature with no rebuild.
1540
1550
  """
1541
- node = self.graph.nodes.get(node_id)
1542
- if node is None or node.kind != "function" or self.root_of(node_id) != "tests":
1543
- return False
1544
- mod = self._module_of(node_id)
1545
- if mod is None or not mod.file:
1546
- return False
1547
- base = mod.file.rsplit("/", 1)[-1]
1548
- if not (base.startswith("test_") or base.endswith("_test.py")):
1551
+ mod = self._collected_module(node_id)
1552
+ if mod is None:
1549
1553
  return False
1550
1554
  tail = node_id[len(mod.id) + 1:].split(".")
1551
1555
  if not tail or not tail[-1].startswith("test"):
@@ -1554,6 +1558,30 @@ class Query:
1554
1558
  return True
1555
1559
  return len(tail) == 2 and tail[0].startswith("Test")
1556
1560
 
1561
+ def _collected_module(self, node_id: str) -> "Node | None":
1562
+ """The module of a function under a ``tests`` root whose file pytest collects, else None."""
1563
+ node = self.graph.nodes.get(node_id)
1564
+ if node is None or node.kind != "function" or self.root_of(node_id) != "tests":
1565
+ return None
1566
+ mod = self._module_of(node_id)
1567
+ if mod is None or not mod.file:
1568
+ return None
1569
+ base = mod.file.rsplit("/", 1)[-1]
1570
+ if not (base.startswith("test_") or base.endswith("_test.py")):
1571
+ return None
1572
+ return mod
1573
+
1574
+ def _is_test_helper(self, node_id: str) -> bool:
1575
+ """R1-C80 D1: a function in a collected test module that is not itself a test.
1576
+
1577
+ Such a helper exists to be called by the tests beside it, so the walk passes through it
1578
+ without spending a hop: a test that calls ``_vector``, which calls the symbol, exercises
1579
+ the symbol as a test that calls it directly does. conftest.py, ``tests/fixtures/`` and
1580
+ shared helper modules are not collected test modules, so they still cost a hop — that is
1581
+ the shared infrastructure where R1-C24 measured precision falling.
1582
+ """
1583
+ return self._collected_module(node_id) is not None and not self._is_test(node_id)
1584
+
1557
1585
  def pytest_nodeid(self, node_id: str) -> str | None:
1558
1586
  """Graph id → the id you can paste after ``pytest`` (design D5).
1559
1587
 
@@ -1566,7 +1594,8 @@ class Query:
1566
1594
  tail = node_id[len(mod.id) + 1:]
1567
1595
  return mod.file + ("::" + "::".join(tail.split(".")) if tail else "")
1568
1596
 
1569
- def _test_caveats(self, *, truncated: int, searched: int, found: bool) -> list[str]:
1597
+ def _test_caveats(self, *, truncated: int, searched: int, found: bool,
1598
+ nearest: int | None = None) -> list[str]:
1570
1599
  """The two labels every answer carries, plus whatever this graph earns.
1571
1600
 
1572
1601
  Both are required (R1-C13): an over-set because reaching a symbol is not
@@ -1594,12 +1623,15 @@ class Query:
1594
1623
  "hop(s). 16% of symbols that coverage.py proves are exercised look "
1595
1624
  "like this — ask for a deeper walk (`depth=6`) for low-confidence "
1596
1625
  "candidates, but do not read silence as 'untested'")
1626
+ elif nearest is not None:
1627
+ # R1-C80 D2: say that the nearest band closed the answer, not that the walk was short.
1628
+ out.append(f"nearest band at distance {nearest}; looked up to depth {searched} for more")
1597
1629
  else:
1598
- out.append(f"searched {searched} hop(s) back")
1630
+ out.append(f"searched {searched} hop(s)")
1599
1631
  return out
1600
1632
 
1601
1633
  def tests_for(self, symbol_id: str, *, depth: int = _TEST_MAX_DEPTH,
1602
- cap: int = _TEST_CAP) -> dict:
1634
+ cap: int = _TEST_CAP, all_bands: bool = False) -> dict:
1603
1635
  """Which tests exercise ``symbol_id`` — nearest band first (R1-C24, axis A10).
1604
1636
 
1605
1637
  Distance 1 is not the question: measured on codemap's own repo, only **18%** of
@@ -1619,6 +1651,7 @@ class Query:
1619
1651
  targets = self._member_ids(symbol_id)
1620
1652
  seen = set(targets)
1621
1653
  frontier = set(targets)
1654
+ bands: list[tuple[int, list[str]]] = []
1622
1655
  for dist in range(1, max(1, depth) + 1):
1623
1656
  nxt: set[str] = set()
1624
1657
  for node in sorted(frontier):
@@ -1626,35 +1659,59 @@ class Query:
1626
1659
  if src not in seen and etype in _TEST_WALK_EDGES:
1627
1660
  seen.add(src)
1628
1661
  nxt.add(src)
1662
+ # R1-C80 D1 (as measured): a def nested in a test or helper is part of its body, so
1663
+ # it leads to its container at no cost. A module-level helper costs a hop like any
1664
+ # other function: made free, it pulled 72–80% coverage-false tests into the nearest
1665
+ # band through shared helpers (design §"Measured", E6).
1666
+ todo = sorted(n for n in nxt if n in self._test_parent)
1667
+ while todo:
1668
+ parent = self._test_parent[todo.pop()]
1669
+ if parent not in seen:
1670
+ seen.add(parent)
1671
+ nxt.add(parent)
1672
+ if parent in self._test_parent:
1673
+ todo.append(parent)
1629
1674
  if not nxt:
1630
- frontier = nxt
1631
1675
  break
1632
1676
  frontier = nxt
1633
1677
  hits = sorted(n for n in nxt if n in self._test_ids)
1634
1678
  if hits:
1635
- return self._tests_envelope(symbol_id, hits, dist, cap, dist)
1636
- return self._tests_envelope(symbol_id, [], None, cap, depth)
1637
-
1638
- def _tests_envelope(self, symbol_id, hits, dist, cap, searched) -> dict:
1639
- shown, truncated = hits[:cap], max(0, len(hits) - cap)
1640
- return {
1679
+ bands.append((dist, hits))
1680
+ # D2: the nearest band is the answer; one more band is only counted.
1681
+ if len(bands) == 2 and not all_bands:
1682
+ break
1683
+ return self._tests_envelope(symbol_id, bands, cap, depth, all_bands)
1684
+
1685
+ def _tests_envelope(self, symbol_id, bands, cap, searched, all_bands=False) -> dict:
1686
+ dist, hits = bands[0] if bands else (None, [])
1687
+ rows = ([(t, d) for d, band in bands for t in band] if all_bands
1688
+ else [(t, dist) for t in hits])
1689
+ shown, truncated = rows[:cap], max(0, len(rows) - cap)
1690
+ nxt = bands[1] if len(bands) > 1 else None
1691
+ out = {
1641
1692
  "symbol": symbol_id,
1642
1693
  "tier": (self.graph.provenance or {}).get("tier"),
1643
1694
  "distance": dist,
1644
1695
  # `unknown` — never "none". A confident empty is the failure this project has
1645
1696
  # now shipped five fixes for (#1 risk:"none", #3, #5, #7, R1-C23).
1646
1697
  "confidence": _TEST_CONFIDENCE.get(dist, "low") if dist else "unknown",
1647
- "tests": [self._test_row(t, dist) for t in shown],
1698
+ "tests": [self._test_row(t, d) for t, d in shown],
1648
1699
  "total_at_distance": len(hits),
1700
+ # R1-C80 D2: the band after the nearest, within `depth` — always present, null if none.
1701
+ "next_band": {"distance": nxt[0], "count": len(nxt[1])} if nxt else None,
1649
1702
  "truncated": truncated,
1650
1703
  "caveats": self._test_caveats(truncated=truncated, searched=searched,
1651
- found=bool(dist)),
1704
+ found=bool(dist), nearest=dist),
1652
1705
  }
1706
+ if all_bands: # D3
1707
+ out["total"] = len(rows)
1708
+ return out
1653
1709
 
1654
1710
  def _test_row(self, node_id: str, dist) -> dict:
1655
1711
  n = self.graph.nodes.get(node_id)
1656
1712
  return {"id": node_id, "node_id": self.pytest_nodeid(node_id),
1657
- "line": getattr(n, "lineno", None), "distance": dist}
1713
+ "line": getattr(n, "lineno", None), "distance": dist,
1714
+ "confidence": _TEST_CONFIDENCE.get(dist, "low") if dist else "unknown"}
1658
1715
 
1659
1716
  def covers(self, test_id: str, *, depth: int = _TEST_MAX_DEPTH,
1660
1717
  cap: int = _TEST_CAP) -> dict:
@@ -1668,12 +1725,19 @@ class Query:
1668
1725
  frontier = {test_id}
1669
1726
  for dist in range(1, max(1, depth) + 1):
1670
1727
  nxt: set[str] = set()
1671
- for node in sorted(frontier):
1672
- for e in self._outbound.get(node, []):
1728
+ todo = sorted(frontier)
1729
+ while todo:
1730
+ node = todo.pop()
1731
+ downs = list(self._outbound.get(node, []))
1732
+ downs += [(c, "calls") for c in self._test_children.get(node, ())] # D1
1733
+ for e in sorted(downs):
1673
1734
  tgt, etype = e
1674
1735
  if tgt in seen or etype not in _TEST_WALK_EDGES:
1675
1736
  continue
1676
1737
  seen.add(tgt)
1738
+ if etype == "calls" and tgt in self._test_parent and node == self._test_parent[tgt]:
1739
+ todo.append(tgt) # R1-C80 D1: a nested def is its container's body
1740
+ continue
1677
1741
  nxt.add(tgt)
1678
1742
  if self.root_of(tgt) == "core":
1679
1743
  out.setdefault(tgt, dist)
@@ -167,13 +167,17 @@ def build_mcp_server(session: "Session", name: str = "codemap") -> Any:
167
167
  return op("callees", {"symbol": symbol, "min_confidence": min_confidence})
168
168
 
169
169
  @server.tool()
170
- def tests(symbol: str, depth: int = 3, cap: int = 25) -> dict:
170
+ def tests(symbol: str, depth: int = 3, cap: int = 25, all_bands: bool = False) -> dict:
171
171
  """Which tests exercise `symbol` — the NEAREST band of test functions that reach
172
- it, as runnable pytest node ids. `confidence` is high/medium by distance and
173
- `unknown` when nothing is found within `depth`: unknown means unknown, never
174
- "untested". Needs a repo-scoped graph (`--consumer tests --mode full`). Raise
175
- `depth` above 3 only for low-confidence candidates."""
176
- return op("tests", {"symbol": symbol, "depth": depth, "cap": cap})
172
+ it, as runnable pytest node ids. A helper function inside a test module costs no
173
+ hop. `next_band` says how many more tests sit at the next distance within `depth`
174
+ (null if none); `all_bands=true` lists every band, each row with its distance.
175
+ `confidence` is high/medium by distance and `unknown` when nothing is found within
176
+ `depth`: unknown means unknown, never "untested". Needs a repo-scoped graph
177
+ (`--consumer tests --mode full`). Raise `depth` above 3 only for low-confidence
178
+ candidates."""
179
+ return op("tests", {"symbol": symbol, "depth": depth, "cap": cap,
180
+ "all_bands": all_bands})
177
181
 
178
182
  @server.tool()
179
183
  def covers(test: str, depth: int = 3, cap: int = 25) -> dict:
@@ -610,7 +610,8 @@ class Session:
610
610
  """R1-C24: which tests exercise a symbol, nearest band first, honestly labelled."""
611
611
  cap = int(args.get("cap", 25))
612
612
  res = self.query.tests_for(
613
- self._canon(args["symbol"]), depth=int(args.get("depth", 3)), cap=cap)
613
+ self._canon(args["symbol"]), depth=int(args.get("depth", 3)), cap=cap,
614
+ all_bands=bool(args.get("all_bands", False)))
614
615
  # These two already say it in their own body (`total_at_distance` + a caveat
615
616
  # line). The envelope block is added anyway: a machine consumer should read one
616
617
  # vocabulary across every limited op, not learn a per-op dialect.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.27
3
+ Version: 0.0.28
4
4
  Summary: Static analyzer that turns a package's source into a queryable code graph.
5
5
  Author-email: kogriv <kogriv@gmail.com>
6
6
  License-Expression: MIT
@@ -44,7 +44,7 @@ and a **SCIP index** for interop with Sourcegraph / Glean and other precise-code
44
44
 
45
45
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
46
46
 
47
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1180 tests with no failures on
47
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1189 tests with no failures on
48
48
  Python 3.11–3.14** ([in CI](docs/ci.md): the full suite including the dogfood pass, a determinism check, a
49
49
  wheel smoke test, and ctags/SCIP interop against the real CLIs), warm serve surface with 31 ops (28 exposed
50
50
  as MCP tools), and SCIP export. See **[DESIGN.md](DESIGN.md)** (product design &
@@ -151,6 +151,7 @@ tests/test_r1c76_contract_table_typo.py
151
151
  tests/test_r1c77_gate_probe_battery.py
152
152
  tests/test_r1c78_forbidden_chains.py
153
153
  tests/test_r1c7_edge_vocab.py
154
+ tests/test_r1c80_tests_through_helpers.py
154
155
  tests/test_r1c8_deadcode.py
155
156
  tests/test_r1c9_incremental.py
156
157
  tests/test_scip_export.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.27"
12
+ version = "0.0.28"
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
@@ -160,3 +160,48 @@ def test_the_pin_is_over_a_fixture_this_test_owns(pkg):
160
160
  """Positive control for the pin: it must not be hashing the live dogfood tree."""
161
161
  assert pkg.graph.target == "pkg"
162
162
  assert len(pkg.graph.nodes) < 40, "a fixture, not a real package"
163
+
164
+
165
+ # -- format 5 (R1-C80): the `tests` answer joins the pinned surfaces ---------------------------
166
+ # Consumers act on it ("what to run before a change"), and until format 5 its shape could move
167
+ # without any pin noticing: none of the six surfaces above is `tests`. Its own fixture, so the
168
+ # repo-scoped build does not move the pin above.
169
+ TESTS_SHA = "8035e17477e5da2f2d6260056b8c030c7846bec2e2c8a6778d5e79746f1a92b3"
170
+
171
+ REPO_FILES = {
172
+ "pkg/__init__.py": "",
173
+ "pkg/core.py": "def act():\n return 1\n\n\ndef wrap():\n return act()\n",
174
+ "tests/test_a.py": ("from pkg.core import act, wrap\n\n\n"
175
+ "def _helper():\n return act()\n\n\n"
176
+ "def test_direct():\n assert act() == 1\n\n\n"
177
+ "def test_through_helper():\n assert _helper() == 1\n\n\n"
178
+ "def test_far():\n assert wrap() == 1\n"),
179
+ }
180
+
181
+
182
+ @pytest.fixture(scope="module")
183
+ def repo(tmp_path_factory):
184
+ from codemap.extract import extract_repo
185
+ root = tmp_path_factory.mktemp("r1c63_tests")
186
+ for rel, body in REPO_FILES.items():
187
+ path = root / rel
188
+ path.parent.mkdir(parents=True, exist_ok=True)
189
+ path.write_text(body)
190
+ return Query(extract_repo(str(root / "pkg"), consumers=(str(root / "tests"),), mode="full"))
191
+
192
+
193
+ def _tests_answers(query) -> str:
194
+ session = Session(query.graph)
195
+ out = []
196
+ for args in ({"symbol": "pkg.core.act"}, {"symbol": "pkg.core.act", "all_bands": True},
197
+ {"symbol": "pkg.core.wrap"}):
198
+ env = session.handle({"op": "tests", "args": args})
199
+ out.append(json.dumps(env["result"], sort_keys=True))
200
+ return "\n@@\n".join(out)
201
+
202
+
203
+ def test_the_tests_answer_matches_its_pin(repo):
204
+ got = hashlib.sha256(_tests_answers(repo).encode()).hexdigest()
205
+ assert got == TESTS_SHA, (
206
+ "the `tests` answer changed shape or content — bump ANSWER_FORMAT if structural, "
207
+ f"and re-pin.\n expected {TESTS_SHA}\n got {got}\n{_tests_answers(repo)}")
@@ -0,0 +1,120 @@
1
+ """R1-C80 — `tests` through a helper in the test module; the answer names the next band.
2
+
3
+ Design `docs/design/tests_through_helpers.md` (pre-registered e666077, corrected before code in 3247be6).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ from codemap.cli import main
13
+ from codemap.extract import extract_repo
14
+ from codemap.query import Query
15
+
16
+ FILES = {
17
+ "pkg/__init__.py": "",
18
+ "pkg/core.py": "def act():\n return 1\n\n\ndef wrap():\n return act()\n",
19
+ "tests/test_a.py": (
20
+ "from pkg.core import act, wrap\n\n\n"
21
+ "def _helper():\n return act()\n\n\n"
22
+ "def _outer():\n return _helper()\n\n\n"
23
+ "def test_direct():\n assert act() == 1\n\n\n"
24
+ "def test_through_helper():\n assert _helper() == 1\n\n\n"
25
+ "def test_through_two_helpers():\n assert _outer() == 1\n\n\n"
26
+ "def test_nested():\n def one():\n return _helper()\n assert one() == 1\n\n\n"
27
+ "def test_far():\n assert wrap() == 1\n\n\n"
28
+ "def test_recursive_helper_is_not_a_self_edge():\n pass\n"
29
+ ),
30
+ "tests/helpers.py": "from pkg.core import act\n\n\ndef shared():\n return act()\n",
31
+ }
32
+
33
+
34
+ def _repo(root: Path, files=FILES, mode="full") -> Query:
35
+ for rel, body in files.items():
36
+ p = root / rel
37
+ p.parent.mkdir(parents=True, exist_ok=True)
38
+ p.write_text(body)
39
+ return Query(extract_repo(str(root / "pkg"), consumers=(str(root / "tests"),), mode=mode))
40
+
41
+
42
+ @pytest.fixture(scope="module")
43
+ def q(tmp_path_factory) -> Query:
44
+ return _repo(tmp_path_factory.mktemp("r1c80"))
45
+
46
+
47
+ def _edges(q, etype=None):
48
+ return {(e.source, e.target, e.extras.get("resolution")) for e in q.graph.edges
49
+ if e.type != "contains" and (etype is None or e.type == etype)}
50
+
51
+
52
+ def test_D0_a_test_calling_its_modules_helper_is_an_edge(q):
53
+ es = _edges(q, "calls")
54
+ assert ("tests.test_a.test_through_helper", "tests.test_a._helper", "local") in es
55
+ assert ("tests.test_a._outer", "tests.test_a._helper", "local") in es
56
+ assert ("tests.test_a.test_nested.one", "tests.test_a._helper", "local") in es
57
+ # every local edge stays inside the consumer root
58
+ for src, tgt, res in _edges(q):
59
+ if res == "local":
60
+ assert q.root_of(src) == "tests" and q.root_of(tgt) == "tests"
61
+
62
+
63
+ def test_D0_not_in_thin_mode_and_not_for_imported_names(tmp_path):
64
+ thin = _repo(tmp_path / "thin", mode="thin")
65
+ assert not any(r == "local" for *_, r in _edges(thin))
66
+ files = {**FILES, "tests/test_b.py": ("from pkg.core import act\n\n\ndef act_local():\n return act()\n\n\n"
67
+ "def test_b():\n assert act() == 1\n")}
68
+ full = _repo(tmp_path / "full", files)
69
+ assert ("tests.test_b.test_b", "pkg.core.act", "imported") in _edges(full, "calls")
70
+
71
+
72
+ def test_E1_tests_behind_a_helper_are_reachable_one_hop_further(q):
73
+ # As measured (design §"Measured"): a module-level helper costs a hop. Free, it pulled
74
+ # mostly coverage-false tests into the nearest band through shared helpers.
75
+ r = q.tests_for("pkg.core.act", all_bands=True)
76
+ rows = {t["id"]: t["distance"] for t in r["tests"]}
77
+ assert rows["tests.test_a.test_direct"] == 1
78
+ assert rows["tests.test_a.test_through_helper"] == 2 # unreachable before D0
79
+ assert rows["tests.test_a.test_through_two_helpers"] == 3
80
+ assert rows["tests.test_a.test_nested"] == 2 # nested def → its test, free
81
+ assert "tests.helpers.shared" not in rows # not a test
82
+
83
+
84
+ def test_E7_next_band_is_named_and_null_when_there_is_none(q):
85
+ r = q.tests_for("pkg.core.act")
86
+ assert {t["id"] for t in r["tests"]} == {"tests.test_a.test_direct"}
87
+ assert r["next_band"] == {"distance": 2, "count": 3} # through_helper, nested, far
88
+ assert "nearest band at distance 1; looked up to depth 3 for more" in r["caveats"]
89
+ assert q.tests_for("pkg.core.wrap")["next_band"] is None
90
+
91
+
92
+ def test_E8_all_bands_is_the_union_with_distances_and_is_stable(q):
93
+ r = q.tests_for("pkg.core.act", all_bands=True)
94
+ rows = {(t["id"], t["distance"]) for t in r["tests"]}
95
+ assert ("tests.test_a.test_far", 2) in rows and ("tests.test_a.test_direct", 1) in rows
96
+ assert r["total"] == 5 and r["total_at_distance"] == 1
97
+ assert r == q.tests_for("pkg.core.act", all_bands=True)
98
+
99
+
100
+ def test_E9_covers_reaches_through_a_helper(q):
101
+ assert {"id": "pkg.core.act", "distance": 2} in q.covers("tests.test_a.test_through_helper")["symbols"]
102
+ assert {"id": "pkg.core.act", "distance": 3} in q.covers("tests.test_a.test_through_two_helpers")["symbols"]
103
+
104
+
105
+ def test_the_cli_names_the_next_band_and_lists_all_bands(tmp_path, capsys):
106
+ from codemap import store
107
+ q = _repo(tmp_path)
108
+ g = tmp_path / "g.json"
109
+ store.save(q.graph, str(g))
110
+ assert main(["tests", "act", "--graph", str(g)]) == 0
111
+ out = capsys.readouterr().out
112
+ assert "… 3 more test(s) at distance 2 — `--all-bands` lists them" in out
113
+ assert main(["tests", "act", "--graph", str(g), "--all-bands"]) == 0
114
+ out = capsys.readouterr().out
115
+ assert "test_far (distance 2)" in out and "more test(s)" not in out
116
+
117
+
118
+ def test_E9_covers_enters_a_nested_helper(q):
119
+ # test_nested → (free) one → _helper → act: the nested def is the test's own body
120
+ assert {"id": "pkg.core.act", "distance": 2} in q.covers("tests.test_a.test_nested")["symbols"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes