codmap 0.0.22__tar.gz → 0.0.23__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 (154) hide show
  1. {codmap-0.0.22/codmap.egg-info → codmap-0.0.23}/PKG-INFO +18 -12
  2. {codmap-0.0.22 → codmap-0.0.23}/README.md +17 -11
  3. {codmap-0.0.22 → codmap-0.0.23}/codemap/arch.py +76 -1
  4. {codmap-0.0.22 → codmap-0.0.23}/codemap/cli.py +20 -4
  5. {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/attrflow.py +3 -2
  6. {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/behavior.py +45 -13
  7. {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/griffe_extractor.py +58 -12
  8. {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/gsource.py +23 -0
  9. {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/roots.py +2 -4
  10. {codmap-0.0.22 → codmap-0.0.23}/codemap/model.py +7 -1
  11. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/check.py +42 -3
  12. codmap-0.0.23/codemap/serve/currency.py +67 -0
  13. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/session.py +8 -1
  14. {codmap-0.0.22 → codmap-0.0.23/codmap.egg-info}/PKG-INFO +18 -12
  15. {codmap-0.0.22 → codmap-0.0.23}/codmap.egg-info/SOURCES.txt +6 -0
  16. {codmap-0.0.22 → codmap-0.0.23}/pyproject.toml +1 -1
  17. {codmap-0.0.22 → codmap-0.0.23}/tests/test_pack_honesty.py +3 -2
  18. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c3_arch_contract.py +17 -2
  19. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c63_answer_format.py +12 -1
  20. codmap-0.0.23/tests/test_r1c68_dotted_imports.py +123 -0
  21. codmap-0.0.23/tests/test_r1c69_contract_validation.py +106 -0
  22. codmap-0.0.23/tests/test_r1c70_check_graph_currency.py +130 -0
  23. codmap-0.0.23/tests/test_r1c71_attribute_chain_calls.py +82 -0
  24. codmap-0.0.23/tests/test_r1c72_flat_inference_in_packages.py +99 -0
  25. {codmap-0.0.22 → codmap-0.0.23}/LICENSE +0 -0
  26. {codmap-0.0.22 → codmap-0.0.23}/codemap/__init__.py +0 -0
  27. {codmap-0.0.22 → codmap-0.0.23}/codemap/apidiff.py +0 -0
  28. {codmap-0.0.22 → codmap-0.0.23}/codemap/diagnostics.py +0 -0
  29. {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/__init__.py +0 -0
  30. {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/dataflow.py +0 -0
  31. {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/dispatch.py +0 -0
  32. {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/probe.py +0 -0
  33. {codmap-0.0.22 → codmap-0.0.23}/codemap/extract/union.py +0 -0
  34. {codmap-0.0.22 → codmap-0.0.23}/codemap/freshness.py +0 -0
  35. {codmap-0.0.22 → codmap-0.0.23}/codemap/incremental.py +0 -0
  36. {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/__init__.py +0 -0
  37. {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/base.py +0 -0
  38. {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/cocoindex.py +0 -0
  39. {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/gate.py +0 -0
  40. {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/gitnexus.py +0 -0
  41. {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/registry.py +0 -0
  42. {codmap-0.0.22 → codmap-0.0.23}/codemap/integrations/transport.py +0 -0
  43. {codmap-0.0.22 → codmap-0.0.23}/codemap/provenance.py +0 -0
  44. {codmap-0.0.22 → codmap-0.0.23}/codemap/query.py +0 -0
  45. {codmap-0.0.22 → codmap-0.0.23}/codemap/scope.py +0 -0
  46. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/__init__.py +0 -0
  47. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/_scip_pb2.py +0 -0
  48. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/api_surface.py +0 -0
  49. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/apidiff.py +0 -0
  50. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/architecture.py +0 -0
  51. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/audit.py +0 -0
  52. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/ctags.py +0 -0
  53. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/impact.py +0 -0
  54. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/limits.py +0 -0
  55. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/livingdocs.py +0 -0
  56. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/mcp_server.py +0 -0
  57. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/mermaid.py +0 -0
  58. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/pack.py +0 -0
  59. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/rag.py +0 -0
  60. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/review.py +0 -0
  61. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/scip.py +0 -0
  62. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/semantic.py +0 -0
  63. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/server.py +0 -0
  64. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/subsystems.py +0 -0
  65. {codmap-0.0.22 → codmap-0.0.23}/codemap/serve/vault.py +0 -0
  66. {codmap-0.0.22 → codmap-0.0.23}/codemap/store.py +0 -0
  67. {codmap-0.0.22 → codmap-0.0.23}/codemap/tomlio.py +0 -0
  68. {codmap-0.0.22 → codmap-0.0.23}/codemap/watch.py +0 -0
  69. {codmap-0.0.22 → codmap-0.0.23}/codmap.egg-info/dependency_links.txt +0 -0
  70. {codmap-0.0.22 → codmap-0.0.23}/codmap.egg-info/entry_points.txt +0 -0
  71. {codmap-0.0.22 → codmap-0.0.23}/codmap.egg-info/requires.txt +0 -0
  72. {codmap-0.0.22 → codmap-0.0.23}/codmap.egg-info/top_level.txt +0 -0
  73. {codmap-0.0.22 → codmap-0.0.23}/setup.cfg +0 -0
  74. {codmap-0.0.22 → codmap-0.0.23}/tests/test_epistemic.py +0 -0
  75. {codmap-0.0.22 → codmap-0.0.23}/tests/test_gitnexus_router.py +0 -0
  76. {codmap-0.0.22 → codmap-0.0.23}/tests/test_impact_depth.py +0 -0
  77. {codmap-0.0.22 → codmap-0.0.23}/tests/test_integrations.py +0 -0
  78. {codmap-0.0.22 → codmap-0.0.23}/tests/test_issue3_serve_freshness.py +0 -0
  79. {codmap-0.0.22 → codmap-0.0.23}/tests/test_livingdocs.py +0 -0
  80. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m0_api_surface.py +0 -0
  81. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m11_argcontract.py +0 -0
  82. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m12_dataflow.py +0 -0
  83. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m15_review.py +0 -0
  84. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m16_architecture.py +0 -0
  85. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m17_mcp.py +0 -0
  86. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m18_freshness.py +0 -0
  87. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m1_5_semantics.py +0 -0
  88. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m1_query.py +0 -0
  89. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m2_views.py +0 -0
  90. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m32_watch.py +0 -0
  91. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m3_serve.py +0 -0
  92. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m4_behavior.py +0 -0
  93. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m5_deep.py +0 -0
  94. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m6_repo_scope.py +0 -0
  95. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m7_dispatch.py +0 -0
  96. {codmap-0.0.22 → codmap-0.0.23}/tests/test_m9_family.py +0 -0
  97. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  98. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c13_grep_vs_graph.py +0 -0
  99. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c16_semantic.py +0 -0
  100. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c20_attribute_edges.py +0 -0
  101. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c21_flat_layout.py +0 -0
  102. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c22_source_visible_refs.py +0 -0
  103. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c23_hard_python.py +0 -0
  104. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c24_test_mapping.py +0 -0
  105. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c25_provenance.py +0 -0
  106. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c26_deep_union.py +0 -0
  107. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c27_config_honesty.py +0 -0
  108. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c28_limit_envelope.py +0 -0
  109. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c29_lazy_imports.py +0 -0
  110. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c2_ctags.py +0 -0
  111. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c30_local_import_calls.py +0 -0
  112. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c30f1_reexport_calls.py +0 -0
  113. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c30f2_check_scope.py +0 -0
  114. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c31_path_origin.py +0 -0
  115. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c32_report_json.py +0 -0
  116. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c33_query_signature.py +0 -0
  117. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c34_signature_kinds.py +0 -0
  118. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c35_contract_location.py +0 -0
  119. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c36_target_resolution.py +0 -0
  120. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c37_rule_mutation.py +0 -0
  121. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c38_tool_drift.py +0 -0
  122. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c39_edge_resolution.py +0 -0
  123. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c3_dogfood.py +0 -0
  124. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c40_flow_reach.py +0 -0
  125. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c41_scope_membership.py +0 -0
  126. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c42_deep_tier_stability.py +0 -0
  127. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c43_incremental_splice.py +0 -0
  128. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c44_absent_answers.py +0 -0
  129. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c45_repeat_union.py +0 -0
  130. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c46_shadowed_definitions.py +0 -0
  131. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c47_incremental_resample.py +0 -0
  132. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c48_type_checking_imports.py +0 -0
  133. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c49_type_only_cycles.py +0 -0
  134. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c4_complexity.py +0 -0
  135. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c50_flow_entry_points.py +0 -0
  136. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c53_narrowing_declared.py +0 -0
  137. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c54_deterministic_rendering.py +0 -0
  138. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c55_override_grade.py +0 -0
  139. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c56_stub_files.py +0 -0
  140. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c57_facade_package.py +0 -0
  141. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c58_cycle_tangles.py +0 -0
  142. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c59_completeness_drop.py +0 -0
  143. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c5_apidiff.py +0 -0
  144. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c60_nested_definitions.py +0 -0
  145. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c62_phantom_rules.py +0 -0
  146. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c64_dependency_probe.py +0 -0
  147. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c66_pack_direction.py +0 -0
  148. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c6_pack.py +0 -0
  149. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c7_edge_vocab.py +0 -0
  150. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c8_deadcode.py +0 -0
  151. {codmap-0.0.22 → codmap-0.0.23}/tests/test_r1c9_incremental.py +0 -0
  152. {codmap-0.0.22 → codmap-0.0.23}/tests/test_scip_export.py +0 -0
  153. {codmap-0.0.22 → codmap-0.0.23}/tests/test_scope.py +0 -0
  154. {codmap-0.0.22 → codmap-0.0.23}/tests/test_subsystems.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codmap
3
- Version: 0.0.22
3
+ Version: 0.0.23
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, **1054 tests with no failures on
47
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1115 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 &
@@ -107,18 +107,24 @@ answer.
107
107
 
108
108
  That sentence used to end at "None answers these", and a second pass over the 68k-star tool made it
109
109
  narrower: its *importable library* does carry a cycle finder, reachable from neither its CLI nor its MCP
110
- tools and called nowhere in its own source. Measured on the same tree it reports **136 cycles**; codemap
111
- reports **1**. Scoring both against the truth set — every intra-package import, function-local ones
112
- included — is less flattering than that sounds:
110
+ tools and called nowhere in its own source. Measured on the same tree (its 1.6.0) it reported **136
111
+ cycles**; codemap reported **1**. Scoring both against the truth set — every intra-package import,
112
+ function-local ones included — is less flattering than that sounds:
113
113
 
114
- | | reported | of the 41 real ones | precision | recall |
114
+ | | reported | of the 45 real ones | precision | recall |
115
115
  |---|---:|---:|---:|---:|
116
- | codemap, that morning | 1 | 1 | 100% | **2.4%** |
117
- | the peer's library API | 136 | 13 | 10% | 32% |
118
- | codemap, after R1-C29 | 41 | 41 | 100% | 100% |
119
-
120
- Theirs over-reports because it walks name-resolved call edges — a `dict.get` becomes a call into an
121
- unrelated class. **Ours under-reported because its import map was module-level only**, and it phrased
116
+ | codemap, before R1-C29 | 1 | 1 | 100% | **2.2%** |
117
+ | the peer's library API, 1.6.0 | 136 | 13 | 10% | 29% |
118
+ | the peer's library API, **1.6.2** (re-measured 2026-10-15) | 25 | 16 | **64%** | 36% |
119
+ | codemap, after R1-C29 (0.0.4) | 45 | 45 | 100% | 100% |
120
+
121
+ *Corrected 2026-10-15.* This table first said **41** real cycles, recall 32% and 2.4%. The 41 were counted
122
+ on a later commit of the same repository than the one both tools were measured on; on the measured tree
123
+ there are 45 ([re-measure](research/codegraph_remeasure.md) §7, C3). The peer fixed the call edges we
124
+ reported and its 1.6.2 row is the current one.
125
+
126
+ Theirs over-reported because it walks name-resolved call edges — a `dict.get` became a call into an
127
+ unrelated class (fixed upstream in 1.6.1). **Ours under-reported because its import map was module-level only**, and it phrased
122
128
  that as *"import graph is acyclic"* — a property claim over a partial map, which is the worse of the two
123
129
  errors even though it was the smaller one. Both halves of that came from
124
130
  [#11](https://github.com/kogriv/codemap/issues/11), filed off a different target and fixed the same day
@@ -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, **1054 tests with no failures on
11
+ **Status:** 🟢 M0–M20 implemented + research track (R1/R2) — schema 0.13, **1115 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 &
@@ -71,18 +71,24 @@ answer.
71
71
 
72
72
  That sentence used to end at "None answers these", and a second pass over the 68k-star tool made it
73
73
  narrower: its *importable library* does carry a cycle finder, reachable from neither its CLI nor its MCP
74
- tools and called nowhere in its own source. Measured on the same tree it reports **136 cycles**; codemap
75
- reports **1**. Scoring both against the truth set — every intra-package import, function-local ones
76
- included — is less flattering than that sounds:
74
+ tools and called nowhere in its own source. Measured on the same tree (its 1.6.0) it reported **136
75
+ cycles**; codemap reported **1**. Scoring both against the truth set — every intra-package import,
76
+ function-local ones included — is less flattering than that sounds:
77
77
 
78
- | | reported | of the 41 real ones | precision | recall |
78
+ | | reported | of the 45 real ones | precision | recall |
79
79
  |---|---:|---:|---:|---:|
80
- | codemap, that morning | 1 | 1 | 100% | **2.4%** |
81
- | the peer's library API | 136 | 13 | 10% | 32% |
82
- | codemap, after R1-C29 | 41 | 41 | 100% | 100% |
83
-
84
- Theirs over-reports because it walks name-resolved call edges — a `dict.get` becomes a call into an
85
- unrelated class. **Ours under-reported because its import map was module-level only**, and it phrased
80
+ | codemap, before R1-C29 | 1 | 1 | 100% | **2.2%** |
81
+ | the peer's library API, 1.6.0 | 136 | 13 | 10% | 29% |
82
+ | the peer's library API, **1.6.2** (re-measured 2026-10-15) | 25 | 16 | **64%** | 36% |
83
+ | codemap, after R1-C29 (0.0.4) | 45 | 45 | 100% | 100% |
84
+
85
+ *Corrected 2026-10-15.* This table first said **41** real cycles, recall 32% and 2.4%. The 41 were counted
86
+ on a later commit of the same repository than the one both tools were measured on; on the measured tree
87
+ there are 45 ([re-measure](research/codegraph_remeasure.md) §7, C3). The peer fixed the call edges we
88
+ reported and its 1.6.2 row is the current one.
89
+
90
+ Theirs over-reported because it walks name-resolved call edges — a `dict.get` became a call into an
91
+ unrelated class (fixed upstream in 1.6.1). **Ours under-reported because its import map was module-level only**, and it phrased
86
92
  that as *"import graph is acyclic"* — a property claim over a partial map, which is the worse of the two
87
93
  errors even though it was the smaller one. Both halves of that came from
88
94
  [#11](https://github.com/kogriv/codemap/issues/11), filed off a different target and fixed the same day
@@ -104,8 +104,83 @@ def load_contract(root: str | Path = ".") -> ArchitectureContract:
104
104
  return replace(parse_contract(data.get("architecture", {})), path=str(path))
105
105
 
106
106
 
107
+ #: R1-C69: the closed schema of ``[architecture]``. A key outside it is an error, not a skip.
108
+ _FLAGS = ("no_cycles", "no_lazy_cycles", "no_type_only_cycles", "exhaustive", "no_phantom_rules")
109
+ _KNOWN_KEYS = ("layers", "independent", "forbidden") + _FLAGS
110
+
111
+
112
+ def _is_str_list(v) -> bool:
113
+ return isinstance(v, list) and all(isinstance(x, str) for x in v)
114
+
115
+
116
+ def contract_problems(section) -> list[str]:
117
+ """Every way ``section`` departs from the ``[architecture]`` schema, in reading order (R1-C69).
118
+
119
+ Empty when the section is valid. Design ``docs/design/contract_validation.md`` D1: unknown
120
+ keys (with the nearest known one suggested), values of the wrong type — `"false"` is a
121
+ string and used to switch a rule *on* — and malformed items, each named by key and index.
122
+ Names that do not exist in the graph are not a schema question (D3, R1-C62).
123
+ """
124
+ import difflib
125
+
126
+ if not isinstance(section, dict):
127
+ return [f"`[architecture]` must be a table, got {type(section).__name__}"]
128
+ problems: list[str] = []
129
+ for key in section:
130
+ if key not in _KNOWN_KEYS:
131
+ near = difflib.get_close_matches(key, _KNOWN_KEYS, n=1, cutoff=0.6)
132
+ hint = f" (did you mean `{near[0]}`?)" if near else ""
133
+ 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")
136
+ if "independent" in section:
137
+ groups = section["independent"]
138
+ if not isinstance(groups, list):
139
+ problems.append("`independent` must be a list of lists of strings")
140
+ else:
141
+ for i, grp in enumerate(groups):
142
+ if not _is_str_list(grp) or len(grp) < 2:
143
+ problems.append(f"`independent[{i}]` must be a list of at least 2 strings")
144
+ if "forbidden" in section:
145
+ items = section["forbidden"]
146
+ if not isinstance(items, list):
147
+ problems.append("`forbidden` must be a list of `{ from = \"…\", to = \"…\" }` tables")
148
+ else:
149
+ for i, item in enumerate(items):
150
+ if not isinstance(item, dict):
151
+ problems.append(f"`forbidden[{i}]` must be a table `{{ from = \"…\", to = \"…\" }}`")
152
+ continue
153
+ extra = sorted(set(item) - {"from", "to"})
154
+ missing = [k for k in ("from", "to") if k not in item]
155
+ wrong = [k for k in ("from", "to") if k in item and not isinstance(item[k], str)]
156
+ if extra:
157
+ problems.append(f"`forbidden[{i}]` has unknown key(s) "
158
+ + ", ".join(f"`{k}`" for k in extra))
159
+ if missing:
160
+ problems.append(f"`forbidden[{i}]` is missing " + ", ".join(f"`{k}`" for k in missing))
161
+ if wrong:
162
+ problems.append(f"`forbidden[{i}]`: " + ", ".join(f"`{k}`" for k in wrong)
163
+ + " must be a string")
164
+ for flag in _FLAGS:
165
+ if flag in section and not isinstance(section[flag], bool):
166
+ problems.append(f"`{flag}` must be true or false, got {type(section[flag]).__name__} "
167
+ f"`{section[flag]!r}`")
168
+ return problems
169
+
170
+
107
171
  def parse_contract(section: dict) -> ArchitectureContract:
108
- """Build a contract from the parsed ``[architecture]`` table (validated leniently)."""
172
+ """Build a contract from the parsed ``[architecture]`` table.
173
+
174
+ R1-C69: validated against a closed schema first. A section with any problem is **not
175
+ enforced at all** — it comes back as an ``error`` contract, the R1-C27 path, so the CLI
176
+ exits 2 and MCP answers ``ok: false`` naming every problem. Enforcing the valid half used
177
+ to be the silent part: ``forbiden = [...]`` beside ``no_cycles = true`` printed a green
178
+ tick over a rule the gate never ran.
179
+ """
180
+ problems = contract_problems(section)
181
+ if problems:
182
+ return ArchitectureContract(
183
+ error="`[architecture]` is not a valid contract: " + "; ".join(problems))
109
184
  forbidden = []
110
185
  for item in section.get("forbidden", []) or []:
111
186
  if isinstance(item, dict) and "from" in item and "to" in item:
@@ -807,6 +807,12 @@ def _cmd_pack(args) -> int:
807
807
  return 1 if "error" in p else 0
808
808
 
809
809
 
810
+ def _check_exit(violations, currency: dict | None, allow_stale: bool) -> int:
811
+ """2 on a violation, or on a graph behind its source unless ``--allow-stale`` (R1-C70)."""
812
+ behind = currency is not None and currency.get("status") == "behind"
813
+ return 2 if (violations or (behind and not allow_stale)) else 0
814
+
815
+
810
816
  def _cmd_check(args) -> int:
811
817
  """Enforce the [architecture] contract → exit 2 on any violation (R1-C3).
812
818
 
@@ -820,21 +826,28 @@ def _cmd_check(args) -> int:
820
826
  from codemap.serve.check import render_check
821
827
  q = Query(_graph_from(args))
822
828
  contract = load_contract(args.root)
829
+ # R1-C70: a graph read from disk may be behind the tree it is about to be judged as.
830
+ currency = None
831
+ if getattr(args, "graph", None) and not getattr(args, "build", None):
832
+ from codemap.serve.currency import graph_currency
833
+ currency = graph_currency(args.graph, (q.graph.provenance or {}).get("scope_id"))
834
+ allow_stale = getattr(args, "allow_stale", False)
823
835
  # R1-C27: check `error` before anything else. A malformed codemap.toml used to be
824
836
  # indistinguishable from an absent one, so removing a single `]` turned a gate that
825
837
  # reported 14 violations into exit 0. Exit 2 — the same code as a violation, not a new
826
838
  # one: the status answers "may the pipeline proceed?" (no, either way), and a new code
827
839
  # would sort an unreadable contract into the success branch of every `if rc == 2` that
828
840
  # already exists in someone's pipeline.
841
+ kw = {"currency": currency, "allow_stale": allow_stale}
829
842
  if contract.error:
830
- _emit_answer(render_check(q, contract, []))
843
+ _emit_answer(render_check(q, contract, [], **kw))
831
844
  return 2
832
845
  if contract.is_empty() and args.require_contract:
833
- _emit_answer(render_check(q, contract, []))
846
+ _emit_answer(render_check(q, contract, [], **kw))
834
847
  raise SystemExit("error: no [architecture] contract found (--require-contract)")
835
848
  violations = check_contract(q, contract)
836
- _emit_answer(render_check(q, contract, violations))
837
- return 2 if violations else 0
849
+ _emit_answer(render_check(q, contract, violations, **kw))
850
+ return _check_exit(violations, currency, allow_stale)
838
851
 
839
852
 
840
853
  def _cmd_diff(args) -> int:
@@ -1173,6 +1186,9 @@ def build_parser() -> argparse.ArgumentParser:
1173
1186
  help="Dir with codemap.toml holding [architecture] (default: cwd).")
1174
1187
  ck.add_argument("--require-contract", action="store_true",
1175
1188
  help="Fail if no [architecture] contract is present (default: no-op success).")
1189
+ ck.add_argument("--allow-stale", action="store_true",
1190
+ help="With --graph: do not fail when the graph is behind its source tree "
1191
+ "(R1-C70); the 'behind' line is still printed.")
1176
1192
  ck.set_defaults(func=_cmd_check)
1177
1193
 
1178
1194
  return p
@@ -50,7 +50,7 @@ from codemap.extract.behavior import (
50
50
  _node_id,
51
51
  _own_nodes,
52
52
  )
53
- from codemap.extract.gsource import module_file, module_imports
53
+ from codemap.extract.gsource import flat_inference_applies, module_file, module_imports
54
54
  from codemap.model import Edge
55
55
 
56
56
 
@@ -66,7 +66,8 @@ def add_attrflow(graph, griffe_root, target_pkg: str, *, deep: bool = False,
66
66
  (R1-C9), mirroring :func:`add_behavior`.
67
67
  """
68
68
  modules = _index_modules(griffe_root)
69
- known_modules = set(modules) # R1-C21: flat-layout sibling lookup
69
+ # R1-C21 sibling lookup, gated like behavior's (R1-C72).
70
+ known_modules = set(modules) if flat_inference_applies(graph, target_pkg) else frozenset()
70
71
  project = _jedi_project(search_path) if deep else None
71
72
  pkg = target_pkg + "."
72
73
  # (func_id, attr_id, access, resolution) — dedup collapses repeated sites.
@@ -27,8 +27,8 @@ import ast
27
27
  import math
28
28
  from pathlib import Path
29
29
 
30
- from codemap.extract.gsource import (NESTED_IMPORT_HINT, module_file, module_imports,
31
- qualify_target)
30
+ from codemap.extract.gsource import (NESTED_IMPORT_HINT, flat_inference_applies, module_file,
31
+ module_imports, qualify_target)
32
32
  from codemap.model import Edge
33
33
 
34
34
  _BUILTINS = set(vars(__import__("builtins")))
@@ -51,7 +51,9 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
51
51
  modules left out. When ``None`` (default) every module is processed.
52
52
  """
53
53
  modules = _index_modules(griffe_root)
54
- known_modules = set(modules) # R1-C21: flat-layout sibling lookup
54
+ # R1-C21 flat-layout sibling lookup — empty, so it never fires, in a core that imports
55
+ # itself by name (R1-C72): there `import warnings` is the standard library.
56
+ known_modules = set(modules) if flat_inference_applies(graph, target_pkg) else frozenset()
55
57
  project = _jedi_project(search_path) if deep else None
56
58
  # R1-C30-f1: a call to a re-exported name points at a name that is not a definition.
57
59
  # The structural pass already recorded where each re-export really leads.
@@ -588,16 +590,33 @@ def _arg_contract(shapes: list[tuple]) -> dict:
588
590
  return contract
589
591
 
590
592
 
593
+ def _is_internal(target: str, modpath: str) -> bool:
594
+ """R1-C71/D3: the package root is inside the package. ``startswith(pkg + ".")`` alone
595
+ filed ``import pkg; pkg.f()`` — and the bound name of ``import pkg.a.b`` — as external."""
596
+ root = modpath.split(".")[0]
597
+ return target == root or target.startswith(root + ".")
598
+
599
+
600
+ def _attr_chain(node) -> tuple[str, list[str]] | None:
601
+ """``a.b.c`` → ``("a", ["b", "c"])`` when the chain bottoms out in a plain name."""
602
+ parts = []
603
+ while isinstance(node, ast.Attribute):
604
+ parts.append(node.attr)
605
+ node = node.value
606
+ if not isinstance(node, ast.Name):
607
+ return None
608
+ return node.id, parts[::-1]
609
+
610
+
591
611
  def _resolve(call, modpath, class_prefix, imports, modmembers, members, reexports=None,
592
612
  nodes=frozenset()):
593
613
  """Return (target_id, resolution). Only module/self/imported become edges."""
594
- pkg = modpath.split(".")[0] + "."
595
614
  f = call.func
596
615
  if isinstance(f, ast.Name):
597
616
  name = f.id
598
617
  if name in imports:
599
618
  tgt = imports[name]
600
- if tgt.startswith(pkg):
619
+ if _is_internal(tgt, modpath):
601
620
  return _follow_reexport(tgt, reexports or {}, nodes), "imported"
602
621
  return tgt, "external"
603
622
  if name in modmembers:
@@ -613,13 +632,27 @@ def _resolve(call, modpath, class_prefix, imports, modmembers, members, reexport
613
632
  # method call on self — resolve to the class that actually defines
614
633
  # the member (own or a base), never a phantom same-class id.
615
634
  return f"{members[attr]}.{attr}", "self"
616
- if recv.id in imports:
617
- tgt = imports[recv.id]
618
- if tgt.startswith(pkg):
619
- return _follow_reexport(f"{tgt}.{attr}", reexports or {}, nodes), "imported"
620
- return f"{tgt}.{attr}", "external"
621
- if recv.id in modmembers:
635
+ if recv.id in modmembers and recv.id not in imports:
622
636
  return f"{modpath}.{recv.id}.{attr}", "module"
637
+ # R1-C71 (design ``attribute_chain_calls.md``): a callee rooted at an imported name,
638
+ # at any depth. Internal: follow it hop by hop, each hop through a re-export when the
639
+ # name is no node (D1) — the depth-1 case is the one-hop instance and answers as it
640
+ # always did; the soundness guard downstream still refuses a target that is no node,
641
+ # so `b.registry.get()` stays unresolved. External: external at any depth (D2) —
642
+ # `np.random.rand()` is no more unknown than `np.zeros()`.
643
+ chain = _attr_chain(f)
644
+ if chain is not None and chain[0] in imports:
645
+ head, parts = chain
646
+ tgt = imports[head]
647
+ if not _is_internal(tgt, modpath):
648
+ return f"{tgt}.{'.'.join(parts)}", "external"
649
+ cur = tgt
650
+ for part in parts:
651
+ cur = _follow_reexport(f"{cur}.{part}", reexports or {}, nodes)
652
+ if len(parts) == 1 or cur in nodes:
653
+ return cur, "imported"
654
+ # a deeper chain that lands on no definition (`b.registry.get("k")`) keeps the
655
+ # answer it had before R1-C71: string-keyed dispatch, else unresolved
623
656
  # X.method('literal', ...) — dynamic string-keyed dispatch (registry/dict)
624
657
  if call.args and isinstance(call.args[0], ast.Constant) \
625
658
  and isinstance(call.args[0].value, str) and attr in ("create", "get", "register"):
@@ -868,10 +901,9 @@ def _module_level_nodes(tree):
868
901
  def _resolve_name(name, modpath, imports, modmembers, reexports=None, nodes=frozenset()):
869
902
  """(target_id, resolution) for a bare name used as a value — the `_resolve` rules for
870
903
  ``ast.Name`` minus the call. Only module/imported become edges."""
871
- pkg = modpath.split(".")[0] + "."
872
904
  if name in imports:
873
905
  tgt = imports[name]
874
- if tgt.startswith(pkg):
906
+ if _is_internal(tgt, modpath):
875
907
  return _follow_reexport(tgt, reexports or {}, nodes), "imported"
876
908
  return tgt, "external"
877
909
  if name in modmembers:
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
  import ast
21
21
  import multiprocessing
22
22
  import os
23
+ import sys
23
24
  from dataclasses import dataclass, field
24
25
  from pathlib import Path
25
26
 
@@ -29,7 +30,8 @@ from codemap.extract.attrflow import add_attrflow
29
30
  from codemap.extract.behavior import add_behavior
30
31
  from codemap.extract.dataflow import add_dataflow
31
32
  from codemap.extract.dispatch import add_dispatch, add_family_links
32
- from codemap.extract.gsource import NESTED_IMPORT_HINT, module_file, module_identity
33
+ from codemap.extract.gsource import (DOTTED_IMPORT_HINT, NESTED_IMPORT_HINT, module_file,
34
+ module_identity)
33
35
  from codemap.extract.union import merge_samples
34
36
  from codemap.provenance import build_provenance
35
37
  from codemap.model import Edge, Graph, Node
@@ -322,9 +324,10 @@ def _input_report(graph, pkg_dir: Path, root: Path, walk) -> dict:
322
324
  return report
323
325
 
324
326
 
325
- def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
326
- """``([(target, scope)], eager targets)`` — the imports griffe's module-level map does
327
- not carry, and the targets it carries correctly.
327
+ def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str], set[str]]:
328
+ """``([(target, scope)], eager targets, griffe heads to drop)`` — the imports griffe's
329
+ module-level map does not carry, the targets it carries correctly, and the entries it
330
+ carries wrongly.
328
331
 
329
332
  Two families, one traversal, one parse — griffe records neither, and both used to be
330
333
  invisible to the import graph:
@@ -345,6 +348,13 @@ def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
345
348
  of a condition that is always false at run time — so the second value returned is
346
349
  the set of targets imported at plain module level: the caller demotes griffe's entry
347
350
  for a target that appears only under ``TYPE_CHECKING``.
351
+ - **dotted `import a.b.c` at module level** (R1-C68, design ``dotted_imports.md``).
352
+ griffe records the *bound name*, ``{"a": "a"}``, so the edge went to the root package
353
+ and a contract banning ``core → analysis`` passed over ``import pkg.analysis.b``. The
354
+ full name is recorded here, scope ``"module"``; the third value returned is the heads
355
+ griffe's entry should be dropped for — bound by dotted imports only, never by a plain
356
+ ``import a`` (D2). Under ``TYPE_CHECKING`` the full name already came out of this
357
+ pass; the head griffe filed beside it was left behind as an *eager* edge to ``a``.
348
358
 
349
359
  Cost discipline, and it is not theoretical: the first version of this walked every
350
360
  function's subtree separately, which is quadratic in nesting and cost the dogfood
@@ -356,13 +366,13 @@ def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
356
366
  try:
357
367
  src = module.source
358
368
  except Exception: # no source (namespace dir, synthetic)
359
- return [], set()
360
- if not NESTED_IMPORT_HINT.search(src):
361
- return [], set()
369
+ return [], set(), set()
370
+ if not (NESTED_IMPORT_HINT.search(src) or DOTTED_IMPORT_HINT.search(src)):
371
+ return [], set(), set()
362
372
  try:
363
373
  tree = ast.parse(src)
364
374
  except (SyntaxError, ValueError):
365
- return [], set() # unreadable: D2's report owns this file
375
+ return [], set(), set() # unreadable: D2's report owns this file
366
376
  modpath = module.canonical_path
367
377
  f = module_file(module)
368
378
  is_pkg = f is not None and f.name == "__init__.py"
@@ -376,6 +386,19 @@ def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
376
386
 
377
387
  out: list[tuple[str, str]] = []
378
388
  eager: set[str] = set() # targets imported at plain module level (griffe has them)
389
+ # R1-C68/D2: heads griffe records for module-level `import a.b.c` (it files the bound
390
+ # name), against heads a plain `import a` really binds. Only the difference is dropped.
391
+ dotted_heads: set[str] = set()
392
+ plain_heads: set[str] = set()
393
+
394
+ def note_heads(names) -> None:
395
+ for a in names:
396
+ if a.asname is not None:
397
+ continue # `import a.b as x`: griffe records a.b, correctly
398
+ if "." in a.name:
399
+ dotted_heads.add(a.name.split(".", 1)[0])
400
+ else:
401
+ plain_heads.add(a.name)
379
402
 
380
403
  def visit(node, scope: str | None) -> None:
381
404
  """``scope`` is None at module level (griffe has those), else
@@ -398,10 +421,16 @@ def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
398
421
  for stmt in child.orelse:
399
422
  visit_stmt(stmt, else_scope if else_scope else scope)
400
423
  elif isinstance(child, ast.Import):
424
+ if scope in (None, "type_checking"):
425
+ # griffe's map holds these (TYPE_CHECKING included, as module-level)
426
+ note_heads(child.names)
401
427
  if scope is not None:
402
428
  out.extend((a.name, scope) for a in child.names)
403
429
  else:
404
430
  eager.update(a.name for a in child.names)
431
+ # R1-C68: griffe has `a` for `import a.b.c`; the dependency is a.b.c
432
+ out.extend((a.name, "module") for a in child.names
433
+ if a.asname is None and "." in a.name)
405
434
  elif isinstance(child, ast.ImportFrom):
406
435
  target = resolve(child)
407
436
  if not target:
@@ -428,7 +457,7 @@ def _source_import_targets(module) -> tuple[list[tuple[str, str]], set[str]]:
428
457
  visit(holder, scope)
429
458
 
430
459
  visit(tree, None)
431
- return out, eager
460
+ return out, eager, dotted_heads - plain_heads
432
461
 
433
462
 
434
463
  def _type_checking_branches(node: ast.If) -> tuple[str | None, str | None] | None:
@@ -457,7 +486,7 @@ def _collect(graph, obj, root, target_pkg, walk) -> None:
457
486
  # R1-C23/D3 (star imports) + R1-C29 (function-local and class-body imports) +
458
487
  # R1-C48 (`if TYPE_CHECKING:`): everything griffe's module-level map does not
459
488
  # carry, or carries under the wrong scope, in one parse.
460
- nested, eager = _source_import_targets(obj)
489
+ nested, eager, dotted_only = _source_import_targets(obj)
461
490
  type_checking = {tgt for tgt, scope in nested if scope == "type_checking"}
462
491
  # R1-C56/D1: a `.pyi` is a declaration — Python never executes it, so *none* of
463
492
  # its imports run, whatever they look like. Recorded as a fourth scope rather
@@ -465,6 +494,10 @@ def _collect(graph, obj, root, target_pkg, walk) -> None:
465
494
  # cycle classes are named by mechanism.
466
495
  stub = _is_stub(obj)
467
496
  for name, tgt in (obj.imports or {}).items():
497
+ if name == tgt and tgt in dotted_only:
498
+ # R1-C68/D2: the bound name of `import a.b.c`, not a dependency on `a`;
499
+ # the full name came from the AST pass above.
500
+ continue
468
501
  # griffe files an import under `if TYPE_CHECKING:` as module-level; it never
469
502
  # runs. Demote it unless the same target is also imported eagerly (D2).
470
503
  scope = "type_checking" if tgt in type_checking and tgt not in eager else "module"
@@ -558,6 +591,19 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
558
591
  )
559
592
 
560
593
  known_modules = set(module_ids)
594
+ # R1-C72 (design ``flat_inference_in_packages.md`` D1, revised): the flat inference is for
595
+ # a directory that is itself on sys.path. The evidence is a bare import that can only be a
596
+ # sibling — it names a module beside the importer (not the importer itself) and is no
597
+ # standard-library name. With it, siblings shadow the stdlib too and every bare import is
598
+ # rewritten; without it, `import warnings` beside `_pytest/warnings.py` is the standard
599
+ # library and nothing is. A namespace root needs no evidence. Decided from the collected
600
+ # imports before anything is emitted, so edge order is kept.
601
+ root_node = graph.nodes.get(target_pkg)
602
+ flat_ok = (root_node is not None and root_node.file is None) or any(
603
+ (sib := _flat_sibling(src, t, known_modules)) is not None and sib != src
604
+ and t.split(".", 1)[0] not in sys.stdlib_module_names
605
+ for src, t, _scope in imports
606
+ if not (t == target_pkg or t.startswith(target_pkg + ".")))
561
607
  for parent_module, name, target_path, is_public in aliases:
562
608
  extras = {"as": name, "public": is_public}
563
609
  if not (target_path == target_pkg or target_path.startswith(target_pkg + ".")):
@@ -567,7 +613,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
567
613
  # here — so every re-export in such a tree was filed as external and produced
568
614
  # no edge at all. Same narrow gate as pass B: only when the head names a
569
615
  # module sitting beside the re-exporter, and labelled as the inference it is.
570
- if _flat_sibling(parent_module, target_path, known_modules) is None:
616
+ if not flat_ok or _flat_sibling(parent_module, target_path, known_modules) is None:
571
617
  # R1-C57: `import numpy as np` is not this package's API and stays out.
572
618
  # A **public** alias to an outside definition is the opposite case: it is
573
619
  # exactly what the package exposes, and dropping it made the facade layout
@@ -614,7 +660,7 @@ def _resolve_edges(graph, target_pkg, aliases, imports) -> None:
614
660
  # sys.path. griffe records the source-literal target, so pass A cannot tell it from
615
661
  # `pandas.DataFrame`. Retry it against the importer's own package — and label it, since
616
662
  # this is an inference about sys.path, not something the source states.
617
- for src_module, target_path, scope in unresolved:
663
+ for src_module, target_path, scope in (unresolved if flat_ok else ()): # R1-C72
618
664
  tgt_module = _flat_sibling(src_module, target_path, known_modules)
619
665
  if tgt_module is None or tgt_module == src_module:
620
666
  continue
@@ -29,6 +29,13 @@ from pathlib import Path
29
29
  #: on one rule instead of two that can drift apart.
30
30
  NESTED_IMPORT_HINT = re.compile(r"^[ \t]+(?:from|import)\s|import\s+\*", re.MULTILINE)
31
31
 
32
+ #: R1-C68 (design ``dotted_imports.md`` D3): a top-level ``import`` statement naming a dotted
33
+ #: module. griffe files ``import a.b.c`` under its bound name ``a``, so the import graph has to
34
+ #: read the source for it — but only the import graph: call resolution keeps the gate above,
35
+ #: since ``a`` *is* the name bound. A false positive (``import a.b as x``) costs one parse.
36
+ #: Not seen: a dotted name after a backslash continuation line.
37
+ DOTTED_IMPORT_HINT = re.compile(r"^import\s[^\n#]*\w\.\w", re.MULTILINE)
38
+
32
39
 
33
40
  def module_file(obj) -> Path | None:
34
41
  """The single source file behind a griffe object, or ``None`` if it has none."""
@@ -98,3 +105,19 @@ def qualify_target(target: str, modpath: str, known_modules) -> str:
98
105
  if not target.startswith(pkg) and f"{parent}.{target.split('.', 1)[0]}" in known_modules:
99
106
  return f"{parent}.{target}"
100
107
  return target
108
+
109
+
110
+ def flat_inference_applies(graph, core_pkg: str) -> bool:
111
+ """Does the flat-layout inference (R1-C21) apply to this core? One predicate, three users.
112
+
113
+ True for a namespace root (no file), or when the structural pass emitted at least one
114
+ ``imports`` edge labelled ``resolution="flat"``. Since R1-C72 that pass emits none in a core
115
+ that imports itself by package-qualified or relative name: there a bare ``import warnings``
116
+ is absolute — the standard library — however a module beside the importer is named
117
+ (design ``flat_inference_in_packages.md``). The import map for calls and attribute access
118
+ (``module_imports`` / ``qualify_target``, the deep tier's ``_flat_qualify``) and the
119
+ cross-root gate (``roots.core_is_flat``) all ask this, so they cannot disagree.
120
+ """
121
+ root = graph.nodes.get(core_pkg)
122
+ return (root is not None and root.file is None) or any(
123
+ e.type == "imports" and e.extras.get("resolution") == "flat" for e in graph.edges)
@@ -152,10 +152,8 @@ class _CoreIndex:
152
152
  # script cannot be reaching `core/alpha.py` — inferring an edge there would invent
153
153
  # one. Both flat shapes satisfy this: a namespace directory (root has no file), or a
154
154
  # directory whose own imports needed the flat inference.
155
- root = graph.nodes.get(core_pkg)
156
- self.core_is_flat = (root is not None and root.file is None) or any(
157
- e.type == "imports" and e.extras.get("resolution") == "flat" for e in graph.edges
158
- )
155
+ from codemap.extract.gsource import flat_inference_applies
156
+ self.core_is_flat = flat_inference_applies(graph, core_pkg)
159
157
  self.top_modules = {
160
158
  mid.split(".", 1)[1].split(".")[0]
161
159
  for mid in self.module_ids
@@ -103,7 +103,13 @@ SCHEMA_VERSION = "0.13"
103
103
  # uses them — with `coverage.walk` naming the walk (R1-C66,
104
104
  # docs/design/pack_direction.md). Not a separate number: no release ever
105
105
  # answered 2 without it.
106
- ANSWER_FORMAT = 2
106
+ # 3 — the gate's verdict changed shape twice: an `[architecture]` section that departs from
107
+ # the schema is refused ("Contract not read — nothing was enforced", exit 2) where it was
108
+ # read as absent or partly enforced (R1-C69, docs/design/contract_validation.md); and
109
+ # `check --graph` carries a currency line, and its structured answer a `graph_currency`
110
+ # block, with a graph behind its source tree failing the gate (R1-C70,
111
+ # docs/design/check_graph_currency.md).
112
+ ANSWER_FORMAT = 3
107
113
 
108
114
  # Closed vocabulary of edge types (R1-C7). Node ``kind`` is deliberately an OPEN set
109
115
  # (DESIGN §2 — new entity kinds may appear), but edges are TYPED: every relationship