codmap 0.0.26__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.26/codmap.egg-info → codmap-0.0.28}/PKG-INFO +2 -2
- {codmap-0.0.26 → codmap-0.0.28}/README.md +1 -1
- {codmap-0.0.26 → codmap-0.0.28}/codemap/arch.py +109 -5
- {codmap-0.0.26 → codmap-0.0.28}/codemap/cli.py +12 -3
- {codmap-0.0.26 → codmap-0.0.28}/codemap/extract/roots.py +16 -2
- {codmap-0.0.26 → codmap-0.0.28}/codemap/model.py +6 -1
- {codmap-0.0.26 → codmap-0.0.28}/codemap/query.py +87 -23
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/check.py +5 -1
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/mcp_server.py +10 -6
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/session.py +2 -1
- {codmap-0.0.26 → codmap-0.0.28/codmap.egg-info}/PKG-INFO +2 -2
- {codmap-0.0.26 → codmap-0.0.28}/codmap.egg-info/SOURCES.txt +3 -0
- {codmap-0.0.26 → codmap-0.0.28}/pyproject.toml +1 -1
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c49_type_only_cycles.py +6 -1
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c63_answer_format.py +45 -0
- codmap-0.0.28/tests/test_r1c77_gate_probe_battery.py +223 -0
- codmap-0.0.28/tests/test_r1c78_forbidden_chains.py +138 -0
- codmap-0.0.28/tests/test_r1c80_tests_through_helpers.py +120 -0
- {codmap-0.0.26 → codmap-0.0.28}/LICENSE +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/__init__.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/apidiff.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/diagnostics.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/extract/griffe_extractor.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/extract/probe.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/extract/union.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/freshness.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/incremental.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/integrations/base.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/provenance.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/scope.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/audit.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/currency.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/impact.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/limits.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/pack.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/rag.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/review.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/scip.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/server.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/serve/vault.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/store.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/tomlio.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codemap/watch.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/setup.cfg +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_epistemic.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_integrations.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m15_review.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m1_query.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m2_views.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_m9_family.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_pack_honesty.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c40_flow_reach.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c48_type_checking_imports.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c50_flow_entry_points.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c53_narrowing_declared.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c54_deterministic_rendering.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c55_override_grade.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c56_stub_files.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c57_facade_package.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c58_cycle_tangles.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c59_completeness_drop.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c60_nested_definitions.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c62_phantom_rules.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c64_dependency_probe.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c66_pack_direction.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c68_dotted_imports.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c69_contract_validation.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c70_check_graph_currency.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c71_attribute_chain_calls.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c72_flat_inference_in_packages.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c73_cli_version.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c74_call_site_conservation.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c75_reexported_class_calls.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c76_contract_table_typo.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_scip_export.py +0 -0
- {codmap-0.0.26 → codmap-0.0.28}/tests/test_scope.py +0 -0
- {codmap-0.0.26 → 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 &
|
|
@@ -16,6 +16,8 @@ integration gate reads). All rules operate on the **core** module import graph
|
|
|
16
16
|
independent = [["indicators", "data"]]
|
|
17
17
|
# hard bans regardless of layering: `from` must not import `to`.
|
|
18
18
|
forbidden = [{ from = "core", to = "analysis" }]
|
|
19
|
+
# ...or no chain of imports at all, through any module (R1-C78):
|
|
20
|
+
# forbidden = [{ from = "data", to = "analysis", indirect = true }]
|
|
19
21
|
# the import graph must be acyclic *at import time* (the eager graph).
|
|
20
22
|
no_cycles = true
|
|
21
23
|
# also gate the coupling a lazy import hides: cycles closed only by an import
|
|
@@ -53,6 +55,9 @@ class ArchitectureContract:
|
|
|
53
55
|
layers: tuple[str, ...] = ()
|
|
54
56
|
independent: tuple[tuple[str, ...], ...] = ()
|
|
55
57
|
forbidden: tuple[tuple[str, str], ...] = ()
|
|
58
|
+
# R1-C78: parallel to `forbidden` — True where the item bans every chain, not one edge.
|
|
59
|
+
# Kept beside the pairs, not inside them, so every reader of `(from, to)` stays as it was.
|
|
60
|
+
forbidden_indirect: tuple[bool, ...] = ()
|
|
56
61
|
no_cycles: bool = False
|
|
57
62
|
no_lazy_cycles: bool = False
|
|
58
63
|
no_type_only_cycles: bool = False
|
|
@@ -135,6 +140,17 @@ def _is_str_list(v) -> bool:
|
|
|
135
140
|
return isinstance(v, list) and all(isinstance(x, str) for x in v)
|
|
136
141
|
|
|
137
142
|
|
|
143
|
+
def _repeated(names: list[str]) -> list[str]:
|
|
144
|
+
"""Names that occur more than once, in order of first repeat (R1-C79)."""
|
|
145
|
+
seen: set[str] = set()
|
|
146
|
+
out: list[str] = []
|
|
147
|
+
for n in names:
|
|
148
|
+
if n in seen and n not in out:
|
|
149
|
+
out.append(n)
|
|
150
|
+
seen.add(n)
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
138
154
|
def contract_problems(section) -> list[str]:
|
|
139
155
|
"""Every way ``section`` departs from the ``[architecture]`` schema, in reading order (R1-C69).
|
|
140
156
|
|
|
@@ -153,8 +169,14 @@ def contract_problems(section) -> list[str]:
|
|
|
153
169
|
near = difflib.get_close_matches(key, _KNOWN_KEYS, n=1, cutoff=0.6)
|
|
154
170
|
hint = f" (did you mean `{near[0]}`?)" if near else ""
|
|
155
171
|
problems.append(f"unknown key `{key}`{hint}; known keys: {', '.join(_KNOWN_KEYS)}")
|
|
156
|
-
if "layers" in section
|
|
157
|
-
|
|
172
|
+
if "layers" in section:
|
|
173
|
+
if not _is_str_list(section["layers"]):
|
|
174
|
+
problems.append("`layers` must be a list of strings")
|
|
175
|
+
else:
|
|
176
|
+
# R1-C79 (design ``self_contradicting_rules.md``): a repeated layer overwrote its own
|
|
177
|
+
# rank and inverted the stack — the legal edge was named, the upward one passed.
|
|
178
|
+
for name in _repeated(section["layers"]):
|
|
179
|
+
problems.append(f"`layers` repeats `{name}`")
|
|
158
180
|
if "independent" in section:
|
|
159
181
|
groups = section["independent"]
|
|
160
182
|
if not isinstance(groups, list):
|
|
@@ -163,6 +185,9 @@ def contract_problems(section) -> list[str]:
|
|
|
163
185
|
for i, grp in enumerate(groups):
|
|
164
186
|
if not _is_str_list(grp) or len(grp) < 2:
|
|
165
187
|
problems.append(f"`independent[{i}]` must be a list of at least 2 strings")
|
|
188
|
+
continue
|
|
189
|
+
for name in _repeated(grp): # R1-C79: one layer twice can never fire
|
|
190
|
+
problems.append(f"`independent[{i}]` repeats `{name}`")
|
|
166
191
|
if "forbidden" in section:
|
|
167
192
|
items = section["forbidden"]
|
|
168
193
|
if not isinstance(items, list):
|
|
@@ -172,17 +197,23 @@ def contract_problems(section) -> list[str]:
|
|
|
172
197
|
if not isinstance(item, dict):
|
|
173
198
|
problems.append(f"`forbidden[{i}]` must be a table `{{ from = \"…\", to = \"…\" }}`")
|
|
174
199
|
continue
|
|
175
|
-
extra = sorted(set(item) - {"from", "to"})
|
|
200
|
+
extra = sorted(set(item) - {"from", "to", "indirect"})
|
|
176
201
|
missing = [k for k in ("from", "to") if k not in item]
|
|
177
202
|
wrong = [k for k in ("from", "to") if k in item and not isinstance(item[k], str)]
|
|
178
203
|
if extra:
|
|
179
204
|
problems.append(f"`forbidden[{i}]` has unknown key(s) "
|
|
180
205
|
+ ", ".join(f"`{k}`" for k in extra))
|
|
206
|
+
if "indirect" in item and not isinstance(item["indirect"], bool):
|
|
207
|
+
problems.append(f"`forbidden[{i}]`: `indirect` must be true or false, got "
|
|
208
|
+
f"{type(item['indirect']).__name__} `{item['indirect']!r}`")
|
|
181
209
|
if missing:
|
|
182
210
|
problems.append(f"`forbidden[{i}]` is missing " + ", ".join(f"`{k}`" for k in missing))
|
|
183
211
|
if wrong:
|
|
184
212
|
problems.append(f"`forbidden[{i}]`: " + ", ".join(f"`{k}`" for k in wrong)
|
|
185
213
|
+ " must be a string")
|
|
214
|
+
elif not missing and item["from"] == item["to"]:
|
|
215
|
+
# R1-C79: rules read cross-layer edges, so this can never fire.
|
|
216
|
+
problems.append(f"`forbidden[{i}]`: `from` and `to` are both `{item['from']}`")
|
|
186
217
|
for flag in _FLAGS:
|
|
187
218
|
if flag in section and not isinstance(section[flag], bool):
|
|
188
219
|
problems.append(f"`{flag}` must be true or false, got {type(section[flag]).__name__} "
|
|
@@ -203,10 +234,11 @@ def parse_contract(section: dict) -> ArchitectureContract:
|
|
|
203
234
|
if problems:
|
|
204
235
|
return ArchitectureContract(
|
|
205
236
|
error="`[architecture]` is not a valid contract: " + "; ".join(problems))
|
|
206
|
-
forbidden = []
|
|
237
|
+
forbidden, indirect = [], []
|
|
207
238
|
for item in section.get("forbidden", []) or []:
|
|
208
239
|
if isinstance(item, dict) and "from" in item and "to" in item:
|
|
209
240
|
forbidden.append((str(item["from"]), str(item["to"])))
|
|
241
|
+
indirect.append(bool(item.get("indirect", False)))
|
|
210
242
|
independent = tuple(
|
|
211
243
|
tuple(str(x) for x in grp)
|
|
212
244
|
for grp in (section.get("independent", []) or [])
|
|
@@ -216,6 +248,7 @@ def parse_contract(section: dict) -> ArchitectureContract:
|
|
|
216
248
|
layers=tuple(str(x) for x in (section.get("layers", []) or [])),
|
|
217
249
|
independent=independent,
|
|
218
250
|
forbidden=tuple(forbidden),
|
|
251
|
+
forbidden_indirect=tuple(indirect),
|
|
219
252
|
no_cycles=bool(section.get("no_cycles", False)),
|
|
220
253
|
no_lazy_cycles=bool(section.get("no_lazy_cycles", False)),
|
|
221
254
|
no_type_only_cycles=bool(section.get("no_type_only_cycles", False)),
|
|
@@ -287,6 +320,71 @@ def _tangle_line(tangle: dict) -> str:
|
|
|
287
320
|
return f"{head} (tangle of {tangle['size']}: {', '.join(tangle['modules'])})"
|
|
288
321
|
|
|
289
322
|
|
|
323
|
+
_CHAIN_CAP = 25 # witness chains listed per indirect item before "… N more"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _forbidden_chain(query, frm: str, to: str) -> Violation | None:
|
|
327
|
+
"""R1-C78 (design ``forbidden_chains.md``): does any module of ``frm`` reach ``to`` by imports?
|
|
328
|
+
|
|
329
|
+
The graph is the one the direct rule reads — core modules, every import scope (D2). A chain may
|
|
330
|
+
pass through any core module, its own layer and undeclared layers included, and stops at the first
|
|
331
|
+
module of ``to``. Reported (D3): the **entry edges** ``u → v`` (``v`` in ``to``, ``u`` outside it and
|
|
332
|
+
reachable from ``frm``) and, for each, a shortest witness chain, ties to the lexicographically
|
|
333
|
+
smallest, so the answer is deterministic.
|
|
334
|
+
"""
|
|
335
|
+
ig = query.import_graph
|
|
336
|
+
layer = {m: query._layer_of(m) for m in ig.nodes if query.root_of(m) == "core"}
|
|
337
|
+
succ = {m: sorted(w for w in ig.successors(m) if w in layer and w != m) for m in layer}
|
|
338
|
+
sources = sorted(m for m, ly in layer.items() if ly == frm)
|
|
339
|
+
if not sources:
|
|
340
|
+
return None
|
|
341
|
+
# Level-by-level BFS; each node keeps the smallest chain among those of the shortest length.
|
|
342
|
+
best: dict[str, tuple[str, ...]] = {s: (s,) for s in sources}
|
|
343
|
+
frontier = sources
|
|
344
|
+
while frontier:
|
|
345
|
+
nxt: dict[str, tuple[str, ...]] = {}
|
|
346
|
+
for u in frontier:
|
|
347
|
+
for w in succ[u]:
|
|
348
|
+
if w in best or layer[w] == to:
|
|
349
|
+
continue
|
|
350
|
+
cand = best[u] + (w,)
|
|
351
|
+
if w not in nxt or cand < nxt[w]:
|
|
352
|
+
nxt[w] = cand
|
|
353
|
+
best.update(nxt)
|
|
354
|
+
frontier = sorted(nxt)
|
|
355
|
+
entries = sorted((u, w) for u in best for w in succ[u] if layer[w] == to)
|
|
356
|
+
if not entries:
|
|
357
|
+
return None
|
|
358
|
+
witnesses = [best[u] + (w,) for (u, w) in entries]
|
|
359
|
+
# Which modules of `frm` reach `to` at all: walk back from the entry edges, outside `to`.
|
|
360
|
+
pred: dict[str, list[str]] = {}
|
|
361
|
+
for u in layer:
|
|
362
|
+
if layer[u] == to:
|
|
363
|
+
continue
|
|
364
|
+
for w in succ[u]:
|
|
365
|
+
pred.setdefault(w, []).append(u)
|
|
366
|
+
seen, stack = set(), [u for (u, _) in entries]
|
|
367
|
+
while stack:
|
|
368
|
+
u = stack.pop()
|
|
369
|
+
if u in seen:
|
|
370
|
+
continue
|
|
371
|
+
seen.add(u)
|
|
372
|
+
stack.extend(pred.get(u, ()))
|
|
373
|
+
reaching = sum(1 for s in sources if s in seen)
|
|
374
|
+
# A one-hop witness is the entry edge itself, already listed; only longer chains add proof.
|
|
375
|
+
witnesses = [c for c in witnesses if len(c) > 2]
|
|
376
|
+
lines = [" → ".join(f"`{m}`" for m in c) for c in witnesses[:_CHAIN_CAP]]
|
|
377
|
+
if len(witnesses) > _CHAIN_CAP:
|
|
378
|
+
lines.append(f"_… {len(witnesses) - _CHAIN_CAP} more_")
|
|
379
|
+
return Violation(
|
|
380
|
+
"forbidden",
|
|
381
|
+
f"`{frm}` must not reach `{to}`, directly or through other modules: "
|
|
382
|
+
f"{reaching} module(s) of `{frm}` reach it",
|
|
383
|
+
edges=tuple(entries),
|
|
384
|
+
modules=tuple(lines),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
290
388
|
def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
291
389
|
"""Evaluate every rule against the graph; return the violations (empty = clean)."""
|
|
292
390
|
if contract.is_empty():
|
|
@@ -324,7 +422,13 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
|
324
422
|
))
|
|
325
423
|
|
|
326
424
|
# -- forbidden: explicit from→to bans ---------------------------------------
|
|
327
|
-
|
|
425
|
+
indirect = contract.forbidden_indirect or (False,) * len(contract.forbidden)
|
|
426
|
+
for (frm, to), chained in zip(contract.forbidden, indirect):
|
|
427
|
+
if chained: # R1-C78: no chain at all, through any module
|
|
428
|
+
v = _forbidden_chain(query, frm, to)
|
|
429
|
+
if v:
|
|
430
|
+
violations.append(v)
|
|
431
|
+
continue
|
|
328
432
|
bad = tuple((u, v) for (u, v, lu, lv) in edges if lu == frm and lv == to)
|
|
329
433
|
if bad:
|
|
330
434
|
violations.append(Violation(
|
|
@@ -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)
|
|
@@ -198,7 +198,11 @@ def _render_verdict(query, contract: ArchitectureContract, violations: list[Viol
|
|
|
198
198
|
if contract.independent:
|
|
199
199
|
rules.append(_rule_count("independent", appl))
|
|
200
200
|
if contract.forbidden:
|
|
201
|
-
|
|
201
|
+
count = _rule_count("forbidden", appl)
|
|
202
|
+
k = sum(contract.forbidden_indirect)
|
|
203
|
+
if k: # R1-C78 D7: a green run says which kind of rule held
|
|
204
|
+
count = count[:-1] + f", {k} indirect)"
|
|
205
|
+
rules.append(count)
|
|
202
206
|
if contract.no_cycles:
|
|
203
207
|
rules.append("no_cycles")
|
|
204
208
|
if contract.no_lazy_cycles:
|
|
@@ -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 &
|
|
@@ -148,7 +148,10 @@ tests/test_r1c73_cli_version.py
|
|
|
148
148
|
tests/test_r1c74_call_site_conservation.py
|
|
149
149
|
tests/test_r1c75_reexported_class_calls.py
|
|
150
150
|
tests/test_r1c76_contract_table_typo.py
|
|
151
|
+
tests/test_r1c77_gate_probe_battery.py
|
|
152
|
+
tests/test_r1c78_forbidden_chains.py
|
|
151
153
|
tests/test_r1c7_edge_vocab.py
|
|
154
|
+
tests/test_r1c80_tests_through_helpers.py
|
|
152
155
|
tests/test_r1c8_deadcode.py
|
|
153
156
|
tests/test_r1c9_incremental.py
|
|
154
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
|
|
@@ -216,8 +216,13 @@ def test_every_enforced_rule_names_itself_in_the_passing_line(tmp_path):
|
|
|
216
216
|
over the contract's own fields, so a rule added later without a line here fails."""
|
|
217
217
|
import dataclasses
|
|
218
218
|
quiet = Query(extract(str(_pkg(tmp_path, {"a": "from .b import x\n", "b": "x = 1\n"}))))
|
|
219
|
-
|
|
219
|
+
# `forbidden_indirect` (R1-C78) is not a rule but a flag per `forbidden` item; it has its own
|
|
220
|
+
# probe below, so skipping it here does not let it go unnamed.
|
|
221
|
+
skip = {"error", "path", "forbidden_indirect"}
|
|
220
222
|
names = [f.name for f in dataclasses.fields(arch.ArchitectureContract) if f.name not in skip]
|
|
223
|
+
chained = arch.ArchitectureContract(forbidden=(("core", "data"),), forbidden_indirect=(True,))
|
|
224
|
+
md = render_check(quiet, chained, arch.check_contract(quiet, chained))
|
|
225
|
+
assert "forbidden (1 declared, 0 applicable, 1 indirect)" in md, md
|
|
221
226
|
assert "no_type_only_cycles" in names
|
|
222
227
|
for name in names:
|
|
223
228
|
value = {"layers": ("core",), "independent": (("core", "data"),),
|