codmap 0.0.22__tar.gz → 0.0.24__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 (156) hide show
  1. {codmap-0.0.22/codmap.egg-info → codmap-0.0.24}/PKG-INFO +18 -12
  2. {codmap-0.0.22 → codmap-0.0.24}/README.md +17 -11
  3. {codmap-0.0.22 → codmap-0.0.24}/codemap/arch.py +76 -1
  4. {codmap-0.0.22 → codmap-0.0.24}/codemap/cli.py +53 -4
  5. {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/attrflow.py +3 -2
  6. {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/behavior.py +175 -35
  7. {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/griffe_extractor.py +58 -12
  8. {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/gsource.py +23 -0
  9. {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/roots.py +2 -4
  10. {codmap-0.0.22 → codmap-0.0.24}/codemap/model.py +11 -1
  11. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/audit.py +44 -10
  12. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/check.py +42 -3
  13. codmap-0.0.24/codemap/serve/currency.py +67 -0
  14. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/session.py +8 -1
  15. {codmap-0.0.22 → codmap-0.0.24/codmap.egg-info}/PKG-INFO +18 -12
  16. {codmap-0.0.22 → codmap-0.0.24}/codmap.egg-info/SOURCES.txt +8 -0
  17. {codmap-0.0.22 → codmap-0.0.24}/pyproject.toml +1 -1
  18. {codmap-0.0.22 → codmap-0.0.24}/tests/test_pack_honesty.py +3 -2
  19. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c3_arch_contract.py +17 -2
  20. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c63_answer_format.py +13 -1
  21. codmap-0.0.24/tests/test_r1c68_dotted_imports.py +123 -0
  22. codmap-0.0.24/tests/test_r1c69_contract_validation.py +106 -0
  23. codmap-0.0.24/tests/test_r1c70_check_graph_currency.py +130 -0
  24. codmap-0.0.24/tests/test_r1c71_attribute_chain_calls.py +82 -0
  25. codmap-0.0.24/tests/test_r1c72_flat_inference_in_packages.py +99 -0
  26. codmap-0.0.24/tests/test_r1c73_cli_version.py +72 -0
  27. codmap-0.0.24/tests/test_r1c74_call_site_conservation.py +169 -0
  28. {codmap-0.0.22 → codmap-0.0.24}/LICENSE +0 -0
  29. {codmap-0.0.22 → codmap-0.0.24}/codemap/__init__.py +0 -0
  30. {codmap-0.0.22 → codmap-0.0.24}/codemap/apidiff.py +0 -0
  31. {codmap-0.0.22 → codmap-0.0.24}/codemap/diagnostics.py +0 -0
  32. {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/__init__.py +0 -0
  33. {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/dataflow.py +0 -0
  34. {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/dispatch.py +0 -0
  35. {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/probe.py +0 -0
  36. {codmap-0.0.22 → codmap-0.0.24}/codemap/extract/union.py +0 -0
  37. {codmap-0.0.22 → codmap-0.0.24}/codemap/freshness.py +0 -0
  38. {codmap-0.0.22 → codmap-0.0.24}/codemap/incremental.py +0 -0
  39. {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/__init__.py +0 -0
  40. {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/base.py +0 -0
  41. {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/cocoindex.py +0 -0
  42. {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/gate.py +0 -0
  43. {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/gitnexus.py +0 -0
  44. {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/registry.py +0 -0
  45. {codmap-0.0.22 → codmap-0.0.24}/codemap/integrations/transport.py +0 -0
  46. {codmap-0.0.22 → codmap-0.0.24}/codemap/provenance.py +0 -0
  47. {codmap-0.0.22 → codmap-0.0.24}/codemap/query.py +0 -0
  48. {codmap-0.0.22 → codmap-0.0.24}/codemap/scope.py +0 -0
  49. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/__init__.py +0 -0
  50. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/_scip_pb2.py +0 -0
  51. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/api_surface.py +0 -0
  52. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/apidiff.py +0 -0
  53. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/architecture.py +0 -0
  54. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/ctags.py +0 -0
  55. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/impact.py +0 -0
  56. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/limits.py +0 -0
  57. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/livingdocs.py +0 -0
  58. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/mcp_server.py +0 -0
  59. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/mermaid.py +0 -0
  60. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/pack.py +0 -0
  61. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/rag.py +0 -0
  62. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/review.py +0 -0
  63. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/scip.py +0 -0
  64. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/semantic.py +0 -0
  65. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/server.py +0 -0
  66. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/subsystems.py +0 -0
  67. {codmap-0.0.22 → codmap-0.0.24}/codemap/serve/vault.py +0 -0
  68. {codmap-0.0.22 → codmap-0.0.24}/codemap/store.py +0 -0
  69. {codmap-0.0.22 → codmap-0.0.24}/codemap/tomlio.py +0 -0
  70. {codmap-0.0.22 → codmap-0.0.24}/codemap/watch.py +0 -0
  71. {codmap-0.0.22 → codmap-0.0.24}/codmap.egg-info/dependency_links.txt +0 -0
  72. {codmap-0.0.22 → codmap-0.0.24}/codmap.egg-info/entry_points.txt +0 -0
  73. {codmap-0.0.22 → codmap-0.0.24}/codmap.egg-info/requires.txt +0 -0
  74. {codmap-0.0.22 → codmap-0.0.24}/codmap.egg-info/top_level.txt +0 -0
  75. {codmap-0.0.22 → codmap-0.0.24}/setup.cfg +0 -0
  76. {codmap-0.0.22 → codmap-0.0.24}/tests/test_epistemic.py +0 -0
  77. {codmap-0.0.22 → codmap-0.0.24}/tests/test_gitnexus_router.py +0 -0
  78. {codmap-0.0.22 → codmap-0.0.24}/tests/test_impact_depth.py +0 -0
  79. {codmap-0.0.22 → codmap-0.0.24}/tests/test_integrations.py +0 -0
  80. {codmap-0.0.22 → codmap-0.0.24}/tests/test_issue3_serve_freshness.py +0 -0
  81. {codmap-0.0.22 → codmap-0.0.24}/tests/test_livingdocs.py +0 -0
  82. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m0_api_surface.py +0 -0
  83. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m11_argcontract.py +0 -0
  84. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m12_dataflow.py +0 -0
  85. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m15_review.py +0 -0
  86. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m16_architecture.py +0 -0
  87. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m17_mcp.py +0 -0
  88. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m18_freshness.py +0 -0
  89. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m1_5_semantics.py +0 -0
  90. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m1_query.py +0 -0
  91. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m2_views.py +0 -0
  92. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m32_watch.py +0 -0
  93. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m3_serve.py +0 -0
  94. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m4_behavior.py +0 -0
  95. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m5_deep.py +0 -0
  96. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m6_repo_scope.py +0 -0
  97. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m7_dispatch.py +0 -0
  98. {codmap-0.0.22 → codmap-0.0.24}/tests/test_m9_family.py +0 -0
  99. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  100. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c13_grep_vs_graph.py +0 -0
  101. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c16_semantic.py +0 -0
  102. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c20_attribute_edges.py +0 -0
  103. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c21_flat_layout.py +0 -0
  104. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c22_source_visible_refs.py +0 -0
  105. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c23_hard_python.py +0 -0
  106. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c24_test_mapping.py +0 -0
  107. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c25_provenance.py +0 -0
  108. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c26_deep_union.py +0 -0
  109. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c27_config_honesty.py +0 -0
  110. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c28_limit_envelope.py +0 -0
  111. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c29_lazy_imports.py +0 -0
  112. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c2_ctags.py +0 -0
  113. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c30_local_import_calls.py +0 -0
  114. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c30f1_reexport_calls.py +0 -0
  115. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c30f2_check_scope.py +0 -0
  116. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c31_path_origin.py +0 -0
  117. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c32_report_json.py +0 -0
  118. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c33_query_signature.py +0 -0
  119. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c34_signature_kinds.py +0 -0
  120. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c35_contract_location.py +0 -0
  121. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c36_target_resolution.py +0 -0
  122. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c37_rule_mutation.py +0 -0
  123. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c38_tool_drift.py +0 -0
  124. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c39_edge_resolution.py +0 -0
  125. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c3_dogfood.py +0 -0
  126. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c40_flow_reach.py +0 -0
  127. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c41_scope_membership.py +0 -0
  128. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c42_deep_tier_stability.py +0 -0
  129. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c43_incremental_splice.py +0 -0
  130. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c44_absent_answers.py +0 -0
  131. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c45_repeat_union.py +0 -0
  132. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c46_shadowed_definitions.py +0 -0
  133. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c47_incremental_resample.py +0 -0
  134. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c48_type_checking_imports.py +0 -0
  135. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c49_type_only_cycles.py +0 -0
  136. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c4_complexity.py +0 -0
  137. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c50_flow_entry_points.py +0 -0
  138. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c53_narrowing_declared.py +0 -0
  139. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c54_deterministic_rendering.py +0 -0
  140. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c55_override_grade.py +0 -0
  141. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c56_stub_files.py +0 -0
  142. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c57_facade_package.py +0 -0
  143. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c58_cycle_tangles.py +0 -0
  144. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c59_completeness_drop.py +0 -0
  145. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c5_apidiff.py +0 -0
  146. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c60_nested_definitions.py +0 -0
  147. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c62_phantom_rules.py +0 -0
  148. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c64_dependency_probe.py +0 -0
  149. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c66_pack_direction.py +0 -0
  150. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c6_pack.py +0 -0
  151. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c7_edge_vocab.py +0 -0
  152. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c8_deadcode.py +0 -0
  153. {codmap-0.0.22 → codmap-0.0.24}/tests/test_r1c9_incremental.py +0 -0
  154. {codmap-0.0.22 → codmap-0.0.24}/tests/test_scip_export.py +0 -0
  155. {codmap-0.0.22 → codmap-0.0.24}/tests/test_scope.py +0 -0
  156. {codmap-0.0.22 → codmap-0.0.24}/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.24
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, **1128 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, **1128 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:
@@ -969,8 +982,41 @@ def _add_source(p) -> None:
969
982
  help="Deep call resolution via jedi (richer, ~1 min; default fast).")
970
983
 
971
984
 
985
+ def version_line() -> str:
986
+ """``codemap 0.0.24 @abc1234 · answer format 4 · schema 0.13`` (R1-C73).
987
+
988
+ Built from ``provenance.tool_identity()`` — the identity every graph records as
989
+ ``provenance.tool`` — so the CLI and the artifact cannot disagree. Commit and dirtiness
990
+ appear only when known, as there; a missing distribution is said, never invented.
991
+ """
992
+ from codemap.model import ANSWER_FORMAT, SCHEMA_VERSION
993
+ from codemap.provenance import tool_identity
994
+ t = tool_identity()
995
+ head = f"codemap {t['version']}" if t.get("version") else \
996
+ "codemap version unknown (not installed as a distribution)"
997
+ if t.get("commit"):
998
+ head += f" @{t['commit']}"
999
+ if t.get("dirty"):
1000
+ head += " (dirty)"
1001
+ return f"{head} · answer format {ANSWER_FORMAT} · schema {SCHEMA_VERSION}"
1002
+
1003
+
1004
+ class _VersionAction(argparse.Action):
1005
+ """``--version``, computed only when asked: ``tool_identity()`` calls git (design D1)."""
1006
+
1007
+ def __init__(self, option_strings, dest=argparse.SUPPRESS, default=argparse.SUPPRESS,
1008
+ help="Print the installed version, the answer format and the graph schema, then exit."):
1009
+ super().__init__(option_strings=option_strings, dest=dest, default=default, nargs=0,
1010
+ help=help)
1011
+
1012
+ def __call__(self, parser, namespace, values, option_string=None):
1013
+ print(version_line())
1014
+ parser.exit(0)
1015
+
1016
+
972
1017
  def build_parser() -> argparse.ArgumentParser:
973
1018
  p = argparse.ArgumentParser(prog="codemap", description="Static code-graph builder.")
1019
+ p.add_argument("--version", action=_VersionAction)
974
1020
  sub = p.add_subparsers(dest="command", required=True)
975
1021
 
976
1022
  b = sub.add_parser("build", help="Build the canonical graph from a package path.")
@@ -1173,6 +1219,9 @@ def build_parser() -> argparse.ArgumentParser:
1173
1219
  help="Dir with codemap.toml holding [architecture] (default: cwd).")
1174
1220
  ck.add_argument("--require-contract", action="store_true",
1175
1221
  help="Fail if no [architecture] contract is present (default: no-op success).")
1222
+ ck.add_argument("--allow-stale", action="store_true",
1223
+ help="With --graph: do not fail when the graph is behind its source tree "
1224
+ "(R1-C70); the 'behind' line is still printed.")
1176
1225
  ck.set_defaults(func=_cmd_check)
1177
1226
 
1178
1227
  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.
@@ -81,11 +83,18 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
81
83
  # R1-C46: the surviving definition says which earlier bodies it replaced. Stamped
82
84
  # here because this pass holds the AST; the walkers below skip those bodies.
83
85
  _stamp_shadows(graph, tree, modpath)
86
+ walked_here: set[str] = set() # R1-C74: nodes that already took a body in this module
87
+ shadow_skip = _shadow_map(tree)[0]
84
88
  for fnode, class_stack, scope in _named_functions_scoped(tree):
85
89
  node_id = _node_id(modpath, class_stack, fnode.name)
86
90
  local = local_imports.get(id(fnode))
87
91
  fn_imports = {**imports, **local} if local else imports
88
- if node_id not in graph.nodes:
92
+ # R1-C74: a def nested in a function is never a definition node, whatever its
93
+ # name. `_node_id` sees only the class chain, so `def getfixturedefs` inside a method
94
+ # of `FixtureManager` used to *become* the method `FixtureManager.getfixturedefs` —
95
+ # its calls and edges attributed to a namesake (found by the conservation check).
96
+ nested_in_function = len(scope) > len(class_stack)
97
+ if node_id not in graph.nodes or nested_in_function:
89
98
  # R1-C22 D3: a closure / dynamically-built class body is not a definition
90
99
  # node, but the calls inside it are real. Attribute them to the innermost
91
100
  # definition that *does* exist instead of discarding them.
@@ -101,7 +110,9 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
101
110
  return _resolve(call, modpath, _cp, _im, _mm, _me, reexports, nodes)
102
111
  resolve = (_deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules)
103
112
  if script else fast)
104
- _process_function(graph, node_id, fnode, resolve)
113
+ _process_function(graph, node_id, fnode, resolve,
114
+ accumulate=node_id in walked_here)
115
+ walked_here.add(node_id)
105
116
  # R1-C22 D1: functions/classes named as *values* here (dispatch tables,
106
117
  # `default=` callbacks) — a use the call layer cannot see.
107
118
  _emit_name_references(graph, node_id, fnode, modpath, fn_imports, modmembers,
@@ -113,7 +124,11 @@ def add_behavior(graph, griffe_root, target_pkg: str, *, deep: bool = False,
113
124
  mod_resolve = (_deep_then_fast(graph, script, target_pkg, mod_fast, modpath, known_modules)
114
125
  if script else mod_fast)
115
126
  _process_module_level(graph, modpath, tree, mod_resolve, imports, modmembers,
116
- reexports)
127
+ reexports, shadow_skip=shadow_skip)
128
+ # R1-C74: class bodies run at import time and were walked by nobody.
129
+ _process_class_bodies(graph, modpath, tree, mod_resolve, imports, modmembers,
130
+ script, target_pkg, nested, known_modules, reexports,
131
+ skip=shadow_skip)
117
132
  _emit_nested_calls(graph, nested)
118
133
 
119
134
 
@@ -523,30 +538,49 @@ def _deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules):
523
538
  return resolve
524
539
 
525
540
 
526
- def _process_function(graph, node_id, fnode, resolve) -> None:
527
- counts = {"out": 0, "resolved": 0, "external": 0, "unresolved": 0, "dynamic": 0}
541
+ def _new_counts() -> dict:
542
+ return {"out": 0, "resolved": 0, "external": 0, "unresolved": 0, "dynamic": 0}
543
+
544
+
545
+ def _tally(counts: dict, graph, target, resolution) -> bool:
546
+ """Count one call site by how it resolved; True when it becomes an internal edge.
547
+
548
+ One rule for every scope (R1-C74): a function body, a nested function, module-level
549
+ code and a class body are classified the same way, so their counters can be summed
550
+ and checked against the source.
551
+ """
552
+ counts["out"] += 1
553
+ if resolution in _EDGE_RESOLUTIONS and target and target in graph.nodes:
554
+ counts["resolved"] += 1
555
+ return True
556
+ if resolution == "external":
557
+ counts["external"] += 1
558
+ elif resolution == "dynamic":
559
+ counts["dynamic"] += 1
560
+ else:
561
+ counts["unresolved"] += 1
562
+ return False
563
+
564
+
565
+ def _process_function(graph, node_id, fnode, resolve, accumulate: bool = False) -> None:
566
+ """``accumulate``: this node already took a body in this pass (R1-C74). A name defined in
567
+ both branches of an ``if``/``try``, or a legitimate rebinding (a property's setter), keeps
568
+ *both* bodies walked (R1-C46) — their counts add up instead of the last overwriting."""
569
+ counts = _new_counts()
528
570
  # F7: edges are deduped caller->callee, so aggregate the per-call-site argument
529
571
  # contract before emitting — a refactorer needs "how is it called", and the
530
572
  # collapse itself (2 sites -> 1 edge) must stay visible via `callsites`.
531
573
  by_target: dict[str, dict] = {}
532
574
  for call in _own_calls(fnode):
533
- counts["out"] += 1
534
575
  target, resolution = resolve(call)
535
576
  # Soundness (R1-C13-f1/f2): an internal call edge must point at a real
536
577
  # graph node. A resolver can name a non-node — a local variable jedi typed
537
578
  # to its own scope-path, or a nested/closure function that is not a
538
579
  # definition node — so downgrade any such target to unresolved rather than
539
580
  # emit an edge to nothing (which would poison callers/callees/impact).
540
- if resolution in _EDGE_RESOLUTIONS and target and target in graph.nodes:
541
- counts["resolved"] += 1
581
+ if _tally(counts, graph, target, resolution):
542
582
  agg = by_target.setdefault(target, {"resolution": resolution, "shapes": []})
543
583
  agg["shapes"].append(_arg_shape(call))
544
- elif resolution == "external":
545
- counts["external"] += 1
546
- elif resolution == "dynamic":
547
- counts["dynamic"] += 1
548
- else:
549
- counts["unresolved"] += 1
550
584
 
551
585
  for target in sorted(by_target):
552
586
  agg = by_target[target]
@@ -554,7 +588,11 @@ def _process_function(graph, node_id, fnode, resolve) -> None:
554
588
  graph.add_edge(Edge("calls", node_id, target, extras=extras))
555
589
 
556
590
  node = graph.nodes[node_id]
557
- node.extras["calls"] = counts
591
+ if accumulate and "calls" in node.extras:
592
+ for k, v in counts.items():
593
+ node.extras["calls"][k] = node.extras["calls"].get(k, 0) + v
594
+ else:
595
+ node.extras["calls"] = counts
558
596
  node.extras["control"] = _control(fnode)
559
597
  node.extras["complexity"] = _complexity(fnode)
560
598
 
@@ -588,16 +626,33 @@ def _arg_contract(shapes: list[tuple]) -> dict:
588
626
  return contract
589
627
 
590
628
 
629
+ def _is_internal(target: str, modpath: str) -> bool:
630
+ """R1-C71/D3: the package root is inside the package. ``startswith(pkg + ".")`` alone
631
+ filed ``import pkg; pkg.f()`` — and the bound name of ``import pkg.a.b`` — as external."""
632
+ root = modpath.split(".")[0]
633
+ return target == root or target.startswith(root + ".")
634
+
635
+
636
+ def _attr_chain(node) -> tuple[str, list[str]] | None:
637
+ """``a.b.c`` → ``("a", ["b", "c"])`` when the chain bottoms out in a plain name."""
638
+ parts = []
639
+ while isinstance(node, ast.Attribute):
640
+ parts.append(node.attr)
641
+ node = node.value
642
+ if not isinstance(node, ast.Name):
643
+ return None
644
+ return node.id, parts[::-1]
645
+
646
+
591
647
  def _resolve(call, modpath, class_prefix, imports, modmembers, members, reexports=None,
592
648
  nodes=frozenset()):
593
649
  """Return (target_id, resolution). Only module/self/imported become edges."""
594
- pkg = modpath.split(".")[0] + "."
595
650
  f = call.func
596
651
  if isinstance(f, ast.Name):
597
652
  name = f.id
598
653
  if name in imports:
599
654
  tgt = imports[name]
600
- if tgt.startswith(pkg):
655
+ if _is_internal(tgt, modpath):
601
656
  return _follow_reexport(tgt, reexports or {}, nodes), "imported"
602
657
  return tgt, "external"
603
658
  if name in modmembers:
@@ -613,13 +668,27 @@ def _resolve(call, modpath, class_prefix, imports, modmembers, members, reexport
613
668
  # method call on self — resolve to the class that actually defines
614
669
  # the member (own or a base), never a phantom same-class id.
615
670
  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:
671
+ if recv.id in modmembers and recv.id not in imports:
622
672
  return f"{modpath}.{recv.id}.{attr}", "module"
673
+ # R1-C71 (design ``attribute_chain_calls.md``): a callee rooted at an imported name,
674
+ # at any depth. Internal: follow it hop by hop, each hop through a re-export when the
675
+ # name is no node (D1) — the depth-1 case is the one-hop instance and answers as it
676
+ # always did; the soundness guard downstream still refuses a target that is no node,
677
+ # so `b.registry.get()` stays unresolved. External: external at any depth (D2) —
678
+ # `np.random.rand()` is no more unknown than `np.zeros()`.
679
+ chain = _attr_chain(f)
680
+ if chain is not None and chain[0] in imports:
681
+ head, parts = chain
682
+ tgt = imports[head]
683
+ if not _is_internal(tgt, modpath):
684
+ return f"{tgt}.{'.'.join(parts)}", "external"
685
+ cur = tgt
686
+ for part in parts:
687
+ cur = _follow_reexport(f"{cur}.{part}", reexports or {}, nodes)
688
+ if len(parts) == 1 or cur in nodes:
689
+ return cur, "imported"
690
+ # a deeper chain that lands on no definition (`b.registry.get("k")`) keeps the
691
+ # answer it had before R1-C71: string-keyed dispatch, else unresolved
623
692
  # X.method('literal', ...) — dynamic string-keyed dispatch (registry/dict)
624
693
  if call.args and isinstance(call.args[0], ast.Constant) \
625
694
  and isinstance(call.args[0].value, str) and attr in ("create", "get", "register"):
@@ -868,10 +937,9 @@ def _module_level_nodes(tree):
868
937
  def _resolve_name(name, modpath, imports, modmembers, reexports=None, nodes=frozenset()):
869
938
  """(target_id, resolution) for a bare name used as a value — the `_resolve` rules for
870
939
  ``ast.Name`` minus the call. Only module/imported become edges."""
871
- pkg = modpath.split(".")[0] + "."
872
940
  if name in imports:
873
941
  tgt = imports[name]
874
- if tgt.startswith(pkg):
942
+ if _is_internal(tgt, modpath):
875
943
  return _follow_reexport(tgt, reexports or {}, nodes), "imported"
876
944
  return tgt, "external"
877
945
  if name in modmembers:
@@ -909,14 +977,30 @@ def _emit_name_references(graph, src_id, scope, modpath, imports, modmembers,
909
977
 
910
978
 
911
979
  def _process_module_level(graph, modpath, tree, resolve, imports, modmembers,
912
- reexports=None) -> None:
913
- """Calls and value-references in module-level code, sourced from the module (D1/D2)."""
980
+ reexports=None, shadow_skip=None) -> None:
981
+ """Calls and value-references in module-level code, sourced from the module (D1/D2).
982
+
983
+ R1-C74: the module node also carries ``extras.calls`` — its sites used to make edges
984
+ and appear in no resolution figure.
985
+ """
914
986
  by_target: dict[str, dict] = {}
987
+ counts = _new_counts()
915
988
  for call in (n for n in _module_level_nodes(tree) if isinstance(n, ast.Call)):
916
989
  target, resolution = resolve(call)
917
- if resolution in _EDGE_RESOLUTIONS and target and target in graph.nodes:
990
+ if _tally(counts, graph, target, resolution):
918
991
  agg = by_target.setdefault(target, {"resolution": resolution, "shapes": []})
919
992
  agg["shapes"].append(_arg_shape(call))
993
+ if modpath in graph.nodes:
994
+ graph.nodes[modpath].extras["calls"] = counts
995
+ # R1-C74 D1: a replaced body is not walked (R1-C46) — said, not absorbed.
996
+ skipped = _shadow_map(tree)[0] if shadow_skip is None else shadow_skip
997
+ if skipped:
998
+ seen: set[int] = set()
999
+ for node in ast.walk(tree):
1000
+ if id(node) in skipped:
1001
+ seen.update(id(c) for c in ast.walk(node) if isinstance(c, ast.Call))
1002
+ if seen:
1003
+ graph.nodes[modpath].extras["calls_shadowed"] = len(seen)
920
1004
  for target in sorted(by_target):
921
1005
  agg = by_target[target]
922
1006
  graph.add_edge(Edge("calls", modpath, target,
@@ -926,6 +1010,57 @@ def _process_module_level(graph, modpath, tree, resolve, imports, modmembers,
926
1010
  reexports=reexports)
927
1011
 
928
1012
 
1013
+ def _process_class_bodies(graph, modpath, tree, resolve, imports, modmembers, script,
1014
+ target_pkg, nested, known_modules=frozenset(), reexports=None,
1015
+ skip=None) -> None:
1016
+ """Calls in class bodies outside their methods — bases, keywords, decorators, statements.
1017
+
1018
+ R1-C74 (design ``call_site_conservation.md`` D1). A class body runs at import time, so a
1019
+ class that is a graph node is the source of its own ``calls`` edges and carries
1020
+ ``extras.calls``, resolved like module-level code. A class nested in a function runs
1021
+ when the function does: its sites go to the owner as nested, like a closure's. Shadowed
1022
+ definitions are skipped, as everywhere (R1-C46).
1023
+ """
1024
+ skip = _shadow_map(tree)[0] if skip is None else skip
1025
+
1026
+ def visit(node, scope, in_function):
1027
+ for child in ast.iter_child_nodes(node):
1028
+ if id(child) in skip:
1029
+ continue
1030
+ if isinstance(child, ast.ClassDef):
1031
+ cscope = scope + [child.name]
1032
+ calls = [n for n in _own_nodes(child) if isinstance(n, ast.Call)]
1033
+ cid = ".".join([modpath, *cscope])
1034
+ if not in_function and cid in graph.nodes:
1035
+ counts = _new_counts()
1036
+ by_target: dict[str, dict] = {}
1037
+ for call in calls:
1038
+ target, resolution = resolve(call)
1039
+ if _tally(counts, graph, target, resolution) and target != cid:
1040
+ agg = by_target.setdefault(target, {"resolution": resolution,
1041
+ "shapes": []})
1042
+ agg["shapes"].append(_arg_shape(call))
1043
+ graph.nodes[cid].extras["calls"] = counts
1044
+ for target in sorted(by_target):
1045
+ agg = by_target[target]
1046
+ graph.add_edge(Edge("calls", cid, target,
1047
+ extras={"resolution": agg["resolution"],
1048
+ **_arg_contract(agg["shapes"])}))
1049
+ elif calls:
1050
+ owner = _nearest_owner(graph, modpath, scope)
1051
+ if owner is not None:
1052
+ _collect_nested_calls(graph, owner, child, modpath, imports, modmembers,
1053
+ script, target_pkg, nested, known_modules,
1054
+ reexports, calls=calls)
1055
+ visit(child, cscope, in_function)
1056
+ elif isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
1057
+ visit(child, scope + [child.name], True)
1058
+ else:
1059
+ visit(child, scope, in_function)
1060
+
1061
+ visit(tree, [], False)
1062
+
1063
+
929
1064
  def _named_functions_scoped(tree):
930
1065
  """Like :func:`_named_functions`, plus the **full** enclosing scope names.
931
1066
 
@@ -969,17 +1104,22 @@ def _nearest_owner(graph, modpath, scope) -> str | None:
969
1104
 
970
1105
  def _collect_nested_calls(graph, owner, fnode, modpath, imports, modmembers,
971
1106
  script, target_pkg, out, known_modules=frozenset(),
972
- reexports=None) -> None:
973
- """Resolve a non-node function's own calls and stage them under ``owner`` (D3)."""
1107
+ reexports=None, calls=None) -> None:
1108
+ """Resolve a non-node function's own calls and stage them under ``owner`` (D3).
1109
+
1110
+ ``fnode`` may also be a class nested in a function (R1-C74): its body runs inside the
1111
+ owner, like a closure's. Every site is counted into the owner's ``extras.calls_nested``.
1112
+ """
974
1113
  def fast(call):
975
1114
  return _resolve(call, modpath, "", imports, modmembers, {}, reexports, graph.nodes)
976
1115
  # R1-C26: the same union the named-function path uses — a closure's calls must not be
977
1116
  # resolved by a weaker rule than its neighbours'.
978
1117
  resolve = (_deep_then_fast(graph, script, target_pkg, fast, modpath, known_modules)
979
1118
  if script is not None else fast)
980
- for call in _own_calls(fnode):
1119
+ counts = graph.nodes[owner].extras.setdefault("calls_nested", _new_counts())
1120
+ for call in (calls if calls is not None else _own_calls(fnode)):
981
1121
  target, resolution = resolve(call)
982
- if resolution not in _EDGE_RESOLUTIONS or not target or target not in graph.nodes:
1122
+ if not _tally(counts, graph, target, resolution):
983
1123
  continue
984
1124
  if target == owner:
985
1125
  continue # a closure calling its own enclosing function — recursion, not a dep