codmap 0.0.21__tar.gz → 0.0.22__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 (149) hide show
  1. {codmap-0.0.21/codmap.egg-info → codmap-0.0.22}/PKG-INFO +17 -4
  2. {codmap-0.0.21 → codmap-0.0.22}/README.md +16 -3
  3. {codmap-0.0.21 → codmap-0.0.22}/codemap/cli.py +71 -8
  4. codmap-0.0.22/codemap/extract/probe.py +152 -0
  5. {codmap-0.0.21 → codmap-0.0.22}/codemap/model.py +11 -1
  6. {codmap-0.0.21 → codmap-0.0.22}/codemap/provenance.py +71 -1
  7. {codmap-0.0.21 → codmap-0.0.22}/codemap/query.py +101 -16
  8. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/apidiff.py +4 -1
  9. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/audit.py +8 -3
  10. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/mcp_server.py +17 -6
  11. codmap-0.0.22/codemap/serve/pack.py +224 -0
  12. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/session.py +2 -1
  13. {codmap-0.0.21 → codmap-0.0.22/codmap.egg-info}/PKG-INFO +17 -4
  14. {codmap-0.0.21 → codmap-0.0.22}/codmap.egg-info/SOURCES.txt +6 -0
  15. {codmap-0.0.21 → codmap-0.0.22}/pyproject.toml +1 -1
  16. codmap-0.0.22/tests/test_pack_honesty.py +227 -0
  17. codmap-0.0.22/tests/test_r1c59_completeness_drop.py +198 -0
  18. codmap-0.0.22/tests/test_r1c60_nested_definitions.py +117 -0
  19. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c63_answer_format.py +3 -1
  20. codmap-0.0.22/tests/test_r1c64_dependency_probe.py +140 -0
  21. codmap-0.0.22/tests/test_r1c66_pack_direction.py +150 -0
  22. codmap-0.0.21/codemap/serve/pack.py +0 -93
  23. {codmap-0.0.21 → codmap-0.0.22}/LICENSE +0 -0
  24. {codmap-0.0.21 → codmap-0.0.22}/codemap/__init__.py +0 -0
  25. {codmap-0.0.21 → codmap-0.0.22}/codemap/apidiff.py +0 -0
  26. {codmap-0.0.21 → codmap-0.0.22}/codemap/arch.py +0 -0
  27. {codmap-0.0.21 → codmap-0.0.22}/codemap/diagnostics.py +0 -0
  28. {codmap-0.0.21 → codmap-0.0.22}/codemap/extract/__init__.py +0 -0
  29. {codmap-0.0.21 → codmap-0.0.22}/codemap/extract/attrflow.py +0 -0
  30. {codmap-0.0.21 → codmap-0.0.22}/codemap/extract/behavior.py +0 -0
  31. {codmap-0.0.21 → codmap-0.0.22}/codemap/extract/dataflow.py +0 -0
  32. {codmap-0.0.21 → codmap-0.0.22}/codemap/extract/dispatch.py +0 -0
  33. {codmap-0.0.21 → codmap-0.0.22}/codemap/extract/griffe_extractor.py +0 -0
  34. {codmap-0.0.21 → codmap-0.0.22}/codemap/extract/gsource.py +0 -0
  35. {codmap-0.0.21 → codmap-0.0.22}/codemap/extract/roots.py +0 -0
  36. {codmap-0.0.21 → codmap-0.0.22}/codemap/extract/union.py +0 -0
  37. {codmap-0.0.21 → codmap-0.0.22}/codemap/freshness.py +0 -0
  38. {codmap-0.0.21 → codmap-0.0.22}/codemap/incremental.py +0 -0
  39. {codmap-0.0.21 → codmap-0.0.22}/codemap/integrations/__init__.py +0 -0
  40. {codmap-0.0.21 → codmap-0.0.22}/codemap/integrations/base.py +0 -0
  41. {codmap-0.0.21 → codmap-0.0.22}/codemap/integrations/cocoindex.py +0 -0
  42. {codmap-0.0.21 → codmap-0.0.22}/codemap/integrations/gate.py +0 -0
  43. {codmap-0.0.21 → codmap-0.0.22}/codemap/integrations/gitnexus.py +0 -0
  44. {codmap-0.0.21 → codmap-0.0.22}/codemap/integrations/registry.py +0 -0
  45. {codmap-0.0.21 → codmap-0.0.22}/codemap/integrations/transport.py +0 -0
  46. {codmap-0.0.21 → codmap-0.0.22}/codemap/scope.py +0 -0
  47. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/__init__.py +0 -0
  48. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/_scip_pb2.py +0 -0
  49. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/api_surface.py +0 -0
  50. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/architecture.py +0 -0
  51. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/check.py +0 -0
  52. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/ctags.py +0 -0
  53. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/impact.py +0 -0
  54. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/limits.py +0 -0
  55. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/livingdocs.py +0 -0
  56. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/mermaid.py +0 -0
  57. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/rag.py +0 -0
  58. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/review.py +0 -0
  59. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/scip.py +0 -0
  60. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/semantic.py +0 -0
  61. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/server.py +0 -0
  62. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/subsystems.py +0 -0
  63. {codmap-0.0.21 → codmap-0.0.22}/codemap/serve/vault.py +0 -0
  64. {codmap-0.0.21 → codmap-0.0.22}/codemap/store.py +0 -0
  65. {codmap-0.0.21 → codmap-0.0.22}/codemap/tomlio.py +0 -0
  66. {codmap-0.0.21 → codmap-0.0.22}/codemap/watch.py +0 -0
  67. {codmap-0.0.21 → codmap-0.0.22}/codmap.egg-info/dependency_links.txt +0 -0
  68. {codmap-0.0.21 → codmap-0.0.22}/codmap.egg-info/entry_points.txt +0 -0
  69. {codmap-0.0.21 → codmap-0.0.22}/codmap.egg-info/requires.txt +0 -0
  70. {codmap-0.0.21 → codmap-0.0.22}/codmap.egg-info/top_level.txt +0 -0
  71. {codmap-0.0.21 → codmap-0.0.22}/setup.cfg +0 -0
  72. {codmap-0.0.21 → codmap-0.0.22}/tests/test_epistemic.py +0 -0
  73. {codmap-0.0.21 → codmap-0.0.22}/tests/test_gitnexus_router.py +0 -0
  74. {codmap-0.0.21 → codmap-0.0.22}/tests/test_impact_depth.py +0 -0
  75. {codmap-0.0.21 → codmap-0.0.22}/tests/test_integrations.py +0 -0
  76. {codmap-0.0.21 → codmap-0.0.22}/tests/test_issue3_serve_freshness.py +0 -0
  77. {codmap-0.0.21 → codmap-0.0.22}/tests/test_livingdocs.py +0 -0
  78. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m0_api_surface.py +0 -0
  79. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m11_argcontract.py +0 -0
  80. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m12_dataflow.py +0 -0
  81. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m15_review.py +0 -0
  82. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m16_architecture.py +0 -0
  83. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m17_mcp.py +0 -0
  84. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m18_freshness.py +0 -0
  85. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m1_5_semantics.py +0 -0
  86. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m1_query.py +0 -0
  87. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m2_views.py +0 -0
  88. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m32_watch.py +0 -0
  89. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m3_serve.py +0 -0
  90. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m4_behavior.py +0 -0
  91. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m5_deep.py +0 -0
  92. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m6_repo_scope.py +0 -0
  93. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m7_dispatch.py +0 -0
  94. {codmap-0.0.21 → codmap-0.0.22}/tests/test_m9_family.py +0 -0
  95. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c13_callgraph_accuracy.py +0 -0
  96. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c13_grep_vs_graph.py +0 -0
  97. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c16_semantic.py +0 -0
  98. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c20_attribute_edges.py +0 -0
  99. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c21_flat_layout.py +0 -0
  100. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c22_source_visible_refs.py +0 -0
  101. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c23_hard_python.py +0 -0
  102. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c24_test_mapping.py +0 -0
  103. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c25_provenance.py +0 -0
  104. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c26_deep_union.py +0 -0
  105. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c27_config_honesty.py +0 -0
  106. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c28_limit_envelope.py +0 -0
  107. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c29_lazy_imports.py +0 -0
  108. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c2_ctags.py +0 -0
  109. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c30_local_import_calls.py +0 -0
  110. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c30f1_reexport_calls.py +0 -0
  111. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c30f2_check_scope.py +0 -0
  112. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c31_path_origin.py +0 -0
  113. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c32_report_json.py +0 -0
  114. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c33_query_signature.py +0 -0
  115. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c34_signature_kinds.py +0 -0
  116. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c35_contract_location.py +0 -0
  117. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c36_target_resolution.py +0 -0
  118. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c37_rule_mutation.py +0 -0
  119. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c38_tool_drift.py +0 -0
  120. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c39_edge_resolution.py +0 -0
  121. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c3_arch_contract.py +0 -0
  122. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c3_dogfood.py +0 -0
  123. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c40_flow_reach.py +0 -0
  124. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c41_scope_membership.py +0 -0
  125. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c42_deep_tier_stability.py +0 -0
  126. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c43_incremental_splice.py +0 -0
  127. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c44_absent_answers.py +0 -0
  128. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c45_repeat_union.py +0 -0
  129. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c46_shadowed_definitions.py +0 -0
  130. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c47_incremental_resample.py +0 -0
  131. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c48_type_checking_imports.py +0 -0
  132. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c49_type_only_cycles.py +0 -0
  133. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c4_complexity.py +0 -0
  134. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c50_flow_entry_points.py +0 -0
  135. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c53_narrowing_declared.py +0 -0
  136. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c54_deterministic_rendering.py +0 -0
  137. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c55_override_grade.py +0 -0
  138. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c56_stub_files.py +0 -0
  139. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c57_facade_package.py +0 -0
  140. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c58_cycle_tangles.py +0 -0
  141. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c5_apidiff.py +0 -0
  142. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c62_phantom_rules.py +0 -0
  143. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c6_pack.py +0 -0
  144. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c7_edge_vocab.py +0 -0
  145. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c8_deadcode.py +0 -0
  146. {codmap-0.0.21 → codmap-0.0.22}/tests/test_r1c9_incremental.py +0 -0
  147. {codmap-0.0.21 → codmap-0.0.22}/tests/test_scip_export.py +0 -0
  148. {codmap-0.0.21 → codmap-0.0.22}/tests/test_scope.py +0 -0
  149. {codmap-0.0.21 → codmap-0.0.22}/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.22
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, **1054 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 &
@@ -221,8 +221,14 @@ codemap serve --graph graph.json --watch
221
221
  - **Dataflow** — producers/consumers of a string-keyed DataFrame column.
222
222
  - **Semantic search** (opt-in) — a concept query routed to an external adapter, with each fuzzy hit
223
223
  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).
224
+ - **Context pack** — the highest-ranked modules, classes and functions under a token budget, by
225
+ importance or by relevance to seed symbols (`codemap pack --budget N [--seed X]`). With a seed it
226
+ walks both ways — what the seed uses and what uses it (`--direction uses` for one way) — documents are
227
+ never in it, the answer says which walk it took, and a seed that resolves to nothing is refused rather
228
+ than silently answered globally. Deterministic and budget-tested. Against git history, seeded with a
229
+ changed file, it puts 42 % and 60 % of the co-changed files in its first ten on two repositories —
230
+ ahead of `impact` and of grep by names, though on one of the two not beyond the noise; grep still
231
+ finds tests best. **Whether it helps an agent is not measured.** See [docs/pack.md](docs/pack.md).
226
232
  - **Interop** — export the graph as a [SCIP](https://scip-code.org/) index (definitions + symbol
227
233
  info + inherits/implements relationships) so Sourcegraph, Glean and other SCIP consumers can drive
228
234
  go-to-definition, symbol search and type hierarchy over it. See [docs/export.md](docs/export.md).
@@ -266,6 +272,13 @@ build says so in its own diagnostics, and `codemap diff` says so when both sides
266
272
  delta of a few call edges as possible tool noise before reading it as a change in the code. Anything
267
273
  that must be reproducible byte-for-byte — a gate, a two-release comparison — belongs on the fast tier.
268
274
 
275
+ **A deep build does not check jedi.** With jedi answering nothing, a deep build still exits 0 and
276
+ declares `tier: deep` — and its call edges equal the fast tier's. `codemap doctor` asks griffe and jedi
277
+ one question each with a known answer and exits 1 when one is absent or answers wrongly
278
+ ([accuracy.md](docs/accuracy.md#what-this-means-for-trust)); rebuilding over the previous graph of the
279
+ same tree (`build -o` on the existing file) names the classes of edges that shrank
280
+ ([provenance.md](docs/provenance.md#fewer-edges-on-the-same-input-is-named)).
281
+
269
282
  **How many times to build.** A consumer measured 175 full deep builds of one tree and found a real
270
283
  edge present in **75 %** of them; eight builds here agreed (5 of 8, the same edge, and nothing else
271
284
  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, **1054 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 &
@@ -185,8 +185,14 @@ codemap serve --graph graph.json --watch
185
185
  - **Dataflow** — producers/consumers of a string-keyed DataFrame column.
186
186
  - **Semantic search** (opt-in) — a concept query routed to an external adapter, with each fuzzy hit
187
187
  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).
188
+ - **Context pack** — the highest-ranked modules, classes and functions under a token budget, by
189
+ importance or by relevance to seed symbols (`codemap pack --budget N [--seed X]`). With a seed it
190
+ walks both ways — what the seed uses and what uses it (`--direction uses` for one way) — documents are
191
+ never in it, the answer says which walk it took, and a seed that resolves to nothing is refused rather
192
+ than silently answered globally. Deterministic and budget-tested. Against git history, seeded with a
193
+ changed file, it puts 42 % and 60 % of the co-changed files in its first ten on two repositories —
194
+ ahead of `impact` and of grep by names, though on one of the two not beyond the noise; grep still
195
+ finds tests best. **Whether it helps an agent is not measured.** See [docs/pack.md](docs/pack.md).
190
196
  - **Interop** — export the graph as a [SCIP](https://scip-code.org/) index (definitions + symbol
191
197
  info + inherits/implements relationships) so Sourcegraph, Glean and other SCIP consumers can drive
192
198
  go-to-definition, symbol search and type hierarchy over it. See [docs/export.md](docs/export.md).
@@ -230,6 +236,13 @@ build says so in its own diagnostics, and `codemap diff` says so when both sides
230
236
  delta of a few call edges as possible tool noise before reading it as a change in the code. Anything
231
237
  that must be reproducible byte-for-byte — a gate, a two-release comparison — belongs on the fast tier.
232
238
 
239
+ **A deep build does not check jedi.** With jedi answering nothing, a deep build still exits 0 and
240
+ declares `tier: deep` — and its call edges equal the fast tier's. `codemap doctor` asks griffe and jedi
241
+ one question each with a known answer and exits 1 when one is absent or answers wrongly
242
+ ([accuracy.md](docs/accuracy.md#what-this-means-for-trust)); rebuilding over the previous graph of the
243
+ same tree (`build -o` on the existing file) names the classes of edges that shrank
244
+ ([provenance.md](docs/provenance.md#fewer-edges-on-the-same-input-is-named)).
245
+
233
246
  **How many times to build.** A consumer measured 175 full deep builds of one tree and found a real
234
247
  edge present in **75 %** of them; eight builds here agreed (5 of 8, the same edge, and nothing else
235
248
  unstable among 13 675). So one build misses such an edge about 1 time in 4, two builds 1 in 16, three
@@ -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,21 @@ 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
753
808
 
754
809
 
755
810
  def _cmd_check(args) -> int:
@@ -795,7 +850,7 @@ def _cmd_diff(args) -> int:
795
850
  # R1-C25/D4: two graphs built by different tools are a before/after of the *tool*,
796
851
  # not of the code. Never a refusal — comparing across an upgrade is legitimate; what
797
852
  # was missing is being told, since a clean "no breaking changes" reads as proof.
798
- cmp = comparability(old.provenance, new.provenance)
853
+ cmp = comparability(old.provenance, new.provenance, old_graph=old, new_graph=new)
799
854
  if not cmp["comparable"]:
800
855
  for line in cmp["differences"]:
801
856
  print(f"[warning] {line}", file=sys.stderr)
@@ -947,6 +1002,11 @@ def build_parser() -> argparse.ArgumentParser:
947
1002
  "of the chain: a different N restarts it from a full build.")
948
1003
  b.set_defaults(func=_cmd_build)
949
1004
 
1005
+ dr = sub.add_parser("doctor", help="Probe griffe and jedi with a question whose answer is "
1006
+ "known; exit 1 if one is absent or answers wrongly.")
1007
+ dr.add_argument("--json", action="store_true", help="Structured report instead of text.")
1008
+ dr.set_defaults(func=_cmd_doctor)
1009
+
950
1010
  sc = sub.add_parser("scope", help="Resolve the input scope manifest (scope_id + profile), or --diff two.")
951
1011
  sc.add_argument("path", nargs="?", help="Package/dir to scope (like build's path).")
952
1012
  sc.add_argument("--consumer", action="append", metavar="PATH",
@@ -1094,6 +1154,9 @@ def build_parser() -> argparse.ArgumentParser:
1094
1154
  pk.add_argument("--seed", action="append", metavar="X",
1095
1155
  help="Bias relevance to this symbol / file (repeatable). "
1096
1156
  "Omit for global importance.")
1157
+ pk.add_argument("--direction", choices=["both", "uses"],
1158
+ help="Which way a seeded walk runs: both (default) — what the seeds "
1159
+ "use and what uses them; uses — only what they use.")
1097
1160
  pk.add_argument("--format", choices=["markdown", "json"], default="markdown")
1098
1161
  pk.set_defaults(func=_cmd_pack)
1099
1162
 
@@ -0,0 +1,152 @@
1
+ """Does each dependency still answer a known question correctly? (R1-C64)
2
+
3
+ griffe and jedi do work codemap never re-checks: griffe resolves re-export aliases into the
4
+ structure, jedi types a local variable into a call target. Both are hard dependencies, so the
5
+ only "check" they ever got was that ``import`` did not fail. That is the wrong check for the
6
+ failure that matters: a jedi that stopped resolving after an upgrade does not break a build —
7
+ ``_resolve_jedi`` reads any exception as ``unresolved`` — it turns every deep call into a
8
+ fast-tier one, and the graph is simply poorer. Measured: with ``jedi.Script`` answering
9
+ nothing, a deep build of a real 89-module tree exited 0 with ``calls`` equal to the fast tier's
10
+ to the edge, while still declaring ``tier: deep``.
11
+
12
+ The probe is the shape `jg doctor` uses (research/tools/jevgrep.md, S2): a synthetic input
13
+ whose correct answer is known, and a backend that answers it wrongly is refused rather than
14
+ counted as present. One fact per dependency, through the real pipeline — not a benchmark,
15
+ which would be noisy and get switched off.
16
+
17
+ Design: ``docs/design/dependency_probe.md``.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import importlib
23
+ import tempfile
24
+ from pathlib import Path
25
+
26
+ PROBE_PACKAGE = "codemap_probe"
27
+
28
+ #: The probe tree. ``engine.run()`` needs a local variable's type (jedi only — the fast tier
29
+ #: does not build that edge); ``ph()`` goes through a re-export alias whose target griffe
30
+ #: resolves (``Alias.target_path``), which is where the ``export`` edge comes from.
31
+ PROBE_FILES = {
32
+ "__init__.py": f"from {PROBE_PACKAGE}.core import helper as public_helper\n",
33
+ "core.py": ("class Engine:\n"
34
+ " def run(self):\n"
35
+ " return 1\n\n\n"
36
+ "def helper():\n"
37
+ " return 2\n"),
38
+ "app.py": (f"from {PROBE_PACKAGE} import public_helper as ph\n"
39
+ f"from {PROBE_PACKAGE}.core import Engine\n\n\n"
40
+ "def main():\n"
41
+ " engine = Engine()\n"
42
+ " engine.run()\n"
43
+ " return ph()\n"),
44
+ }
45
+
46
+ #: Outcomes. ``wrong`` is the case this module exists for: present, answering, mistaken.
47
+ OK, WRONG, ABSENT, ERROR, NOT_RUN = "ok", "wrong", "absent", "error", "not-run"
48
+
49
+ #: dependency → (edge type, source, target, resolution or None, what the fact means)
50
+ EXPECTED = {
51
+ "griffe": ("export", PROBE_PACKAGE, f"{PROBE_PACKAGE}.core.helper", None,
52
+ f"the re-export `{PROBE_PACKAGE}.public_helper` resolves to "
53
+ f"`{PROBE_PACKAGE}.core.helper`"),
54
+ "jedi": ("calls", f"{PROBE_PACKAGE}.app.main", f"{PROBE_PACKAGE}.core.Engine.run", "deep",
55
+ f"`engine.run()` on a local `Engine()` resolves to "
56
+ f"`{PROBE_PACKAGE}.core.Engine.run`"),
57
+ }
58
+
59
+
60
+ def write_probe(directory: str | Path) -> Path:
61
+ """Write the probe package under ``directory`` and return its path."""
62
+ pkg = Path(directory) / PROBE_PACKAGE
63
+ pkg.mkdir(parents=True, exist_ok=True)
64
+ for name, body in PROBE_FILES.items():
65
+ (pkg / name).write_text(body, encoding="utf-8")
66
+ return pkg
67
+
68
+
69
+ def _version(name: str) -> str | None:
70
+ from importlib.metadata import PackageNotFoundError, version
71
+ try:
72
+ return version(name)
73
+ except PackageNotFoundError:
74
+ return None
75
+
76
+
77
+ def holds(graph, dependency: str) -> bool:
78
+ """Is the dependency's fact an edge of ``graph``?"""
79
+ etype, source, target, resolution, _ = EXPECTED[dependency]
80
+ return any(e.type == etype and e.source == source and e.target == target
81
+ and (resolution is None or e.extras.get("resolution") == resolution)
82
+ for e in graph.edges)
83
+
84
+
85
+ def _expected_str(dependency: str) -> str:
86
+ etype, source, target, resolution, _ = EXPECTED[dependency]
87
+ return f"{etype} {source} → {target}" + (f" ({resolution})" if resolution else "")
88
+
89
+
90
+ def _row(dependency: str, status: str, detail: str) -> dict:
91
+ return {"dependency": dependency, "version": _version(dependency), "status": status,
92
+ "fact": EXPECTED[dependency][4], "expected": _expected_str(dependency),
93
+ "detail": detail}
94
+
95
+
96
+ def _importable(name: str) -> str | None:
97
+ """``None`` when ``name`` imports; otherwise why it does not."""
98
+ try:
99
+ importlib.import_module(name)
100
+ except Exception as exc: # ImportError, or a broken install raising anything else
101
+ return f"not importable ({type(exc).__name__}: {exc})"
102
+ return None
103
+
104
+
105
+ def _check(dependency: str, build) -> tuple[dict, bool]:
106
+ """Run one probe. Returns the row and whether its build produced a graph at all."""
107
+ why = _importable(dependency)
108
+ if why:
109
+ return _row(dependency, ABSENT, why), False
110
+ try:
111
+ graph = build()
112
+ except Exception as exc:
113
+ return _row(dependency, ERROR, f"the probe build raised {type(exc).__name__}: {exc}"), False
114
+ if holds(graph, dependency):
115
+ return _row(dependency, OK, "answered as expected"), True
116
+ return _row(dependency, WRONG, f"expected edge `{_expected_str(dependency)}` is not in "
117
+ "the graph — the dependency imports and answers, and the "
118
+ "answer is wrong"), True
119
+
120
+
121
+ def run_probes() -> dict:
122
+ """Build the probe twice (fast for griffe, deep for jedi) and grade each dependency.
123
+
124
+ jedi's probe stands on griffe's structure: when the fast build already failed, a deep
125
+ result would mean nothing, so jedi is ``not-run`` — unknown, not wrong.
126
+ """
127
+ from codemap.extract import extract
128
+ with tempfile.TemporaryDirectory(prefix="codemap-probe-") as tmp:
129
+ pkg = write_probe(tmp)
130
+ griffe_row, built = _check("griffe", lambda: extract(str(pkg)))
131
+ if not built:
132
+ jedi_row = _row("jedi", NOT_RUN, "not run: the structural pass it stands on "
133
+ f"did not produce a graph ({griffe_row['status']})")
134
+ else:
135
+ jedi_row, _ = _check("jedi", lambda: extract(str(pkg), deep=True))
136
+ rows = [griffe_row, jedi_row]
137
+ return {"ok": all(r["status"] == OK for r in rows), "probes": rows}
138
+
139
+
140
+ def render(report: dict) -> str:
141
+ """Plain text, one line per dependency, then a verdict line."""
142
+ lines = ["codemap doctor — does each dependency still answer a known question correctly?", ""]
143
+ for r in report["probes"]:
144
+ name = f"{r['dependency']} {r['version'] or '(version unknown)'}"
145
+ lines.append(f"{name:<22} {r['status'].upper():<8} {r['fact']}")
146
+ if r["status"] != OK:
147
+ lines.append(f"{'':<22} {'':<8} {r['detail']}")
148
+ lines.append("")
149
+ lines.append("✅ every probe answered as expected." if report["ok"] else
150
+ "❌ at least one dependency is absent or answers wrongly — a graph built "
151
+ "here may be poorer than its tier declares.")
152
+ return "\n".join(lines) + "\n"
@@ -93,7 +93,17 @@ SCHEMA_VERSION = "0.13"
93
93
  #
94
94
  # Starts at 1 with 0.0.21 and makes no claim about earlier releases: the answer format changed
95
95
  # many times before it, and numbering that retroactively would be inventing a history.
96
- ANSWER_FORMAT = 1
96
+ #
97
+ # History:
98
+ # 1 — 0.0.21: first numbered format.
99
+ # 2 — pack honesty: `pack` carries `seeds`, `unreachable_from_seeds` and `coverage`
100
+ # blocks, and a seed set that resolves to nothing is a refusal instead of the
101
+ # global pack (docs/design/pack_honesty.md). Before 2 was released, a seeded
102
+ # pack also began walking both ways by default — what the seeds use and what
103
+ # uses them — with `coverage.walk` naming the walk (R1-C66,
104
+ # docs/design/pack_direction.md). Not a separate number: no release ever
105
+ # answered 2 without it.
106
+ ANSWER_FORMAT = 2
97
107
 
98
108
  # Closed vocabulary of edge types (R1-C7). Node ``kind`` is deliberately an OPEN set
99
109
  # (DESIGN §2 — new entity kinds may appear), but edges are TYPED: every relationship
@@ -269,13 +269,79 @@ def _samples_str(prov: dict) -> str:
269
269
  return f"{runs} sample{'s' if runs != 1 else ''}"
270
270
 
271
271
 
272
- def comparability(old: dict | None, new: dict | None) -> dict:
272
+ # -- completeness of two builds of one input (R1-C59) -------------------------
273
+
274
+ #: A class of edges has *dropped* when it lost at least this many edges **and** at least
275
+ #: this share of its previous count. Above the measured noise, not tuned to a case: the
276
+ #: fast tier is byte-stable (any drop there is another builder), and the deep tier's
277
+ #: sampling noise was measured at single edges — ten builds of one tree differed by no
278
+ #: edge at all, a larger tree lost one real edge in 9524 once in seven builds (R1-C42).
279
+ #: The share keeps a large graph from flagging a handful of edges out of tens of thousands.
280
+ COMPLETENESS_MIN_EDGES = 3
281
+ COMPLETENESS_MIN_SHARE = 0.01
282
+
283
+
284
+ def same_input(old: dict | None, new: dict | None) -> bool:
285
+ """Do two provenance blocks describe one input, read on one tier? (R1-C59 / D2)
286
+
287
+ Needs a recorded ``scope_id`` on both sides: a graph without one (pre-0.12, or built
288
+ by ``extract()`` without a manifest) has an *unknown* input, which is not an equal one.
289
+ """
290
+ if not old or not new or not old.get("scope_id"):
291
+ return False
292
+ return (old.get("scope_id") == new.get("scope_id")
293
+ and old.get("tier") == new.get("tier")
294
+ and old.get("roots") == new.get("roots"))
295
+
296
+
297
+ def edge_counts(graph) -> dict[str, int]:
298
+ """Edges per type — derived from the graph, never stored beside it (D1)."""
299
+ counts: dict[str, int] = {}
300
+ for e in graph.edges:
301
+ counts[e.type] = counts.get(e.type, 0) + 1
302
+ return counts
303
+
304
+
305
+ def completeness_drops(old_graph, new_graph) -> list[dict] | None:
306
+ """Edge classes the newer build of the **same input** holds fewer of (R1-C59).
307
+
308
+ ``None`` when the pair is not comparable this way — another input, another tier, or an
309
+ input nobody recorded; that is "not compared", and it must stay distinguishable from
310
+ ``[]``, "compared, nothing fell" (R1-C44). Measured on one frozen tree: codmap 0.0.3
311
+ and the current code read the same ``scope_id`` and differed by −25 ``imports``,
312
+ −28 ``references`` and −26 ``calls``, and nothing said so — ``diff`` named the other
313
+ tool, never what it had lost.
314
+ """
315
+ if not same_input(old_graph.provenance, new_graph.provenance):
316
+ return None
317
+ before, after = edge_counts(old_graph), edge_counts(new_graph)
318
+ drops = []
319
+ for etype in sorted(before):
320
+ lost = before[etype] - after.get(etype, 0)
321
+ if lost >= COMPLETENESS_MIN_EDGES and lost >= COMPLETENESS_MIN_SHARE * before[etype]:
322
+ drops.append({"type": etype, "old": before[etype], "new": after.get(etype, 0)})
323
+ return drops
324
+
325
+
326
+ def describe_drops(drops: list[dict]) -> str:
327
+ """``completeness fell on the same input: calls 899 → 873 (−2.9 %), …``"""
328
+ parts = [f"{d['type']} {d['old']} → {d['new']} "
329
+ f"(−{(d['old'] - d['new']) * 100 / d['old']:.1f} %)" for d in drops]
330
+ return "completeness fell on the same input: " + ", ".join(parts)
331
+
332
+
333
+ def comparability(old: dict | None, new: dict | None, *,
334
+ old_graph=None, new_graph=None) -> dict:
273
335
  """Are two graphs a before/after of the *code*, or of the *tool*? (D4)
274
336
 
275
337
  Never a refusal — comparing across an upgrade is a legitimate thing to want. What
276
338
  was missing is being told: on the R1-C25 evidence pair, ``diff`` said "no breaking
277
339
  changes" (true, and useless) about two graphs that disagreed on which functions
278
340
  were dead.
341
+
342
+ With both graphs passed, a completeness drop on the same input is one more
343
+ difference (R1-C59): on one input, everything the two graphs disagree on came from
344
+ the builder, and a class that shrank is the part a reader most needs named.
279
345
  """
280
346
  differences: list[str] = []
281
347
  caveats: list[str] = []
@@ -304,6 +370,10 @@ def comparability(old: dict | None, new: dict | None) -> dict:
304
370
  "builds of an unchanged tree can differ by a few jedi-resolved "
305
371
  "edges, so read a small delta as possible tool noise "
306
372
  f"(old: {_samples_str(old)}, new: {_samples_str(new)})")
373
+ if old_graph is not None and new_graph is not None:
374
+ drops = completeness_drops(old_graph, new_graph)
375
+ if drops:
376
+ differences.append(describe_drops(drops))
307
377
  return {
308
378
  "comparable": not differences,
309
379
  "differences": differences,
@@ -74,6 +74,23 @@ _IMPACT_EDGES = ("calls", "references", "inherits", "imports", "decorated_by",
74
74
  # is excluded on purpose (structural, would just inflate big modules).
75
75
  _RANK_EDGE_TYPES = ("calls", "imports", "references", "inherits", "implements")
76
76
 
77
+ # Node kinds `rank` walks (R1-C6). Everything else — documents, attributes, columns — can
78
+ # never be ranked, so never enters a pack; `pack` declares that rather than implying a
79
+ # "most relevant slice" of the whole graph (pack honesty).
80
+ RANKED_KINDS = ("module", "class", "function")
81
+
82
+ #: Which way a *seeded* rank walks (R1-C66). ``uses``: from the seeds to what they use —
83
+ #: their callers are never reached. ``both``: that, and everything that uses the seeds.
84
+ #: The default for a seeded `pack` is ``both``, decided by a pre-registered rule
85
+ #: (research/pack_direction.md §5); ``uses`` stays available for "what does this need".
86
+ SEED_DIRECTIONS = ("uses", "both")
87
+
88
+
89
+ def _check_direction(direction: str) -> None:
90
+ if direction not in SEED_DIRECTIONS:
91
+ raise ValueError(f"unknown direction {direction!r}; expected one of "
92
+ f"{', '.join(SEED_DIRECTIONS)}")
93
+
77
94
  # Test-mapping walk (R1-C24). Execution and naming relations only: `imports` is
78
95
  # module→module and would spread the answer across the package without saying anything
79
96
  # about what a *test* runs; `inherits` is kept because exercising a subclass does exercise
@@ -1063,21 +1080,87 @@ class Query:
1063
1080
 
1064
1081
  # -- relevance ranking (R1-C6) -------------------------------------------
1065
1082
 
1083
+ def _expand_seed(self, seed: str) -> tuple[str, set[str]]:
1084
+ """One seed → ``(via, node ids)``: how it was read and what it matched.
1085
+
1086
+ ``via`` is ``id`` | ``file`` | ``name`` | ``none``. The ids are *all* matches,
1087
+ rankable or not — whether they can bias a rank is the caller's question.
1088
+ """
1089
+ if seed in self.graph.nodes:
1090
+ return "id", {seed}
1091
+ in_file = {n.id for n in self.graph.nodes.values() if n.file == seed}
1092
+ if in_file:
1093
+ return "file", in_file
1094
+ by_name = {n.id for n in self.find(seed)} # short name → matches
1095
+ return ("name", by_name) if by_name else ("none", set())
1096
+
1066
1097
  def _expand_seeds(self, seeds) -> set[str]:
1067
1098
  """Map each seed (node id / short name / file path) to concrete node ids."""
1068
1099
  out: set[str] = set()
1069
- files = {n.file for n in self.graph.nodes.values() if n.file}
1070
1100
  for s in seeds:
1071
- if s in self.graph.nodes:
1072
- out.add(s)
1073
- elif s in files:
1074
- out.update(n.id for n in self.graph.nodes.values() if n.file == s)
1101
+ out |= self._expand_seed(s)[1]
1102
+ return out
1103
+
1104
+ def _rank_graph(self, edge_types=_RANK_EDGE_TYPES,
1105
+ root: str | None = None) -> "nx.DiGraph":
1106
+ """The graph `rank` walks: rankable kinds only, usage edges user→used."""
1107
+ g = nx.DiGraph()
1108
+ for nid, n in self.graph.nodes.items():
1109
+ if n.kind in RANKED_KINDS and (root is None or self.root_of(nid) == root):
1110
+ g.add_node(nid)
1111
+ ets = set(edge_types)
1112
+ for e in self.graph.edges:
1113
+ if e.type in ets and e.source in g and e.target in g:
1114
+ g.add_edge(e.source, e.target)
1115
+ return g
1116
+
1117
+ def seed_resolution(self, seeds, *, root: str | None = None) -> dict:
1118
+ """What each seed became — the answer to "did my seed count?" (pack honesty).
1119
+
1120
+ A seed that matched only nodes `rank` does not walk (a document, an attribute,
1121
+ a column) biases nothing, so it is **unresolved**, and ``unrankable`` says what
1122
+ it did match. Before this, all three shapes fell through to the global rank
1123
+ under a header naming the seed.
1124
+ """
1125
+ from collections import Counter
1126
+ g = self._rank_graph(root=root)
1127
+ per, unresolved, ids = [], [], set()
1128
+ for s in seeds:
1129
+ via, matched = self._expand_seed(s)
1130
+ rankable = sorted(m for m in matched if m in g)
1131
+ other = Counter(self.graph.nodes[m].kind for m in matched if m not in g)
1132
+ per.append({"input": s, "via": via, "matched": rankable,
1133
+ "ambiguous": via == "name" and len(rankable) > 1,
1134
+ "unrankable": dict(sorted(other.items()))})
1135
+ if rankable:
1136
+ ids.update(rankable)
1075
1137
  else:
1076
- out.update(n.id for n in self.find(s)) # short name → matches
1138
+ unresolved.append(s)
1139
+ return {"seeds": per, "unresolved": unresolved, "ids": sorted(ids)}
1140
+
1141
+ def reachable_from(self, ids, *, root: str | None = None,
1142
+ direction: str = "uses") -> set[str]:
1143
+ """Rankable nodes a seeded walk reaches from ``ids``.
1144
+
1145
+ ``direction`` (R1-C66): ``uses`` — along usage edges, what the seeds use;
1146
+ ``both`` — that, plus everything that uses the seeds, directly or transitively
1147
+ (ancestors ∪ descendants; a *sibling* that only shares a dependency is not
1148
+ reached). Exactly the set a seeded rank of that direction may surface. Decided by
1149
+ walking, never by ``rank == 0`` — ranks are rounded, and a far reachable node can
1150
+ round to zero.
1151
+ """
1152
+ _check_direction(direction)
1153
+ g = self._rank_graph(root=root)
1154
+ seeds = [i for i in ids if i in g]
1155
+ out = set(seeds)
1156
+ for i in seeds:
1157
+ out |= nx.descendants(g, i)
1158
+ if direction == "both":
1159
+ out |= nx.ancestors(g, i)
1077
1160
  return out
1078
1161
 
1079
1162
  def rank(self, *, seeds=(), edge_types=_RANK_EDGE_TYPES,
1080
- root: str | None = None) -> dict[str, float]:
1163
+ root: str | None = None, direction: str = "uses") -> dict[str, float]:
1081
1164
  """Importance rank over usage edges via PageRank (R1-C6).
1082
1165
 
1083
1166
  Without ``seeds``: global importance — heavily-depended-upon symbols (imports
@@ -1086,16 +1169,15 @@ class Query:
1086
1169
  context (aider's repo-map trick). ``root`` restricts to one provenance root.
1087
1170
  Deterministic: PageRank is a fixed power-iteration; scores rounded, ties broken
1088
1171
  by id at the call sites that order them.
1172
+
1173
+ ``direction`` applies only with seeds. ``both`` (R1-C66) walks the usage edges in
1174
+ both directions — the restart is still the seeds, so the rank is closeness to them
1175
+ whichever way the edge points. Measured against git history on two repositories
1176
+ (``research/pack_direction.md``): recall@10 of the co-changed files 0.19 → 0.42
1177
+ and 0.20 → 0.60.
1089
1178
  """
1090
- g = nx.DiGraph()
1091
- for nid, n in self.graph.nodes.items():
1092
- if n.kind in ("module", "class", "function") and (
1093
- root is None or self.root_of(nid) == root):
1094
- g.add_node(nid)
1095
- ets = set(edge_types)
1096
- for e in self.graph.edges:
1097
- if e.type in ets and e.source in g and e.target in g:
1098
- g.add_edge(e.source, e.target)
1179
+ _check_direction(direction)
1180
+ g = self._rank_graph(edge_types, root)
1099
1181
  if g.number_of_nodes() == 0:
1100
1182
  return {}
1101
1183
  personalization = None
@@ -1103,6 +1185,9 @@ class Query:
1103
1185
  seed_ids = {s for s in self._expand_seeds(seeds) if s in g}
1104
1186
  if seed_ids: # restart biased to seeds (normalized inside _pagerank)
1105
1187
  personalization = {n: (1.0 if n in seed_ids else 0.0) for n in g}
1188
+ if direction == "both":
1189
+ g = nx.DiGraph(g)
1190
+ g.add_edges_from([(b, a) for a, b in g.edges])
1106
1191
  pr = _pagerank(g, personalization)
1107
1192
  return {n: round(v, 8) for n, v in pr.items()}
1108
1193