codmap 0.0.26__tar.gz → 0.0.27__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.27}/PKG-INFO +2 -2
- {codmap-0.0.26 → codmap-0.0.27}/README.md +1 -1
- {codmap-0.0.26 → codmap-0.0.27}/codemap/arch.py +109 -5
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/check.py +5 -1
- {codmap-0.0.26 → codmap-0.0.27/codmap.egg-info}/PKG-INFO +2 -2
- {codmap-0.0.26 → codmap-0.0.27}/codmap.egg-info/SOURCES.txt +2 -0
- {codmap-0.0.26 → codmap-0.0.27}/pyproject.toml +1 -1
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c49_type_only_cycles.py +6 -1
- codmap-0.0.27/tests/test_r1c77_gate_probe_battery.py +223 -0
- codmap-0.0.27/tests/test_r1c78_forbidden_chains.py +138 -0
- {codmap-0.0.26 → codmap-0.0.27}/LICENSE +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/__init__.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/apidiff.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/cli.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/diagnostics.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/extract/griffe_extractor.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/extract/probe.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/extract/roots.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/extract/union.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/freshness.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/incremental.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/integrations/base.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/model.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/provenance.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/query.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/scope.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/audit.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/currency.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/impact.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/limits.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/pack.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/rag.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/review.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/scip.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/server.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/session.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/serve/vault.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/store.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/tomlio.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codemap/watch.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/setup.cfg +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_epistemic.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_integrations.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m15_review.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m1_query.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m2_views.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_m9_family.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_pack_honesty.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c40_flow_reach.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c48_type_checking_imports.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c50_flow_entry_points.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c53_narrowing_declared.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c54_deterministic_rendering.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c55_override_grade.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c56_stub_files.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c57_facade_package.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c58_cycle_tangles.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c59_completeness_drop.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c60_nested_definitions.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c62_phantom_rules.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c63_answer_format.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c64_dependency_probe.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c66_pack_direction.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c68_dotted_imports.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c69_contract_validation.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c70_check_graph_currency.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c71_attribute_chain_calls.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c72_flat_inference_in_packages.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c73_cli_version.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c74_call_site_conservation.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c75_reexported_class_calls.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c76_contract_table_typo.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_scip_export.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/tests/test_scope.py +0 -0
- {codmap-0.0.26 → codmap-0.0.27}/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.27
|
|
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, **1180 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, **1180 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(
|
|
@@ -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:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codmap
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.27
|
|
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, **1180 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,6 +148,8 @@ 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
|
|
152
154
|
tests/test_r1c8_deadcode.py
|
|
153
155
|
tests/test_r1c9_incremental.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.27"
|
|
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"),),
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""R1-C77 — the gate probe battery: the questions asked of import-linter, asked of `codemap check`.
|
|
2
|
+
|
|
3
|
+
Design `docs/design/gate_probe_battery.md` (predictions pre-registered in 312c104). One fixture, `gp` with
|
|
4
|
+
layers `high > mid > low`; every probe is a fresh copy with one change. A probe whose answer is a defect is
|
|
5
|
+
`xfail(strict=True)` against the *right* answer, with the number of its fix, so fixing it turns this red
|
|
6
|
+
until the mark comes off.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from codemap.cli import main
|
|
17
|
+
|
|
18
|
+
LAYERS = '[architecture]\nlayers = ["high", "mid", "low"]\n'
|
|
19
|
+
UP = "from gp.high import h\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture(scope="module")
|
|
23
|
+
def base(tmp_path_factory) -> Path:
|
|
24
|
+
root = tmp_path_factory.mktemp("r1c77") / "proj"
|
|
25
|
+
gp = root / "gp"
|
|
26
|
+
for d in ("", "high", "mid", "low"):
|
|
27
|
+
(gp / d).mkdir(parents=True, exist_ok=True)
|
|
28
|
+
(gp / d / "__init__.py").write_text("")
|
|
29
|
+
(gp / "high" / "h.py").write_text("from gp.mid import m\n\nclass X:\n pass\n")
|
|
30
|
+
(gp / "mid" / "m.py").write_text("from gp.low import l\n")
|
|
31
|
+
(gp / "low" / "l.py").write_text("Y = 1\n")
|
|
32
|
+
(root / "codemap.toml").write_text(LAYERS)
|
|
33
|
+
return root
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _copy(base: Path, tmp_path: Path) -> Path:
|
|
37
|
+
dst = tmp_path / "proj"
|
|
38
|
+
shutil.copytree(base, dst)
|
|
39
|
+
return dst
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run(root: Path, capsys, *extra) -> tuple[int, str]:
|
|
43
|
+
rc = main(["check", "--build", str(root / "gp"), "--root", str(root), *extra])
|
|
44
|
+
return rc, capsys.readouterr().out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# -- import shapes: the change is in gp/low/l.py unless the probe says otherwise -------------------
|
|
48
|
+
|
|
49
|
+
SHAPES = {
|
|
50
|
+
"P0": ("low/l.py", UP),
|
|
51
|
+
"P1": ("low/l.py", "def f():\n from gp.high import h\n return h\n"),
|
|
52
|
+
"P2": ("low/l.py", "from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n from gp.high import h\n"),
|
|
53
|
+
"P3": ("low/l.py", "import gp.high.h\n"),
|
|
54
|
+
"P4": ("low/__init__.py", "from ..high import h\n"),
|
|
55
|
+
"P5": ("low/l.py", "from gp import high\n"),
|
|
56
|
+
"P6": ("low/l.py", "from .. import high\n"),
|
|
57
|
+
"P7": ("low/l.py", "import gp.high.h as hh\n"),
|
|
58
|
+
"P8": ("low/l.py", "from gp.high.h import *\n"),
|
|
59
|
+
"P9": ("low/l.py", "try:\n from gp.high import h\nexcept ImportError:\n h = None\n"),
|
|
60
|
+
"P10": ("low/l.py", "class K:\n from gp.high import h\n"),
|
|
61
|
+
"P11": ("low/l.py", "import sys\nif sys.version_info >= (3, 8):\n from gp.high import h\n"),
|
|
62
|
+
"P12": ("low/l.py", "class K:\n def m(self):\n def inner():\n"
|
|
63
|
+
" from gp.high import h\n return h\n return inner\n"),
|
|
64
|
+
"P13": ("low/l.py", "import gp.high\n\ndef f():\n return gp.high.h.X\n"),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_P0c_control_clean_tree_is_satisfied(base, tmp_path, capsys):
|
|
69
|
+
rc, out = _run(_copy(base, tmp_path), capsys)
|
|
70
|
+
assert rc == 0 and "Contract satisfied" in out, out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize("probe", sorted(SHAPES, key=lambda p: int(p[1:])))
|
|
74
|
+
def test_an_upward_import_is_caught_in_every_shape(base, tmp_path, capsys, probe):
|
|
75
|
+
root = _copy(base, tmp_path)
|
|
76
|
+
rel, src = SHAPES[probe]
|
|
77
|
+
(root / "gp" / rel).write_text(src)
|
|
78
|
+
rc, out = _run(root, capsys)
|
|
79
|
+
assert rc == 2, f"{probe}: {out}"
|
|
80
|
+
# Which edge, not only whether (the C11 lesson): from gp.low, into gp.high.
|
|
81
|
+
assert "`layered`" in out and "- `gp.low" in out and "` → `gp.high" in out, f"{probe}: {out}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_P14_a_dynamic_import_is_a_documented_limit(base, tmp_path, capsys):
|
|
85
|
+
root = _copy(base, tmp_path)
|
|
86
|
+
(root / "gp" / "low" / "l.py").write_text('import importlib\nh = importlib.import_module("gp.high.h")\n')
|
|
87
|
+
rc, out = _run(root, capsys)
|
|
88
|
+
assert rc == 0, out # a static tool cannot see it; import-linter is blind to it too
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# -- contract shapes -------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _with(base, tmp_path, toml: str, src: str | None = UP, rel: str = "low/l.py") -> Path:
|
|
94
|
+
root = _copy(base, tmp_path)
|
|
95
|
+
(root / "codemap.toml").write_text(toml)
|
|
96
|
+
if src is not None:
|
|
97
|
+
(root / "gp" / rel).write_text(src)
|
|
98
|
+
return root
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
REFUSED = {
|
|
102
|
+
"C1": (LAYERS + "exhaustiv = true\n", ["unknown key `exhaustiv`", "did you mean `exhaustive`"]),
|
|
103
|
+
"C3": ('[architecture]\nforbidden = [{from = "low", too = "high"}]\n', ["unknown key(s) `too`", "missing `to`"]),
|
|
104
|
+
"C6": (LAYERS + 'exhaustive = "maybe"\n', ["`exhaustive` must be true or false"]),
|
|
105
|
+
"C8": ('[architectur]\nlayers = ["high", "mid", "low"]\n', ["`[architectur]`"]),
|
|
106
|
+
"C9": ('[architecture]\nforbidden = {from = "low", to = "high"}\n', ["`forbidden` must be a list"]),
|
|
107
|
+
"C10": ('[architecture]\nLayers = ["high", "mid", "low"]\n', ["unknown key `Layers`", "did you mean `layers`"]),
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.parametrize("probe", sorted(REFUSED, key=lambda p: int(p[1:])))
|
|
112
|
+
def test_a_malformed_contract_is_refused(base, tmp_path, capsys, probe):
|
|
113
|
+
toml, heard = REFUSED[probe]
|
|
114
|
+
rc, out = _run(_with(base, tmp_path, toml), capsys)
|
|
115
|
+
assert rc == 2 and "nothing was enforced" in out, f"{probe}: {out}"
|
|
116
|
+
for line in heard:
|
|
117
|
+
assert line in out, f"{probe}: {line!r} not in {out}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_C15_indirect_is_accepted_since_r1c78(base, tmp_path, capsys):
|
|
121
|
+
# First run (73a757e, before R1-C78): refused as an unknown key, as predicted. Now the key exists.
|
|
122
|
+
toml = '[architecture]\nforbidden = [{from = "low", to = "high", indirect = true}]\n'
|
|
123
|
+
rc, out = _run(_with(base, tmp_path, toml), capsys)
|
|
124
|
+
assert rc == 2 and "`low` must not reach `high`" in out and "- `gp.low.l` → `gp.high.h`" in out, out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_C2_exhaustive_names_an_undeclared_layer(base, tmp_path, capsys):
|
|
128
|
+
root = _with(base, tmp_path, LAYERS + "exhaustive = true\n", src=None)
|
|
129
|
+
(root / "gp" / "extra").mkdir()
|
|
130
|
+
(root / "gp" / "extra" / "__init__.py").write_text("")
|
|
131
|
+
rc, out = _run(root, capsys)
|
|
132
|
+
assert rc == 2 and "`exhaustive`" in out and "- extra" in out, out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_C4_a_misspelled_forbidden_layer_is_inert_and_disclosed(base, tmp_path, capsys):
|
|
136
|
+
toml = '[architecture]\nforbidden = [{from = "low", to = "hihg"}]\n'
|
|
137
|
+
rc, out = _run(_with(base, tmp_path, toml), capsys)
|
|
138
|
+
assert rc == 0, out # R1-C62: inert by design ...
|
|
139
|
+
assert "forbidden (1 declared, 0 applicable)" in out and "`hihg`" in out, out # ... and said
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_C4b_no_phantom_rules_makes_it_a_failure(base, tmp_path, capsys):
|
|
143
|
+
toml = '[architecture]\nforbidden = [{from = "low", to = "hihg"}]\nno_phantom_rules = true\n'
|
|
144
|
+
rc, out = _run(_with(base, tmp_path, toml), capsys)
|
|
145
|
+
assert rc == 2 and "`no_phantom_rules`" in out and "hihg" in out, out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_C5_a_misspelled_layer_is_inert_and_disclosed(base, tmp_path, capsys):
|
|
149
|
+
toml = '[architecture]\nlayers = ["hihg", "mid", "low"]\n'
|
|
150
|
+
rc, out = _run(_with(base, tmp_path, toml), capsys)
|
|
151
|
+
assert rc == 0, out
|
|
152
|
+
assert "layered (3 declared, 2 applicable)" in out and "`hihg`" in out, out
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_C7_no_contract_passes_unless_required(base, tmp_path, capsys):
|
|
156
|
+
root = _copy(base, tmp_path)
|
|
157
|
+
(root / "codemap.toml").unlink()
|
|
158
|
+
(root / "gp" / "low" / "l.py").write_text(UP)
|
|
159
|
+
rc, out = _run(root, capsys)
|
|
160
|
+
assert rc == 0 and "No `[architecture]` contract found" in out, out
|
|
161
|
+
with pytest.raises(SystemExit, match="no \\[architecture\\] contract found"):
|
|
162
|
+
_run(root, capsys, "--require-contract")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_C11_a_repeated_layer_is_refused(base, tmp_path, capsys):
|
|
166
|
+
# R1-C79. First run (73a757e): exit 2, but for the wrong edge. The second `high` overwrote the first in
|
|
167
|
+
# `rank`, the legal `gp.high.h → gp.mid.m` was named as the violation and the real upward
|
|
168
|
+
# `gp.mid.m → gp.high.h` was not. An exit-code-only assertion passed on it.
|
|
169
|
+
toml = '[architecture]\nlayers = ["high", "mid", "high", "low"]\n'
|
|
170
|
+
rc, out = _run(_with(base, tmp_path, toml, src="from gp.high import h\n", rel="mid/m.py"), capsys)
|
|
171
|
+
assert rc == 2 and "nothing was enforced" in out and "`layers` repeats `high`" in out, out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_C12_an_empty_layers_list(base, tmp_path, capsys):
|
|
175
|
+
rc, out = _run(_with(base, tmp_path, "[architecture]\nlayers = []\n"), capsys)
|
|
176
|
+
assert rc == 0 and "No `[architecture]` contract found" in out, out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_C13_a_same_layer_forbidden_item_is_refused(base, tmp_path, capsys):
|
|
180
|
+
rc, out = _run(_with(base, tmp_path, '[architecture]\nforbidden = [{from = "low", to = "low"}]\n',
|
|
181
|
+
src=None), capsys)
|
|
182
|
+
assert rc == 2 and "`from` and `to` are both `low`" in out, out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_C14_an_independent_group_naming_one_layer_twice_is_refused(base, tmp_path, capsys):
|
|
186
|
+
rc, out = _run(_with(base, tmp_path, '[architecture]\nindependent = [["low", "low"]]\n', src=None), capsys)
|
|
187
|
+
assert rc == 2 and "`independent[0]` repeats `low`" in out, out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# -- graph and invocation --------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def test_G1_a_stale_graph_fails_unless_allowed(base, tmp_path, capsys):
|
|
193
|
+
root = _copy(base, tmp_path)
|
|
194
|
+
graph = tmp_path / "g.json"
|
|
195
|
+
assert main(["build", str(root / "gp"), "--out", str(graph)]) == 0
|
|
196
|
+
capsys.readouterr()
|
|
197
|
+
(root / "gp" / "low" / "l.py").write_text(UP)
|
|
198
|
+
rc = main(["check", "--graph", str(graph), "--root", str(root)])
|
|
199
|
+
out = capsys.readouterr().out
|
|
200
|
+
assert rc == 2 and "behind its source tree" in out, out
|
|
201
|
+
rc = main(["check", "--graph", str(graph), "--root", str(root), "--allow-stale"])
|
|
202
|
+
out = capsys.readouterr().out
|
|
203
|
+
assert rc == 0 and "behind its source tree" in out, out # G1b
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_G2_a_build_path_that_does_not_exist(tmp_path, capsys):
|
|
207
|
+
missing = tmp_path / "nope"
|
|
208
|
+
try:
|
|
209
|
+
rc = main(["check", "--build", str(missing), "--root", str(tmp_path)])
|
|
210
|
+
except SystemExit as e:
|
|
211
|
+
rc = e.code
|
|
212
|
+
captured = capsys.readouterr()
|
|
213
|
+
assert rc not in (0, None), captured
|
|
214
|
+
assert "nope" in (captured.out + captured.err + str(rc)), captured
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_G3_a_root_without_a_contract_names_where_it_looked(base, tmp_path, capsys):
|
|
218
|
+
root = _copy(base, tmp_path)
|
|
219
|
+
elsewhere = tmp_path / "elsewhere"
|
|
220
|
+
elsewhere.mkdir()
|
|
221
|
+
rc = main(["check", "--build", str(root / "gp"), "--root", str(elsewhere)])
|
|
222
|
+
out = capsys.readouterr().out
|
|
223
|
+
assert rc == 0 and str(elsewhere.resolve() / "codemap.toml") in out, out
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""R1-C78 — `forbidden` with `indirect = true`: no chain of imports from one layer to another.
|
|
2
|
+
|
|
3
|
+
Design `docs/design/forbidden_chains.md` (acceptance pre-registered in 312c104, oracle in e466ee1).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from codemap import arch
|
|
14
|
+
from codemap.cli import main
|
|
15
|
+
from codemap.extract import extract, extract_repo
|
|
16
|
+
from codemap.query import Query
|
|
17
|
+
from codemap.serve.check import build_check
|
|
18
|
+
|
|
19
|
+
CHAIN = '[architecture]\nforbidden = [{from = "data", to = "analysis", indirect = true}]\n'
|
|
20
|
+
DIRECT = '[architecture]\nforbidden = [{from = "data", to = "analysis"}]\n'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _pkg(root: Path, files: dict[str, str]) -> Path:
|
|
24
|
+
pkg = root / "pkg"
|
|
25
|
+
for rel, src in {"__init__.py": "", **files}.items():
|
|
26
|
+
p = pkg / rel
|
|
27
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
for parent in p.relative_to(pkg).parents:
|
|
29
|
+
init = pkg / parent / "__init__.py"
|
|
30
|
+
if not init.exists():
|
|
31
|
+
init.write_text("")
|
|
32
|
+
p.write_text(src)
|
|
33
|
+
return pkg
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check(tmp_path: Path, files: dict[str, str], toml: str, capsys) -> tuple[int, str]:
|
|
37
|
+
pkg = _pkg(tmp_path, files)
|
|
38
|
+
(tmp_path / "codemap.toml").write_text(toml)
|
|
39
|
+
rc = main(["check", "--build", str(pkg), "--root", str(tmp_path)])
|
|
40
|
+
return rc, capsys.readouterr().out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
BASE = {
|
|
44
|
+
"data/d.py": "from pkg.core import c\n",
|
|
45
|
+
"core/c.py": "from pkg.analysis import a\n",
|
|
46
|
+
"analysis/a.py": "X = 1\n",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_A1_a_two_hop_chain_breaks_an_indirect_item_and_not_a_direct_one(tmp_path, capsys):
|
|
51
|
+
rc, out = _check(tmp_path, BASE, CHAIN, capsys)
|
|
52
|
+
assert rc == 2, out
|
|
53
|
+
assert "`data` must not reach `analysis`, directly or through other modules: 1 module(s)" in out
|
|
54
|
+
assert "- `pkg.core.c` → `pkg.analysis.a`" in out # the entry edge
|
|
55
|
+
assert "- `pkg.data.d` → `pkg.core.c` → `pkg.analysis.a`" in out # its witness
|
|
56
|
+
rc, out = _check(tmp_path, BASE, DIRECT, capsys)
|
|
57
|
+
assert rc == 0 and "forbidden (1)" in out, out # direct: unchanged
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.mark.parametrize("middle", [
|
|
61
|
+
"def f():\n from pkg.analysis import a\n return a\n",
|
|
62
|
+
"from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n from pkg.analysis import a\n",
|
|
63
|
+
], ids=["function-local", "type-checking"])
|
|
64
|
+
def test_A2_every_import_scope_carries_a_chain(tmp_path, capsys, middle):
|
|
65
|
+
rc, out = _check(tmp_path, {**BASE, "core/c.py": middle}, CHAIN, capsys)
|
|
66
|
+
assert rc == 2 and "`pkg.data.d` → `pkg.core.c` → `pkg.analysis.a`" in out, out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_A3_a_chain_through_an_undeclared_layer(tmp_path, capsys):
|
|
70
|
+
files = {"data/d.py": "from pkg.utils import u\n", "utils/u.py": "from pkg.analysis import a\n",
|
|
71
|
+
"analysis/a.py": "X = 1\n"}
|
|
72
|
+
toml = '[architecture]\nlayers = ["analysis", "data"]\n' + CHAIN.split("\n", 1)[1]
|
|
73
|
+
rc, out = _check(tmp_path, files, toml, capsys)
|
|
74
|
+
assert rc == 2, out
|
|
75
|
+
assert "`layered`" not in out # the stack alone sees nothing
|
|
76
|
+
assert "`pkg.data.d` → `pkg.utils.u` → `pkg.analysis.a`" in out, out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_A4_a_consumer_root_does_not_create_a_chain(tmp_path):
|
|
80
|
+
# Vacuous by construction, and asserted as such: codemap's graph has no edge from core into a
|
|
81
|
+
# consumer root (even `import helper` with `tests/helper.py` present is not resolved), so no
|
|
82
|
+
# chain can pass through one and the `root_of == "core"` filter in `_forbidden_chain` is
|
|
83
|
+
# defensive. What is pinned here is the property the filter relies on.
|
|
84
|
+
pkg = _pkg(tmp_path, {"data/d.py": "import helper\n", "analysis/a.py": "Y = 1\n"})
|
|
85
|
+
tests = tmp_path / "tests"
|
|
86
|
+
tests.mkdir()
|
|
87
|
+
(tests / "helper.py").write_text("from pkg.analysis import a\n")
|
|
88
|
+
(tests / "test_x.py").write_text("from pkg.data import d\nfrom pkg.analysis import a\n")
|
|
89
|
+
(tmp_path / "codemap.toml").write_text(CHAIN)
|
|
90
|
+
q = Query(extract_repo(str(pkg), consumers=(str(tests),), mode="full"))
|
|
91
|
+
ig = q.import_graph
|
|
92
|
+
assert any(q.root_of(u) != "core" for u, _ in ig.edges()) # consumers are in the graph
|
|
93
|
+
assert not any(q.root_of(u) == "core" and q.root_of(v) != "core" for u, v in ig.edges())
|
|
94
|
+
assert arch.check_contract(q, arch.load_contract(tmp_path)) == []
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_A5_the_shortest_then_smallest_witness_and_a_stable_answer(tmp_path, capsys):
|
|
98
|
+
files = {
|
|
99
|
+
"data/a.py": "from pkg.core import z\n", # data.a → core.z → core.m → analysis.t (3)
|
|
100
|
+
"data/b.py": "from pkg.core import y\n", # data.b → core.y → core.m → analysis.t (3)
|
|
101
|
+
"data/c.py": "from pkg.core import m\n", # data.c → core.m → analysis.t (2) ← shortest
|
|
102
|
+
"core/z.py": "from pkg.core import m\n",
|
|
103
|
+
"core/y.py": "from pkg.core import m\n",
|
|
104
|
+
"core/m.py": "from pkg.analysis import t\n",
|
|
105
|
+
"analysis/t.py": "T = 1\n",
|
|
106
|
+
}
|
|
107
|
+
rc, out = _check(tmp_path, files, CHAIN, capsys)
|
|
108
|
+
assert rc == 2 and "3 module(s) of `data` reach it" in out, out
|
|
109
|
+
assert "- `pkg.data.c` → `pkg.core.m` → `pkg.analysis.t`" in out, out
|
|
110
|
+
files2 = {**files, "data/c.py": "X = 1\n"} # two chains of 3: a < b
|
|
111
|
+
rc, out1 = _check(tmp_path, files2, CHAIN, capsys)
|
|
112
|
+
assert "- `pkg.data.a` → `pkg.core.z` → `pkg.core.m` → `pkg.analysis.t`" in out1, out1
|
|
113
|
+
rc, out2 = _check(tmp_path, files2, CHAIN, capsys)
|
|
114
|
+
assert out1 == out2
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.parametrize("value, rc_want", [('"yes"', 2), ("false", 0)])
|
|
118
|
+
def test_A6_indirect_is_a_boolean(tmp_path, capsys, value, rc_want):
|
|
119
|
+
toml = f'[architecture]\nforbidden = [{{from = "data", to = "analysis", indirect = {value}}}]\n'
|
|
120
|
+
rc, out = _check(tmp_path, BASE, toml, capsys)
|
|
121
|
+
assert rc == rc_want, out
|
|
122
|
+
if rc_want == 2:
|
|
123
|
+
assert "`forbidden[0]`: `indirect` must be true or false" in out
|
|
124
|
+
else:
|
|
125
|
+
assert "forbidden (1)" in out # false is the same as absent
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_the_structured_answer_carries_entry_edges_and_witnesses(tmp_path):
|
|
129
|
+
pkg = _pkg(tmp_path, BASE)
|
|
130
|
+
(tmp_path / "codemap.toml").write_text(CHAIN)
|
|
131
|
+
c = arch.load_contract(tmp_path)
|
|
132
|
+
q = Query(extract(str(pkg)))
|
|
133
|
+
payload = build_check(q, c, arch.check_contract(q, c))
|
|
134
|
+
(v,) = payload["violations"]
|
|
135
|
+
assert payload["ok"] is False and v["rule"] == "forbidden"
|
|
136
|
+
assert v["edges"] == [["pkg.core.c", "pkg.analysis.a"]]
|
|
137
|
+
assert v["modules"] == ["`pkg.data.d` → `pkg.core.c` → `pkg.analysis.a`"]
|
|
138
|
+
json.dumps(payload)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|