codmap 0.0.25__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.25/codmap.egg-info → codmap-0.0.27}/PKG-INFO +2 -2
- {codmap-0.0.25 → codmap-0.0.27}/README.md +1 -1
- {codmap-0.0.25 → codmap-0.0.27}/codemap/arch.py +131 -5
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/check.py +5 -1
- {codmap-0.0.25 → codmap-0.0.27/codmap.egg-info}/PKG-INFO +2 -2
- {codmap-0.0.25 → codmap-0.0.27}/codmap.egg-info/SOURCES.txt +3 -0
- {codmap-0.0.25 → codmap-0.0.27}/pyproject.toml +1 -1
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c49_type_only_cycles.py +6 -1
- codmap-0.0.27/tests/test_r1c76_contract_table_typo.py +68 -0
- 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.25 → codmap-0.0.27}/LICENSE +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/__init__.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/apidiff.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/cli.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/diagnostics.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/__init__.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/attrflow.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/behavior.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/dataflow.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/dispatch.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/griffe_extractor.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/gsource.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/probe.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/roots.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/union.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/freshness.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/incremental.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/__init__.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/base.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/cocoindex.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/gate.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/gitnexus.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/registry.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/transport.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/model.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/provenance.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/query.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/scope.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/__init__.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/_scip_pb2.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/api_surface.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/apidiff.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/architecture.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/audit.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/ctags.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/currency.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/impact.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/limits.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/livingdocs.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/mcp_server.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/mermaid.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/pack.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/rag.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/review.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/scip.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/semantic.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/server.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/session.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/subsystems.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/vault.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/store.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/tomlio.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codemap/watch.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codmap.egg-info/dependency_links.txt +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codmap.egg-info/entry_points.txt +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codmap.egg-info/requires.txt +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/codmap.egg-info/top_level.txt +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/setup.cfg +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_epistemic.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_gitnexus_router.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_impact_depth.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_integrations.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_issue3_serve_freshness.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_livingdocs.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m0_api_surface.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m11_argcontract.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m12_dataflow.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m15_review.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m16_architecture.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m17_mcp.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m18_freshness.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m1_5_semantics.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m1_query.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m2_views.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m32_watch.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m3_serve.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m4_behavior.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m5_deep.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m6_repo_scope.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m7_dispatch.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_m9_family.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_pack_honesty.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c13_callgraph_accuracy.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c13_grep_vs_graph.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c16_semantic.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c20_attribute_edges.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c21_flat_layout.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c22_source_visible_refs.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c23_hard_python.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c24_test_mapping.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c25_provenance.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c26_deep_union.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c27_config_honesty.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c28_limit_envelope.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c29_lazy_imports.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c2_ctags.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c30_local_import_calls.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c30f1_reexport_calls.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c30f2_check_scope.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c31_path_origin.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c32_report_json.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c33_query_signature.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c34_signature_kinds.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c35_contract_location.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c36_target_resolution.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c37_rule_mutation.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c38_tool_drift.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c39_edge_resolution.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c3_arch_contract.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c3_dogfood.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c40_flow_reach.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c41_scope_membership.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c42_deep_tier_stability.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c43_incremental_splice.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c44_absent_answers.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c45_repeat_union.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c46_shadowed_definitions.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c47_incremental_resample.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c48_type_checking_imports.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c4_complexity.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c50_flow_entry_points.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c53_narrowing_declared.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c54_deterministic_rendering.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c55_override_grade.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c56_stub_files.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c57_facade_package.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c58_cycle_tangles.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c59_completeness_drop.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c5_apidiff.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c60_nested_definitions.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c62_phantom_rules.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c63_answer_format.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c64_dependency_probe.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c66_pack_direction.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c68_dotted_imports.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c69_contract_validation.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c6_pack.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c70_check_graph_currency.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c71_attribute_chain_calls.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c72_flat_inference_in_packages.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c73_cli_version.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c74_call_site_conservation.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c75_reexported_class_calls.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c7_edge_vocab.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c8_deadcode.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c9_incremental.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_scip_export.py +0 -0
- {codmap-0.0.25 → codmap-0.0.27}/tests/test_scope.py +0 -0
- {codmap-0.0.25 → 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
|
|
@@ -101,9 +106,31 @@ def load_contract(root: str | Path = ".") -> ArchitectureContract:
|
|
|
101
106
|
data, error = read_toml(path)
|
|
102
107
|
if error:
|
|
103
108
|
return ArchitectureContract(error=error, path=str(path))
|
|
109
|
+
near = _near_miss_tables(data)
|
|
110
|
+
if near:
|
|
111
|
+
return ArchitectureContract(error=near, path=str(path))
|
|
104
112
|
return replace(parse_contract(data.get("architecture", {})), path=str(path))
|
|
105
113
|
|
|
106
114
|
|
|
115
|
+
def _near_miss_tables(data: dict) -> str | None:
|
|
116
|
+
"""R1-C76 (design ``contract_table_typo.md``): a table named like ``[architecture]`` but not it.
|
|
117
|
+
|
|
118
|
+
``[architectur]`` used to read as "no contract", exit 0: R1-C69 closed the schema inside the
|
|
119
|
+
table, and the table name stayed the one typo that turned a gate green. Refused even beside a
|
|
120
|
+
real ``[architecture]`` (D2): its rules would never run. Other tables are not checked (D3).
|
|
121
|
+
"""
|
|
122
|
+
import difflib
|
|
123
|
+
|
|
124
|
+
names = [k for k, v in data.items()
|
|
125
|
+
if k != "architecture" and isinstance(v, dict)
|
|
126
|
+
and difflib.SequenceMatcher(None, k.lower(), "architecture").ratio() >= 0.8]
|
|
127
|
+
if not names:
|
|
128
|
+
return None
|
|
129
|
+
listed = ", ".join(f"`[{k}]`" for k in names)
|
|
130
|
+
return (f"{listed} looks like a misspelling of `[architecture]`; codemap reads only "
|
|
131
|
+
"`[architecture]`, so its rules would never run")
|
|
132
|
+
|
|
133
|
+
|
|
107
134
|
#: R1-C69: the closed schema of ``[architecture]``. A key outside it is an error, not a skip.
|
|
108
135
|
_FLAGS = ("no_cycles", "no_lazy_cycles", "no_type_only_cycles", "exhaustive", "no_phantom_rules")
|
|
109
136
|
_KNOWN_KEYS = ("layers", "independent", "forbidden") + _FLAGS
|
|
@@ -113,6 +140,17 @@ def _is_str_list(v) -> bool:
|
|
|
113
140
|
return isinstance(v, list) and all(isinstance(x, str) for x in v)
|
|
114
141
|
|
|
115
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
|
+
|
|
116
154
|
def contract_problems(section) -> list[str]:
|
|
117
155
|
"""Every way ``section`` departs from the ``[architecture]`` schema, in reading order (R1-C69).
|
|
118
156
|
|
|
@@ -131,8 +169,14 @@ def contract_problems(section) -> list[str]:
|
|
|
131
169
|
near = difflib.get_close_matches(key, _KNOWN_KEYS, n=1, cutoff=0.6)
|
|
132
170
|
hint = f" (did you mean `{near[0]}`?)" if near else ""
|
|
133
171
|
problems.append(f"unknown key `{key}`{hint}; known keys: {', '.join(_KNOWN_KEYS)}")
|
|
134
|
-
if "layers" in section
|
|
135
|
-
|
|
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}`")
|
|
136
180
|
if "independent" in section:
|
|
137
181
|
groups = section["independent"]
|
|
138
182
|
if not isinstance(groups, list):
|
|
@@ -141,6 +185,9 @@ def contract_problems(section) -> list[str]:
|
|
|
141
185
|
for i, grp in enumerate(groups):
|
|
142
186
|
if not _is_str_list(grp) or len(grp) < 2:
|
|
143
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}`")
|
|
144
191
|
if "forbidden" in section:
|
|
145
192
|
items = section["forbidden"]
|
|
146
193
|
if not isinstance(items, list):
|
|
@@ -150,17 +197,23 @@ def contract_problems(section) -> list[str]:
|
|
|
150
197
|
if not isinstance(item, dict):
|
|
151
198
|
problems.append(f"`forbidden[{i}]` must be a table `{{ from = \"…\", to = \"…\" }}`")
|
|
152
199
|
continue
|
|
153
|
-
extra = sorted(set(item) - {"from", "to"})
|
|
200
|
+
extra = sorted(set(item) - {"from", "to", "indirect"})
|
|
154
201
|
missing = [k for k in ("from", "to") if k not in item]
|
|
155
202
|
wrong = [k for k in ("from", "to") if k in item and not isinstance(item[k], str)]
|
|
156
203
|
if extra:
|
|
157
204
|
problems.append(f"`forbidden[{i}]` has unknown key(s) "
|
|
158
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}`")
|
|
159
209
|
if missing:
|
|
160
210
|
problems.append(f"`forbidden[{i}]` is missing " + ", ".join(f"`{k}`" for k in missing))
|
|
161
211
|
if wrong:
|
|
162
212
|
problems.append(f"`forbidden[{i}]`: " + ", ".join(f"`{k}`" for k in wrong)
|
|
163
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']}`")
|
|
164
217
|
for flag in _FLAGS:
|
|
165
218
|
if flag in section and not isinstance(section[flag], bool):
|
|
166
219
|
problems.append(f"`{flag}` must be true or false, got {type(section[flag]).__name__} "
|
|
@@ -181,10 +234,11 @@ def parse_contract(section: dict) -> ArchitectureContract:
|
|
|
181
234
|
if problems:
|
|
182
235
|
return ArchitectureContract(
|
|
183
236
|
error="`[architecture]` is not a valid contract: " + "; ".join(problems))
|
|
184
|
-
forbidden = []
|
|
237
|
+
forbidden, indirect = [], []
|
|
185
238
|
for item in section.get("forbidden", []) or []:
|
|
186
239
|
if isinstance(item, dict) and "from" in item and "to" in item:
|
|
187
240
|
forbidden.append((str(item["from"]), str(item["to"])))
|
|
241
|
+
indirect.append(bool(item.get("indirect", False)))
|
|
188
242
|
independent = tuple(
|
|
189
243
|
tuple(str(x) for x in grp)
|
|
190
244
|
for grp in (section.get("independent", []) or [])
|
|
@@ -194,6 +248,7 @@ def parse_contract(section: dict) -> ArchitectureContract:
|
|
|
194
248
|
layers=tuple(str(x) for x in (section.get("layers", []) or [])),
|
|
195
249
|
independent=independent,
|
|
196
250
|
forbidden=tuple(forbidden),
|
|
251
|
+
forbidden_indirect=tuple(indirect),
|
|
197
252
|
no_cycles=bool(section.get("no_cycles", False)),
|
|
198
253
|
no_lazy_cycles=bool(section.get("no_lazy_cycles", False)),
|
|
199
254
|
no_type_only_cycles=bool(section.get("no_type_only_cycles", False)),
|
|
@@ -265,6 +320,71 @@ def _tangle_line(tangle: dict) -> str:
|
|
|
265
320
|
return f"{head} (tangle of {tangle['size']}: {', '.join(tangle['modules'])})"
|
|
266
321
|
|
|
267
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
|
+
|
|
268
388
|
def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
269
389
|
"""Evaluate every rule against the graph; return the violations (empty = clean)."""
|
|
270
390
|
if contract.is_empty():
|
|
@@ -302,7 +422,13 @@ def check_contract(query, contract: ArchitectureContract) -> list[Violation]:
|
|
|
302
422
|
))
|
|
303
423
|
|
|
304
424
|
# -- forbidden: explicit from→to bans ---------------------------------------
|
|
305
|
-
|
|
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
|
|
306
432
|
bad = tuple((u, v) for (u, v, lu, lv) in edges if lu == frm and lv == to)
|
|
307
433
|
if bad:
|
|
308
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 &
|
|
@@ -147,6 +147,9 @@ tests/test_r1c72_flat_inference_in_packages.py
|
|
|
147
147
|
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
|
+
tests/test_r1c76_contract_table_typo.py
|
|
151
|
+
tests/test_r1c77_gate_probe_battery.py
|
|
152
|
+
tests/test_r1c78_forbidden_chains.py
|
|
150
153
|
tests/test_r1c7_edge_vocab.py
|
|
151
154
|
tests/test_r1c8_deadcode.py
|
|
152
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,68 @@
|
|
|
1
|
+
"""R1-C76 — a misspelled `[architecture]` table is refused, not read as "no contract".
|
|
2
|
+
|
|
3
|
+
Found by asking codemap the question its import-linter card asked of import-linter (zero contracts pass).
|
|
4
|
+
Design `docs/design/contract_table_typo.md`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from codemap import arch
|
|
12
|
+
from codemap.cli import main
|
|
13
|
+
from codemap.extract import extract
|
|
14
|
+
from codemap.query import Query
|
|
15
|
+
from codemap.serve.check import build_check
|
|
16
|
+
|
|
17
|
+
RULE = 'forbidden = [{from="core", to="analysis"}]\n'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture(scope="module")
|
|
21
|
+
def pkg(tmp_path_factory):
|
|
22
|
+
root = tmp_path_factory.mktemp("r1c76") / "tpkg"
|
|
23
|
+
for d in (root, root / "core", root / "analysis"):
|
|
24
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
(d / "__init__.py").write_text("")
|
|
26
|
+
(root / "analysis" / "b.py").write_text("X = 1\n")
|
|
27
|
+
(root / "core" / "a.py").write_text("from tpkg.analysis import b\n")
|
|
28
|
+
return root
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _check(tmp_path, pkg, toml, capsys, *extra) -> tuple[int, str]:
|
|
32
|
+
if toml is not None:
|
|
33
|
+
(tmp_path / "codemap.toml").write_text(toml)
|
|
34
|
+
rc = main(["check", "--build", str(pkg), "--root", str(tmp_path), *extra])
|
|
35
|
+
return rc, capsys.readouterr().out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize("toml, name", [
|
|
39
|
+
(f"[architectur]\n{RULE}", "architectur"),
|
|
40
|
+
(f"[Architecture]\n{RULE}", "Architecture"),
|
|
41
|
+
(f"[archtecture]\n{RULE}", "archtecture"),
|
|
42
|
+
(f"[architecture]\nno_cycles = true\n\n[architectur]\n{RULE}", "architectur"),
|
|
43
|
+
], ids=["A1", "A2-case", "A2-letter", "A3-both"])
|
|
44
|
+
def test_a_near_miss_table_is_refused(tmp_path, pkg, capsys, toml, name):
|
|
45
|
+
rc, out = _check(tmp_path, pkg, toml, capsys)
|
|
46
|
+
assert rc == 2, out
|
|
47
|
+
assert f"`[{name}]`" in out and "`[architecture]`" in out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_mcp_check_carries_the_refusal(tmp_path, pkg):
|
|
51
|
+
(tmp_path / "codemap.toml").write_text(f"[architectur]\n{RULE}")
|
|
52
|
+
c = arch.load_contract(tmp_path)
|
|
53
|
+
q = Query(extract(str(pkg)))
|
|
54
|
+
payload = build_check(q, c, arch.check_contract(q, c))
|
|
55
|
+
assert payload["ok"] is False
|
|
56
|
+
assert "`[architectur]`" in payload["contract_error"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_what_was_valid_or_absent_answers_as_before(tmp_path, pkg, capsys):
|
|
60
|
+
rc, out = _check(tmp_path, pkg, f"[architecture]\n{RULE}", capsys)
|
|
61
|
+
assert rc == 2 and "1 rule(s) broken" in out # a real violation, as before
|
|
62
|
+
rc, out = _check(tmp_path, pkg, '[integrations]\nenabled = []\n', capsys)
|
|
63
|
+
assert rc == 0 and "No `[architecture]` contract found" in out # unrelated table: still absent
|
|
64
|
+
with pytest.raises(SystemExit, match="no \\[architecture\\] contract found"):
|
|
65
|
+
_check(tmp_path, pkg, None, capsys, "--require-contract") # still opt-in for absence (exit 1)
|
|
66
|
+
(tmp_path / "codemap.toml").unlink()
|
|
67
|
+
rc, out = _check(tmp_path, pkg, None, capsys)
|
|
68
|
+
assert rc == 0 and "No `[architecture]` contract found" in out # no file at all
|
|
@@ -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
|