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.
Files changed (160) hide show
  1. {codmap-0.0.25/codmap.egg-info → codmap-0.0.27}/PKG-INFO +2 -2
  2. {codmap-0.0.25 → codmap-0.0.27}/README.md +1 -1
  3. {codmap-0.0.25 → codmap-0.0.27}/codemap/arch.py +131 -5
  4. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/check.py +5 -1
  5. {codmap-0.0.25 → codmap-0.0.27/codmap.egg-info}/PKG-INFO +2 -2
  6. {codmap-0.0.25 → codmap-0.0.27}/codmap.egg-info/SOURCES.txt +3 -0
  7. {codmap-0.0.25 → codmap-0.0.27}/pyproject.toml +1 -1
  8. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c49_type_only_cycles.py +6 -1
  9. codmap-0.0.27/tests/test_r1c76_contract_table_typo.py +68 -0
  10. codmap-0.0.27/tests/test_r1c77_gate_probe_battery.py +223 -0
  11. codmap-0.0.27/tests/test_r1c78_forbidden_chains.py +138 -0
  12. {codmap-0.0.25 → codmap-0.0.27}/LICENSE +0 -0
  13. {codmap-0.0.25 → codmap-0.0.27}/codemap/__init__.py +0 -0
  14. {codmap-0.0.25 → codmap-0.0.27}/codemap/apidiff.py +0 -0
  15. {codmap-0.0.25 → codmap-0.0.27}/codemap/cli.py +0 -0
  16. {codmap-0.0.25 → codmap-0.0.27}/codemap/diagnostics.py +0 -0
  17. {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/__init__.py +0 -0
  18. {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/attrflow.py +0 -0
  19. {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/behavior.py +0 -0
  20. {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/dataflow.py +0 -0
  21. {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/dispatch.py +0 -0
  22. {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/griffe_extractor.py +0 -0
  23. {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/gsource.py +0 -0
  24. {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/probe.py +0 -0
  25. {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/roots.py +0 -0
  26. {codmap-0.0.25 → codmap-0.0.27}/codemap/extract/union.py +0 -0
  27. {codmap-0.0.25 → codmap-0.0.27}/codemap/freshness.py +0 -0
  28. {codmap-0.0.25 → codmap-0.0.27}/codemap/incremental.py +0 -0
  29. {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/__init__.py +0 -0
  30. {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/base.py +0 -0
  31. {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/cocoindex.py +0 -0
  32. {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/gate.py +0 -0
  33. {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/gitnexus.py +0 -0
  34. {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/registry.py +0 -0
  35. {codmap-0.0.25 → codmap-0.0.27}/codemap/integrations/transport.py +0 -0
  36. {codmap-0.0.25 → codmap-0.0.27}/codemap/model.py +0 -0
  37. {codmap-0.0.25 → codmap-0.0.27}/codemap/provenance.py +0 -0
  38. {codmap-0.0.25 → codmap-0.0.27}/codemap/query.py +0 -0
  39. {codmap-0.0.25 → codmap-0.0.27}/codemap/scope.py +0 -0
  40. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/__init__.py +0 -0
  41. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/_scip_pb2.py +0 -0
  42. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/api_surface.py +0 -0
  43. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/apidiff.py +0 -0
  44. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/architecture.py +0 -0
  45. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/audit.py +0 -0
  46. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/ctags.py +0 -0
  47. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/currency.py +0 -0
  48. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/impact.py +0 -0
  49. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/limits.py +0 -0
  50. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/livingdocs.py +0 -0
  51. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/mcp_server.py +0 -0
  52. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/mermaid.py +0 -0
  53. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/pack.py +0 -0
  54. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/rag.py +0 -0
  55. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/review.py +0 -0
  56. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/scip.py +0 -0
  57. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/semantic.py +0 -0
  58. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/server.py +0 -0
  59. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/session.py +0 -0
  60. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/subsystems.py +0 -0
  61. {codmap-0.0.25 → codmap-0.0.27}/codemap/serve/vault.py +0 -0
  62. {codmap-0.0.25 → codmap-0.0.27}/codemap/store.py +0 -0
  63. {codmap-0.0.25 → codmap-0.0.27}/codemap/tomlio.py +0 -0
  64. {codmap-0.0.25 → codmap-0.0.27}/codemap/watch.py +0 -0
  65. {codmap-0.0.25 → codmap-0.0.27}/codmap.egg-info/dependency_links.txt +0 -0
  66. {codmap-0.0.25 → codmap-0.0.27}/codmap.egg-info/entry_points.txt +0 -0
  67. {codmap-0.0.25 → codmap-0.0.27}/codmap.egg-info/requires.txt +0 -0
  68. {codmap-0.0.25 → codmap-0.0.27}/codmap.egg-info/top_level.txt +0 -0
  69. {codmap-0.0.25 → codmap-0.0.27}/setup.cfg +0 -0
  70. {codmap-0.0.25 → codmap-0.0.27}/tests/test_epistemic.py +0 -0
  71. {codmap-0.0.25 → codmap-0.0.27}/tests/test_gitnexus_router.py +0 -0
  72. {codmap-0.0.25 → codmap-0.0.27}/tests/test_impact_depth.py +0 -0
  73. {codmap-0.0.25 → codmap-0.0.27}/tests/test_integrations.py +0 -0
  74. {codmap-0.0.25 → codmap-0.0.27}/tests/test_issue3_serve_freshness.py +0 -0
  75. {codmap-0.0.25 → codmap-0.0.27}/tests/test_livingdocs.py +0 -0
  76. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m0_api_surface.py +0 -0
  77. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m11_argcontract.py +0 -0
  78. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m12_dataflow.py +0 -0
  79. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m15_review.py +0 -0
  80. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m16_architecture.py +0 -0
  81. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m17_mcp.py +0 -0
  82. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m18_freshness.py +0 -0
  83. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m1_5_semantics.py +0 -0
  84. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m1_query.py +0 -0
  85. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m2_views.py +0 -0
  86. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m32_watch.py +0 -0
  87. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m3_serve.py +0 -0
  88. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m4_behavior.py +0 -0
  89. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m5_deep.py +0 -0
  90. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m6_repo_scope.py +0 -0
  91. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m7_dispatch.py +0 -0
  92. {codmap-0.0.25 → codmap-0.0.27}/tests/test_m9_family.py +0 -0
  93. {codmap-0.0.25 → codmap-0.0.27}/tests/test_pack_honesty.py +0 -0
  94. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  95. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c13_grep_vs_graph.py +0 -0
  96. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c16_semantic.py +0 -0
  97. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c20_attribute_edges.py +0 -0
  98. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c21_flat_layout.py +0 -0
  99. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c22_source_visible_refs.py +0 -0
  100. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c23_hard_python.py +0 -0
  101. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c24_test_mapping.py +0 -0
  102. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c25_provenance.py +0 -0
  103. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c26_deep_union.py +0 -0
  104. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c27_config_honesty.py +0 -0
  105. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c28_limit_envelope.py +0 -0
  106. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c29_lazy_imports.py +0 -0
  107. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c2_ctags.py +0 -0
  108. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c30_local_import_calls.py +0 -0
  109. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c30f1_reexport_calls.py +0 -0
  110. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c30f2_check_scope.py +0 -0
  111. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c31_path_origin.py +0 -0
  112. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c32_report_json.py +0 -0
  113. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c33_query_signature.py +0 -0
  114. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c34_signature_kinds.py +0 -0
  115. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c35_contract_location.py +0 -0
  116. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c36_target_resolution.py +0 -0
  117. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c37_rule_mutation.py +0 -0
  118. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c38_tool_drift.py +0 -0
  119. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c39_edge_resolution.py +0 -0
  120. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c3_arch_contract.py +0 -0
  121. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c3_dogfood.py +0 -0
  122. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c40_flow_reach.py +0 -0
  123. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c41_scope_membership.py +0 -0
  124. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c42_deep_tier_stability.py +0 -0
  125. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c43_incremental_splice.py +0 -0
  126. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c44_absent_answers.py +0 -0
  127. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c45_repeat_union.py +0 -0
  128. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c46_shadowed_definitions.py +0 -0
  129. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c47_incremental_resample.py +0 -0
  130. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c48_type_checking_imports.py +0 -0
  131. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c4_complexity.py +0 -0
  132. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c50_flow_entry_points.py +0 -0
  133. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c53_narrowing_declared.py +0 -0
  134. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c54_deterministic_rendering.py +0 -0
  135. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c55_override_grade.py +0 -0
  136. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c56_stub_files.py +0 -0
  137. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c57_facade_package.py +0 -0
  138. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c58_cycle_tangles.py +0 -0
  139. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c59_completeness_drop.py +0 -0
  140. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c5_apidiff.py +0 -0
  141. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c60_nested_definitions.py +0 -0
  142. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c62_phantom_rules.py +0 -0
  143. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c63_answer_format.py +0 -0
  144. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c64_dependency_probe.py +0 -0
  145. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c66_pack_direction.py +0 -0
  146. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c68_dotted_imports.py +0 -0
  147. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c69_contract_validation.py +0 -0
  148. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c6_pack.py +0 -0
  149. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c70_check_graph_currency.py +0 -0
  150. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c71_attribute_chain_calls.py +0 -0
  151. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c72_flat_inference_in_packages.py +0 -0
  152. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c73_cli_version.py +0 -0
  153. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c74_call_site_conservation.py +0 -0
  154. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c75_reexported_class_calls.py +0 -0
  155. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c7_edge_vocab.py +0 -0
  156. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c8_deadcode.py +0 -0
  157. {codmap-0.0.25 → codmap-0.0.27}/tests/test_r1c9_incremental.py +0 -0
  158. {codmap-0.0.25 → codmap-0.0.27}/tests/test_scip_export.py +0 -0
  159. {codmap-0.0.25 → codmap-0.0.27}/tests/test_scope.py +0 -0
  160. {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.25
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
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
46
46
 
47
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1130 tests with no failures on
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
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
10
10
 
11
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1130 tests with no failures on
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 and not _is_str_list(section["layers"]):
135
- problems.append("`layers` must be a list of strings")
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
- for frm, to in contract.forbidden:
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
- rules.append(_rule_count("forbidden", appl))
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.25
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
  [![CI](https://github.com/kogriv/codemap/actions/workflows/ci.yml/badge.svg)](https://github.com/kogriv/codemap/actions/workflows/ci.yml)
46
46
 
47
- **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1130 tests with no failures on
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.25"
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
- skip = {"error", "path"}
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