codmap 0.0.21__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 (155) hide show
  1. {codmap-0.0.21/codmap.egg-info → codmap-0.0.23}/PKG-INFO +33 -14
  2. {codmap-0.0.21 → codmap-0.0.23}/README.md +32 -13
  3. {codmap-0.0.21 → codmap-0.0.23}/codemap/arch.py +76 -1
  4. {codmap-0.0.21 → codmap-0.0.23}/codemap/cli.py +91 -12
  5. {codmap-0.0.21 → codmap-0.0.23}/codemap/extract/attrflow.py +3 -2
  6. {codmap-0.0.21 → codmap-0.0.23}/codemap/extract/behavior.py +45 -13
  7. {codmap-0.0.21 → codmap-0.0.23}/codemap/extract/griffe_extractor.py +58 -12
  8. {codmap-0.0.21 → codmap-0.0.23}/codemap/extract/gsource.py +23 -0
  9. codmap-0.0.23/codemap/extract/probe.py +152 -0
  10. {codmap-0.0.21 → codmap-0.0.23}/codemap/extract/roots.py +2 -4
  11. {codmap-0.0.21 → codmap-0.0.23}/codemap/model.py +17 -1
  12. {codmap-0.0.21 → codmap-0.0.23}/codemap/provenance.py +71 -1
  13. {codmap-0.0.21 → codmap-0.0.23}/codemap/query.py +101 -16
  14. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/apidiff.py +4 -1
  15. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/audit.py +8 -3
  16. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/check.py +42 -3
  17. codmap-0.0.23/codemap/serve/currency.py +67 -0
  18. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/mcp_server.py +17 -6
  19. codmap-0.0.23/codemap/serve/pack.py +224 -0
  20. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/session.py +10 -2
  21. {codmap-0.0.21 → codmap-0.0.23/codmap.egg-info}/PKG-INFO +33 -14
  22. {codmap-0.0.21 → codmap-0.0.23}/codmap.egg-info/SOURCES.txt +12 -0
  23. {codmap-0.0.21 → codmap-0.0.23}/pyproject.toml +1 -1
  24. codmap-0.0.23/tests/test_pack_honesty.py +228 -0
  25. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c3_arch_contract.py +17 -2
  26. codmap-0.0.23/tests/test_r1c59_completeness_drop.py +198 -0
  27. codmap-0.0.23/tests/test_r1c60_nested_definitions.py +117 -0
  28. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c63_answer_format.py +14 -1
  29. codmap-0.0.23/tests/test_r1c64_dependency_probe.py +140 -0
  30. codmap-0.0.23/tests/test_r1c66_pack_direction.py +150 -0
  31. codmap-0.0.23/tests/test_r1c68_dotted_imports.py +123 -0
  32. codmap-0.0.23/tests/test_r1c69_contract_validation.py +106 -0
  33. codmap-0.0.23/tests/test_r1c70_check_graph_currency.py +130 -0
  34. codmap-0.0.23/tests/test_r1c71_attribute_chain_calls.py +82 -0
  35. codmap-0.0.23/tests/test_r1c72_flat_inference_in_packages.py +99 -0
  36. codmap-0.0.21/codemap/serve/pack.py +0 -93
  37. {codmap-0.0.21 → codmap-0.0.23}/LICENSE +0 -0
  38. {codmap-0.0.21 → codmap-0.0.23}/codemap/__init__.py +0 -0
  39. {codmap-0.0.21 → codmap-0.0.23}/codemap/apidiff.py +0 -0
  40. {codmap-0.0.21 → codmap-0.0.23}/codemap/diagnostics.py +0 -0
  41. {codmap-0.0.21 → codmap-0.0.23}/codemap/extract/__init__.py +0 -0
  42. {codmap-0.0.21 → codmap-0.0.23}/codemap/extract/dataflow.py +0 -0
  43. {codmap-0.0.21 → codmap-0.0.23}/codemap/extract/dispatch.py +0 -0
  44. {codmap-0.0.21 → codmap-0.0.23}/codemap/extract/union.py +0 -0
  45. {codmap-0.0.21 → codmap-0.0.23}/codemap/freshness.py +0 -0
  46. {codmap-0.0.21 → codmap-0.0.23}/codemap/incremental.py +0 -0
  47. {codmap-0.0.21 → codmap-0.0.23}/codemap/integrations/__init__.py +0 -0
  48. {codmap-0.0.21 → codmap-0.0.23}/codemap/integrations/base.py +0 -0
  49. {codmap-0.0.21 → codmap-0.0.23}/codemap/integrations/cocoindex.py +0 -0
  50. {codmap-0.0.21 → codmap-0.0.23}/codemap/integrations/gate.py +0 -0
  51. {codmap-0.0.21 → codmap-0.0.23}/codemap/integrations/gitnexus.py +0 -0
  52. {codmap-0.0.21 → codmap-0.0.23}/codemap/integrations/registry.py +0 -0
  53. {codmap-0.0.21 → codmap-0.0.23}/codemap/integrations/transport.py +0 -0
  54. {codmap-0.0.21 → codmap-0.0.23}/codemap/scope.py +0 -0
  55. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/__init__.py +0 -0
  56. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/_scip_pb2.py +0 -0
  57. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/api_surface.py +0 -0
  58. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/architecture.py +0 -0
  59. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/ctags.py +0 -0
  60. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/impact.py +0 -0
  61. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/limits.py +0 -0
  62. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/livingdocs.py +0 -0
  63. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/mermaid.py +0 -0
  64. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/rag.py +0 -0
  65. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/review.py +0 -0
  66. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/scip.py +0 -0
  67. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/semantic.py +0 -0
  68. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/server.py +0 -0
  69. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/subsystems.py +0 -0
  70. {codmap-0.0.21 → codmap-0.0.23}/codemap/serve/vault.py +0 -0
  71. {codmap-0.0.21 → codmap-0.0.23}/codemap/store.py +0 -0
  72. {codmap-0.0.21 → codmap-0.0.23}/codemap/tomlio.py +0 -0
  73. {codmap-0.0.21 → codmap-0.0.23}/codemap/watch.py +0 -0
  74. {codmap-0.0.21 → codmap-0.0.23}/codmap.egg-info/dependency_links.txt +0 -0
  75. {codmap-0.0.21 → codmap-0.0.23}/codmap.egg-info/entry_points.txt +0 -0
  76. {codmap-0.0.21 → codmap-0.0.23}/codmap.egg-info/requires.txt +0 -0
  77. {codmap-0.0.21 → codmap-0.0.23}/codmap.egg-info/top_level.txt +0 -0
  78. {codmap-0.0.21 → codmap-0.0.23}/setup.cfg +0 -0
  79. {codmap-0.0.21 → codmap-0.0.23}/tests/test_epistemic.py +0 -0
  80. {codmap-0.0.21 → codmap-0.0.23}/tests/test_gitnexus_router.py +0 -0
  81. {codmap-0.0.21 → codmap-0.0.23}/tests/test_impact_depth.py +0 -0
  82. {codmap-0.0.21 → codmap-0.0.23}/tests/test_integrations.py +0 -0
  83. {codmap-0.0.21 → codmap-0.0.23}/tests/test_issue3_serve_freshness.py +0 -0
  84. {codmap-0.0.21 → codmap-0.0.23}/tests/test_livingdocs.py +0 -0
  85. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m0_api_surface.py +0 -0
  86. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m11_argcontract.py +0 -0
  87. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m12_dataflow.py +0 -0
  88. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m15_review.py +0 -0
  89. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m16_architecture.py +0 -0
  90. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m17_mcp.py +0 -0
  91. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m18_freshness.py +0 -0
  92. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m1_5_semantics.py +0 -0
  93. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m1_query.py +0 -0
  94. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m2_views.py +0 -0
  95. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m32_watch.py +0 -0
  96. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m3_serve.py +0 -0
  97. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m4_behavior.py +0 -0
  98. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m5_deep.py +0 -0
  99. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m6_repo_scope.py +0 -0
  100. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m7_dispatch.py +0 -0
  101. {codmap-0.0.21 → codmap-0.0.23}/tests/test_m9_family.py +0 -0
  102. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  103. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c13_grep_vs_graph.py +0 -0
  104. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c16_semantic.py +0 -0
  105. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c20_attribute_edges.py +0 -0
  106. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c21_flat_layout.py +0 -0
  107. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c22_source_visible_refs.py +0 -0
  108. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c23_hard_python.py +0 -0
  109. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c24_test_mapping.py +0 -0
  110. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c25_provenance.py +0 -0
  111. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c26_deep_union.py +0 -0
  112. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c27_config_honesty.py +0 -0
  113. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c28_limit_envelope.py +0 -0
  114. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c29_lazy_imports.py +0 -0
  115. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c2_ctags.py +0 -0
  116. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c30_local_import_calls.py +0 -0
  117. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c30f1_reexport_calls.py +0 -0
  118. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c30f2_check_scope.py +0 -0
  119. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c31_path_origin.py +0 -0
  120. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c32_report_json.py +0 -0
  121. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c33_query_signature.py +0 -0
  122. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c34_signature_kinds.py +0 -0
  123. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c35_contract_location.py +0 -0
  124. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c36_target_resolution.py +0 -0
  125. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c37_rule_mutation.py +0 -0
  126. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c38_tool_drift.py +0 -0
  127. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c39_edge_resolution.py +0 -0
  128. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c3_dogfood.py +0 -0
  129. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c40_flow_reach.py +0 -0
  130. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c41_scope_membership.py +0 -0
  131. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c42_deep_tier_stability.py +0 -0
  132. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c43_incremental_splice.py +0 -0
  133. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c44_absent_answers.py +0 -0
  134. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c45_repeat_union.py +0 -0
  135. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c46_shadowed_definitions.py +0 -0
  136. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c47_incremental_resample.py +0 -0
  137. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c48_type_checking_imports.py +0 -0
  138. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c49_type_only_cycles.py +0 -0
  139. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c4_complexity.py +0 -0
  140. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c50_flow_entry_points.py +0 -0
  141. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c53_narrowing_declared.py +0 -0
  142. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c54_deterministic_rendering.py +0 -0
  143. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c55_override_grade.py +0 -0
  144. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c56_stub_files.py +0 -0
  145. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c57_facade_package.py +0 -0
  146. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c58_cycle_tangles.py +0 -0
  147. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c5_apidiff.py +0 -0
  148. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c62_phantom_rules.py +0 -0
  149. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c6_pack.py +0 -0
  150. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c7_edge_vocab.py +0 -0
  151. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c8_deadcode.py +0 -0
  152. {codmap-0.0.21 → codmap-0.0.23}/tests/test_r1c9_incremental.py +0 -0
  153. {codmap-0.0.21 → codmap-0.0.23}/tests/test_scip_export.py +0 -0
  154. {codmap-0.0.21 → codmap-0.0.23}/tests/test_scope.py +0 -0
  155. {codmap-0.0.21 → 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.21
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, **990 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
@@ -221,8 +227,14 @@ codemap serve --graph graph.json --watch
221
227
  - **Dataflow** — producers/consumers of a string-keyed DataFrame column.
222
228
  - **Semantic search** (opt-in) — a concept query routed to an external adapter, with each fuzzy hit
223
229
  resolved to the exact codemap symbol at its location (`codemap semantic`). See [docs/integrations.md](docs/integrations.md).
224
- - **Context pack** — the most relevant slice of the graph under a token budget, ranked by importance or
225
- by relevance to seed symbols (`codemap pack --budget N [--seed X]`). See [docs/pack.md](docs/pack.md).
230
+ - **Context pack** — the highest-ranked modules, classes and functions under a token budget, by
231
+ importance or by relevance to seed symbols (`codemap pack --budget N [--seed X]`). With a seed it
232
+ walks both ways — what the seed uses and what uses it (`--direction uses` for one way) — documents are
233
+ never in it, the answer says which walk it took, and a seed that resolves to nothing is refused rather
234
+ than silently answered globally. Deterministic and budget-tested. Against git history, seeded with a
235
+ changed file, it puts 42 % and 60 % of the co-changed files in its first ten on two repositories —
236
+ ahead of `impact` and of grep by names, though on one of the two not beyond the noise; grep still
237
+ finds tests best. **Whether it helps an agent is not measured.** See [docs/pack.md](docs/pack.md).
226
238
  - **Interop** — export the graph as a [SCIP](https://scip-code.org/) index (definitions + symbol
227
239
  info + inherits/implements relationships) so Sourcegraph, Glean and other SCIP consumers can drive
228
240
  go-to-definition, symbol search and type hierarchy over it. See [docs/export.md](docs/export.md).
@@ -266,6 +278,13 @@ build says so in its own diagnostics, and `codemap diff` says so when both sides
266
278
  delta of a few call edges as possible tool noise before reading it as a change in the code. Anything
267
279
  that must be reproducible byte-for-byte — a gate, a two-release comparison — belongs on the fast tier.
268
280
 
281
+ **A deep build does not check jedi.** With jedi answering nothing, a deep build still exits 0 and
282
+ declares `tier: deep` — and its call edges equal the fast tier's. `codemap doctor` asks griffe and jedi
283
+ one question each with a known answer and exits 1 when one is absent or answers wrongly
284
+ ([accuracy.md](docs/accuracy.md#what-this-means-for-trust)); rebuilding over the previous graph of the
285
+ same tree (`build -o` on the existing file) names the classes of edges that shrank
286
+ ([provenance.md](docs/provenance.md#fewer-edges-on-the-same-input-is-named)).
287
+
269
288
  **How many times to build.** A consumer measured 175 full deep builds of one tree and found a real
270
289
  edge present in **75 %** of them; eight builds here agreed (5 of 8, the same edge, and nothing else
271
290
  unstable among 13 675). So one build misses such an edge about 1 time in 4, two builds 1 in 16, three
@@ -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, **990 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
@@ -185,8 +191,14 @@ codemap serve --graph graph.json --watch
185
191
  - **Dataflow** — producers/consumers of a string-keyed DataFrame column.
186
192
  - **Semantic search** (opt-in) — a concept query routed to an external adapter, with each fuzzy hit
187
193
  resolved to the exact codemap symbol at its location (`codemap semantic`). See [docs/integrations.md](docs/integrations.md).
188
- - **Context pack** — the most relevant slice of the graph under a token budget, ranked by importance or
189
- by relevance to seed symbols (`codemap pack --budget N [--seed X]`). See [docs/pack.md](docs/pack.md).
194
+ - **Context pack** — the highest-ranked modules, classes and functions under a token budget, by
195
+ importance or by relevance to seed symbols (`codemap pack --budget N [--seed X]`). With a seed it
196
+ walks both ways — what the seed uses and what uses it (`--direction uses` for one way) — documents are
197
+ never in it, the answer says which walk it took, and a seed that resolves to nothing is refused rather
198
+ than silently answered globally. Deterministic and budget-tested. Against git history, seeded with a
199
+ changed file, it puts 42 % and 60 % of the co-changed files in its first ten on two repositories —
200
+ ahead of `impact` and of grep by names, though on one of the two not beyond the noise; grep still
201
+ finds tests best. **Whether it helps an agent is not measured.** See [docs/pack.md](docs/pack.md).
190
202
  - **Interop** — export the graph as a [SCIP](https://scip-code.org/) index (definitions + symbol
191
203
  info + inherits/implements relationships) so Sourcegraph, Glean and other SCIP consumers can drive
192
204
  go-to-definition, symbol search and type hierarchy over it. See [docs/export.md](docs/export.md).
@@ -230,6 +242,13 @@ build says so in its own diagnostics, and `codemap diff` says so when both sides
230
242
  delta of a few call edges as possible tool noise before reading it as a change in the code. Anything
231
243
  that must be reproducible byte-for-byte — a gate, a two-release comparison — belongs on the fast tier.
232
244
 
245
+ **A deep build does not check jedi.** With jedi answering nothing, a deep build still exits 0 and
246
+ declares `tier: deep` — and its call edges equal the fast tier's. `codemap doctor` asks griffe and jedi
247
+ one question each with a known answer and exits 1 when one is absent or answers wrongly
248
+ ([accuracy.md](docs/accuracy.md#what-this-means-for-trust)); rebuilding over the previous graph of the
249
+ same tree (`build -o` on the existing file) names the classes of edges that shrank
250
+ ([provenance.md](docs/provenance.md#fewer-edges-on-the-same-input-is-named)).
251
+
233
252
  **How many times to build.** A consumer measured 175 full deep builds of one tree and found a real
234
253
  edge present in **75 %** of them; eight builds here agreed (5 of 8, the same edge, and nothing else
235
254
  unstable among 13 675). So one build misses such an edge about 1 time in 4, two builds 1 in 16, three
@@ -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:
@@ -2,6 +2,8 @@
2
2
 
3
3
  codemap build <path> [-o graph.json] [--deep]
4
4
  [--consumer PATH ...] [--docs PATH ...] [--mode thin|full]
5
+ codemap doctor [--json]
6
+ probe griffe and jedi with a question whose answer is known (R1-C64)
5
7
  codemap scope <path> [--consumer PATH ...] [--docs PATH ...] [--no-git] [--json]
6
8
  | --diff A.meta.json B.meta.json → input scope manifest (M19.A)
7
9
  codemap query <name> (--graph g.json | --build <path>) [--format json|text]
@@ -30,7 +32,7 @@
30
32
  codemap.toml [integrations].enabled + the tool installed)
31
33
  codemap semantic <query> (--graph g.json | --build <path>) [--root DIR] [--limit N]
32
34
  semantic search via an opt-in adapter, enriched to codemap symbols (R1-C16)
33
- codemap pack (--graph g.json | --build <path>) [--budget N] [--seed X …]
35
+ codemap pack (--graph g.json | --build <path>) [--budget N] [--seed X …] [--direction both|uses]
34
36
  token-budgeted context pack: most relevant graph slice under N tokens (R1-C6)
35
37
  """
36
38
 
@@ -155,6 +157,7 @@ def _cmd_build(args) -> int:
155
157
  text = " ".join(p for p in (d["message"], d.get("consequence")) if p)
156
158
  print(f"[{d.get('severity', 'warning')}] {text}", file=sys.stderr)
157
159
  if args.out:
160
+ _report_completeness_drop(args.out, graph)
158
161
  store.save(graph, args.out)
159
162
  # M18: record the build recipe beside the graph so `codemap refresh` can
160
163
  # rebuild it, and so the graph's age is meaningful to `serve`/stats.
@@ -175,6 +178,36 @@ def _cmd_build(args) -> int:
175
178
  return 0
176
179
 
177
180
 
181
+ def _report_completeness_drop(out: str, graph) -> None:
182
+ """Compare a fresh build with the graph it is about to overwrite (R1-C59).
183
+
184
+ The one moment both builds of an input are in hand without anyone asking: the previous
185
+ graph is still on disk at ``-o``. Read it before the write and say when an edge class
186
+ shrank on the **same** input — on one input that is the builder (tool, dependency,
187
+ sampling), never the code. A warning, not a refusal: rebuilding with a new tool is
188
+ legitimate, and the build still succeeds.
189
+
190
+ A file that exists and cannot be read is not a file that is absent (R1-C27), so it is
191
+ named; a missing file is the first build, and there is nothing to say.
192
+ """
193
+ from codemap.provenance import completeness_drops, describe_drops
194
+ if not Path(out).exists():
195
+ return
196
+ try:
197
+ previous = store.load(out)
198
+ except Exception as exc: # any unreadable previous graph — never fatal to a build
199
+ print(f"[note] the graph already at {out} could not be read "
200
+ f"({type(exc).__name__}); completeness against it not compared",
201
+ file=sys.stderr)
202
+ return
203
+ drops = completeness_drops(previous, graph)
204
+ if drops:
205
+ print(f"[warning] {describe_drops(drops)} — against the graph this build replaces. "
206
+ "Same input, fewer edges: the difference is the builder (the tool, a "
207
+ "dependency, or the deep tier's sampling), not the code; whatever reads "
208
+ "those edges now answers from less than before.", file=sys.stderr)
209
+
210
+
178
211
  def _inputs_with_membership(graph, scope, args):
179
212
  """The extractor's ``inputs`` block plus the manifest membership result (R1-C41).
180
213
 
@@ -333,6 +366,22 @@ def _cmd_watch(args) -> int:
333
366
  return 0
334
367
 
335
368
 
369
+ def _cmd_doctor(args) -> int:
370
+ """Probe each dependency with a question whose answer is known (R1-C64).
371
+
372
+ Exit 0 when every probe answers as expected, 1 otherwise. A dependency that imports
373
+ and answers wrongly is the case this exists for: it never breaks a build, it makes the
374
+ graph poorer while the graph still declares its tier.
375
+ """
376
+ from codemap.extract.probe import render, run_probes
377
+ report = run_probes()
378
+ if args.json:
379
+ print(json.dumps(report, indent=2, sort_keys=True, ensure_ascii=False))
380
+ else:
381
+ print(render(report), end="")
382
+ return 0 if report["ok"] else 1
383
+
384
+
336
385
  def _cmd_scope(args) -> int:
337
386
  """Resolve/print the input scope manifest, or diff two (M19.A)."""
338
387
  from codemap.scope import resolve_scope, diff_scopes
@@ -741,15 +790,27 @@ def _cmd_semantic(args) -> int:
741
790
 
742
791
  def _cmd_pack(args) -> int:
743
792
  """Token-budgeted context pack — most relevant graph slice under N tokens (R1-C6)."""
744
- from codemap.serve.pack import build_pack, render_pack
745
- q = Query(_graph_from(args))
793
+ from codemap.serve.pack import build_pack, render_payload
746
794
  seeds = tuple(args.seed or ())
795
+ if args.direction and not seeds: # before any build: a usage error costs nothing
796
+ print("codemap pack: --direction needs --seed — the global rank walks from no "
797
+ "seed", file=sys.stderr)
798
+ return 2
799
+ q = Query(_graph_from(args))
800
+ p = build_pack(q, budget=args.budget, seeds=seeds, direction=args.direction)
747
801
  if args.format == "json":
748
- print(json.dumps(build_pack(q, budget=args.budget, seeds=seeds),
749
- ensure_ascii=False, indent=2, sort_keys=True))
802
+ print(json.dumps(p, ensure_ascii=False, indent=2, sort_keys=True))
750
803
  else:
751
- print(render_pack(q, budget=args.budget, seeds=seeds), end="")
752
- return 0
804
+ print(render_payload(p, target=q.graph.target, seeds=seeds), end="")
805
+ # No seed resolved → a refusal, not an empty pack: exit 1, as `query` does on an
806
+ # unknown name (pack honesty).
807
+ return 1 if "error" in p else 0
808
+
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
753
814
 
754
815
 
755
816
  def _cmd_check(args) -> int:
@@ -765,21 +826,28 @@ def _cmd_check(args) -> int:
765
826
  from codemap.serve.check import render_check
766
827
  q = Query(_graph_from(args))
767
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)
768
835
  # R1-C27: check `error` before anything else. A malformed codemap.toml used to be
769
836
  # indistinguishable from an absent one, so removing a single `]` turned a gate that
770
837
  # reported 14 violations into exit 0. Exit 2 — the same code as a violation, not a new
771
838
  # one: the status answers "may the pipeline proceed?" (no, either way), and a new code
772
839
  # would sort an unreadable contract into the success branch of every `if rc == 2` that
773
840
  # already exists in someone's pipeline.
841
+ kw = {"currency": currency, "allow_stale": allow_stale}
774
842
  if contract.error:
775
- _emit_answer(render_check(q, contract, []))
843
+ _emit_answer(render_check(q, contract, [], **kw))
776
844
  return 2
777
845
  if contract.is_empty() and args.require_contract:
778
- _emit_answer(render_check(q, contract, []))
846
+ _emit_answer(render_check(q, contract, [], **kw))
779
847
  raise SystemExit("error: no [architecture] contract found (--require-contract)")
780
848
  violations = check_contract(q, contract)
781
- _emit_answer(render_check(q, contract, violations))
782
- return 2 if violations else 0
849
+ _emit_answer(render_check(q, contract, violations, **kw))
850
+ return _check_exit(violations, currency, allow_stale)
783
851
 
784
852
 
785
853
  def _cmd_diff(args) -> int:
@@ -795,7 +863,7 @@ def _cmd_diff(args) -> int:
795
863
  # R1-C25/D4: two graphs built by different tools are a before/after of the *tool*,
796
864
  # not of the code. Never a refusal — comparing across an upgrade is legitimate; what
797
865
  # was missing is being told, since a clean "no breaking changes" reads as proof.
798
- cmp = comparability(old.provenance, new.provenance)
866
+ cmp = comparability(old.provenance, new.provenance, old_graph=old, new_graph=new)
799
867
  if not cmp["comparable"]:
800
868
  for line in cmp["differences"]:
801
869
  print(f"[warning] {line}", file=sys.stderr)
@@ -947,6 +1015,11 @@ def build_parser() -> argparse.ArgumentParser:
947
1015
  "of the chain: a different N restarts it from a full build.")
948
1016
  b.set_defaults(func=_cmd_build)
949
1017
 
1018
+ dr = sub.add_parser("doctor", help="Probe griffe and jedi with a question whose answer is "
1019
+ "known; exit 1 if one is absent or answers wrongly.")
1020
+ dr.add_argument("--json", action="store_true", help="Structured report instead of text.")
1021
+ dr.set_defaults(func=_cmd_doctor)
1022
+
950
1023
  sc = sub.add_parser("scope", help="Resolve the input scope manifest (scope_id + profile), or --diff two.")
951
1024
  sc.add_argument("path", nargs="?", help="Package/dir to scope (like build's path).")
952
1025
  sc.add_argument("--consumer", action="append", metavar="PATH",
@@ -1094,6 +1167,9 @@ def build_parser() -> argparse.ArgumentParser:
1094
1167
  pk.add_argument("--seed", action="append", metavar="X",
1095
1168
  help="Bias relevance to this symbol / file (repeatable). "
1096
1169
  "Omit for global importance.")
1170
+ pk.add_argument("--direction", choices=["both", "uses"],
1171
+ help="Which way a seeded walk runs: both (default) — what the seeds "
1172
+ "use and what uses them; uses — only what they use.")
1097
1173
  pk.add_argument("--format", choices=["markdown", "json"], default="markdown")
1098
1174
  pk.set_defaults(func=_cmd_pack)
1099
1175
 
@@ -1110,6 +1186,9 @@ def build_parser() -> argparse.ArgumentParser:
1110
1186
  help="Dir with codemap.toml holding [architecture] (default: cwd).")
1111
1187
  ck.add_argument("--require-contract", action="store_true",
1112
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.")
1113
1192
  ck.set_defaults(func=_cmd_check)
1114
1193
 
1115
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: