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.
- {codmap-0.0.27/codmap.egg-info → codmap-0.0.28}/PKG-INFO +2 -2
- {codmap-0.0.27 → codmap-0.0.28}/README.md +1 -1
- {codmap-0.0.27 → codmap-0.0.28}/codemap/cli.py +12 -3
- {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/roots.py +16 -2
- {codmap-0.0.27 → codmap-0.0.28}/codemap/model.py +6 -1
- {codmap-0.0.27 → codmap-0.0.28}/codemap/query.py +87 -23
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/mcp_server.py +10 -6
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/session.py +2 -1
- {codmap-0.0.27 → codmap-0.0.28/codmap.egg-info}/PKG-INFO +2 -2
- {codmap-0.0.27 → codmap-0.0.28}/codmap.egg-info/SOURCES.txt +1 -0
- {codmap-0.0.27 → codmap-0.0.28}/pyproject.toml +1 -1
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c63_answer_format.py +45 -0
- codmap-0.0.28/tests/test_r1c80_tests_through_helpers.py +120 -0
- {codmap-0.0.27 → codmap-0.0.28}/LICENSE +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/__init__.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/apidiff.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/arch.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/diagnostics.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/griffe_extractor.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/probe.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/extract/union.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/freshness.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/incremental.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/base.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/provenance.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/scope.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/audit.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/check.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/currency.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/impact.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/limits.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/pack.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/rag.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/review.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/scip.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/server.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/serve/vault.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/store.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/tomlio.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codemap/watch.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/setup.cfg +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_epistemic.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_integrations.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m15_review.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m1_query.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m2_views.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_m9_family.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_pack_honesty.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c40_flow_reach.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c48_type_checking_imports.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c49_type_only_cycles.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c50_flow_entry_points.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c53_narrowing_declared.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c54_deterministic_rendering.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c55_override_grade.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c56_stub_files.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c57_facade_package.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c58_cycle_tangles.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c59_completeness_drop.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c60_nested_definitions.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c62_phantom_rules.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c64_dependency_probe.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c66_pack_direction.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c68_dotted_imports.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c69_contract_validation.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c70_check_graph_currency.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c71_attribute_chain_calls.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c72_flat_inference_in_packages.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c73_cli_version.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c74_call_site_conservation.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c75_reexported_class_calls.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c76_contract_table_typo.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c77_gate_probe_battery.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c78_forbidden_chains.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_scip_export.py +0 -0
- {codmap-0.0.27 → codmap-0.0.28}/tests/test_scope.py +0 -0
- {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.
|
|
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
|
[](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
|
|
46
46
|
|
|
47
|
-
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **
|
|
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
|
[](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
|
|
10
10
|
|
|
11
|
-
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **
|
|
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
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1542
|
-
if
|
|
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
|
|
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)
|
|
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
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
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,
|
|
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
|
-
|
|
1672
|
-
|
|
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.
|
|
173
|
-
`
|
|
174
|
-
|
|
175
|
-
`
|
|
176
|
-
|
|
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.
|
|
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
|
[](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
|
|
46
46
|
|
|
47
|
-
**Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|