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