diffctx 1.17.0__tar.gz → 1.18.1__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 (170) hide show
  1. {diffctx-1.17.0 → diffctx-1.18.1}/CHANGELOG.md +93 -1
  2. {diffctx-1.17.0 → diffctx-1.18.1}/Cargo.lock +1 -1
  3. {diffctx-1.17.0 → diffctx-1.18.1}/PKG-INFO +10 -7
  4. {diffctx-1.17.0 → diffctx-1.18.1}/README.md +6 -3
  5. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/Cargo.toml +2 -2
  6. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/README.md +6 -3
  7. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/discovery.rs +126 -0
  8. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/rust_lang.rs +13 -4
  9. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/effective_config.rs +2 -0
  10. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/graph.rs +33 -0
  11. diffctx-1.18.1/crates/diffctx-native/src/hook.rs +803 -0
  12. diffctx-1.18.1/crates/diffctx-native/src/impact.rs +937 -0
  13. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/in_memory_harness.rs +2 -1
  14. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/lib.rs +2 -0
  15. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/locate.rs +36 -21
  16. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/main.rs +101 -4
  17. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/pipeline.rs +99 -11
  18. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/pybridge.rs +65 -0
  19. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/render.rs +9 -1
  20. {diffctx-1.17.0 → diffctx-1.18.1}/pyproject.toml +3 -3
  21. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/_app.py +38 -4
  22. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/_diffctx.pyi +9 -0
  23. diffctx-1.18.1/src/diffctx/_native/__init__.py +7 -0
  24. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/_native/pipeline.py +24 -0
  25. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/cli.py +13 -2
  26. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/mcp/legacy.py +8 -10
  27. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/mcp/server.py +137 -35
  28. diffctx-1.18.1/src/diffctx/version.py +1 -0
  29. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/writer.py +29 -8
  30. diffctx-1.17.0/src/diffctx/_native/__init__.py +0 -7
  31. diffctx-1.17.0/src/diffctx/version.py +0 -1
  32. {diffctx-1.17.0 → diffctx-1.18.1}/Cargo.toml +0 -0
  33. {diffctx-1.17.0 → diffctx-1.18.1}/LICENSE +0 -0
  34. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/analytics.rs +0 -0
  35. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/candidate_files.rs +0 -0
  36. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/change_class.rs +0 -0
  37. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/analytics.rs +0 -0
  38. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/bm25.rs +0 -0
  39. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/budget.rs +0 -0
  40. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/category_weights.rs +0 -0
  41. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/edge_weights.rs +0 -0
  42. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/env_overrides.rs +0 -0
  43. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/extensions.rs +0 -0
  44. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/filtering.rs +0 -0
  45. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/fragmentation.rs +0 -0
  46. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/git.rs +0 -0
  47. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/graph_filtering.rs +0 -0
  48. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/importance.rs +0 -0
  49. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/limits.rs +0 -0
  50. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/mod.rs +0 -0
  51. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/mode.rs +0 -0
  52. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/needs.rs +0 -0
  53. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/parsers.rs +0 -0
  54. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/render.rs +0 -0
  55. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/scoring.rs +0 -0
  56. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/selection.rs +0 -0
  57. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/tokenization.rs +0 -0
  58. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/config/weights.rs +0 -0
  59. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/core.rs +0 -0
  60. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/base.rs +0 -0
  61. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/config_edges/build_system.rs +0 -0
  62. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/config_edges/cicd.rs +0 -0
  63. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/config_edges/docker.rs +0 -0
  64. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/config_edges/generic.rs +0 -0
  65. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/config_edges/helm.rs +0 -0
  66. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/config_edges/kubernetes.rs +0 -0
  67. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/config_edges/mod.rs +0 -0
  68. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/document/mod.rs +0 -0
  69. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/history/cochange.rs +0 -0
  70. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/history/mod.rs +0 -0
  71. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/mod.rs +0 -0
  72. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/ansible.rs +0 -0
  73. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/bazel.rs +0 -0
  74. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/c_family.rs +0 -0
  75. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/cargo_edges.rs +0 -0
  76. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/clojure.rs +0 -0
  77. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/css.rs +0 -0
  78. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/dart.rs +0 -0
  79. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/dbt.rs +0 -0
  80. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/dotnet.rs +0 -0
  81. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/elixir.rs +0 -0
  82. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/erlang.rs +0 -0
  83. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/go.rs +0 -0
  84. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/graphql.rs +0 -0
  85. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/haskell.rs +0 -0
  86. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/javascript.rs +0 -0
  87. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/julia.rs +0 -0
  88. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/jvm.rs +0 -0
  89. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/latex.rs +0 -0
  90. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/lua.rs +0 -0
  91. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/mod.rs +0 -0
  92. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/nim.rs +0 -0
  93. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/nix.rs +0 -0
  94. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/ocaml.rs +0 -0
  95. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/openapi.rs +0 -0
  96. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/perl.rs +0 -0
  97. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/php.rs +0 -0
  98. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/prisma.rs +0 -0
  99. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/protobuf.rs +0 -0
  100. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/python.rs +0 -0
  101. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/r_lang.rs +0 -0
  102. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/ruby.rs +0 -0
  103. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/shell.rs +0 -0
  104. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/sql.rs +0 -0
  105. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/swift.rs +0 -0
  106. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/tags.rs +0 -0
  107. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/terraform.rs +0 -0
  108. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/semantic/zig.rs +0 -0
  109. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/similarity/lexical.rs +0 -0
  110. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/similarity/mod.rs +0 -0
  111. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/structural/containment.rs +0 -0
  112. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/structural/mod.rs +0 -0
  113. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/structural/sibling.rs +0 -0
  114. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/edges/structural/testing.rs +0 -0
  115. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/excerpt.rs +0 -0
  116. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/facts/mod.rs +0 -0
  117. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/facts/scala.rs +0 -0
  118. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/filtering.rs +0 -0
  119. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/fragmentation.rs +0 -0
  120. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/git.rs +0 -0
  121. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/graph_export.rs +0 -0
  122. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/interval.rs +0 -0
  123. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/languages.rs +0 -0
  124. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/mode.rs +0 -0
  125. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/parsers/config_parser.rs +0 -0
  126. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/parsers/generic.rs +0 -0
  127. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/parsers/markdown.rs +0 -0
  128. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/parsers/mod.rs +0 -0
  129. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/parsers/tree_sitter_strategy.rs +0 -0
  130. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/paths.rs +0 -0
  131. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/peak_rss.rs +0 -0
  132. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/postpass.rs +0 -0
  133. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/ppr.rs +0 -0
  134. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/project_graph.rs +0 -0
  135. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/provenance.rs +0 -0
  136. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/resource.rs +0 -0
  137. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/run_provenance.rs +0 -0
  138. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/sanitize.rs +0 -0
  139. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/scoring.rs +0 -0
  140. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/select.rs +0 -0
  141. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/signatures.rs +0 -0
  142. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/stopwords/code.txt +0 -0
  143. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/stopwords/docs.txt +0 -0
  144. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/stopwords.rs +0 -0
  145. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/test_rng.rs +0 -0
  146. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/testfiles.rs +0 -0
  147. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/token_corpus.rs +0 -0
  148. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/tokenizer.rs +0 -0
  149. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/types.rs +0 -0
  150. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/utility/boltzmann.rs +0 -0
  151. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/utility/importance.rs +0 -0
  152. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/utility/mod.rs +0 -0
  153. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/utility/needs.rs +0 -0
  154. {diffctx-1.17.0 → diffctx-1.18.1}/crates/diffctx-native/src/utility/objective.rs +0 -0
  155. {diffctx-1.17.0 → diffctx-1.18.1}/rust-toolchain.toml +0 -0
  156. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/__init__.py +0 -0
  157. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/__main__.py +0 -0
  158. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/_native/graph_analytics.py +0 -0
  159. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/_native/graph_export.py +0 -0
  160. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/_native/project_graph.py +0 -0
  161. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/clipboard.py +0 -0
  162. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/ignore.py +0 -0
  163. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/logger.py +0 -0
  164. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/mcp/__init__.py +0 -0
  165. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/mcp/__main__.py +0 -0
  166. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/mcp/fetch.py +0 -0
  167. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/mcp/security.py +0 -0
  168. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/py.typed +0 -0
  169. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/tokens.py +0 -0
  170. {diffctx-1.17.0 → diffctx-1.18.1}/src/diffctx/tree.py +0 -0
@@ -7,10 +7,100 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.18.1] - 2026-09-30
11
+
12
+ ### Fixed
13
+
14
+ - **The plugin's impact hook never ran under macOS `/bin/bash` 3.2**: an empty
15
+ `gate=()` expanded under `set -u` is an unbound variable there, the pipeline
16
+ died right of the pipe and `|| true` hid it. One never-empty argument array.
17
+ - Hook command parsing: `git add … && git commit` reviews the working tree
18
+ (the add has not run when the hook sees the line — it reviewed the stale
19
+ index and was silent on the commonest agent pattern); `-m a -m b`, `-F file`
20
+ and other value flags no longer count as pathspecs; `git diff A B` and
21
+ `git diff main` are history, not the pending change; `cd dir && git …` and
22
+ a relative `-C` resolve against the agent's cwd.
23
+ - Impact: a test guards a caller only when it names the symbol *and* reaches
24
+ the caller's module (a Rust test mentioning `render` was the guard of a
25
+ Python `render()`, #312); a chunk caller named `main[88]` is looked up as
26
+ `main`; two-letter modules (`db.py`) keep their callers; `*out = f()` and
27
+ `#[derive]` lines are code, not comments; a wide changed-file list folds
28
+ into a count before a single caller is dropped, and a symbol keeps its
29
+ strongest caller; a run that hit a limit says `(partial: …)` and is never
30
+ `empty`; schema contracts are listed before public symbols with a `+N more`
31
+ count; SHA-256 repositories count commits.
32
+ - `diffctx.impact.v1` and `diffctx.locate.v1` required every field the
33
+ serializer may omit (`truncated`, `commit_count`, `deleted_files`, …), so no
34
+ real document validated; the schemas now require what is always present,
35
+ and a test validates real impact and locate output against them.
36
+ - MCP: the default `diff_ref` on a clean single-commit repository is the
37
+ working tree, not an error naming a range the caller never passed; the
38
+ `git status` guess runs off the event loop with a 10 s cap and never leaks
39
+ the command line into the response.
40
+ - CLI: `--mode impact -o out.yaml` is refused like `-f yaml` instead of
41
+ writing JSON silently; `--mode` help names the text default.
42
+ - Plugin installer: the download is unpacked beside its target so the final
43
+ `mv` is a rename, never a copy another session could execute half-written.
44
+ - MCP `mode=impact` with `paths` no longer marks the whole change reviewed.
45
+ - Landing page: the four-part iOS viewport contract (`viewport-fit=cover`,
46
+ status-bar style, safe-area insets, taller `html`).
47
+ - Privacy page names the release-binary download the plugin makes.
48
+ - `bitcheck.sh` ranges re-pinned on the rewritten history; the old anchors
49
+ kept the pre-rewrite tree, with third-party tokens in retired benchmark
50
+ diffs, reachable on both forges.
51
+ - Runtime image: one package layer (Sonar `docker:S7031`/`S6595`); PyJWT
52
+ 2.15.1 in `uv.lock` (GHSA-w6j9-cwv2-h6wq, transitive).
53
+
54
+ ## [1.18.0] - 2026-09-29
55
+
56
+ ### Added
57
+
58
+ - **`--mode impact`: what a change reaches outside its own diff, under 2k
59
+ tokens.** For each changed symbol, the callers in files the diff does not
60
+ touch, whether a test guards each one, and which symbols more than one
61
+ commit of the range edited; public API and schema changes are listed as
62
+ facts. It walks the graph the other modes already build, with a discovery
63
+ pass that follows references *into* the changed files (the other modes
64
+ only follow what the changed files import), and filters name-only links
65
+ the way a reader would: the caller must name the symbol in code and
66
+ reference its module. An empty impact is an answer, not an error. CLI
67
+ (`-f md` is the text form), MCP `mode="impact"`, the GitHub Action's
68
+ `mode` input, and `schemas/diffctx.impact.v1.json`.
69
+ - **`diffctx hook pretooluse` / `posttooluse`: the Claude Code plugin hands
70
+ the agent a change's impact without asking it to call anything.** Eight
71
+ agent sessions with the tool installed made zero unprompted calls (#289).
72
+ Before a commit, merge, cherry-pick, push or pull request the impact of
73
+ what that command records is injected as context — a plain `git commit`
74
+ reviews the index, `-a` the working tree, a merge the branch since its
75
+ merge base; after a `git diff` or `git status` the agent ran itself, the
76
+ pending change's impact rides on the answer it asked for. Once per change
77
+ (content-keyed with `git patch-id`, so a manual `--mode impact` run
78
+ counts), silent when nothing outside the diff depends on the change, and
79
+ never blocking: a range that runs out of the 30 s deadline is marked
80
+ reviewed rather than retried on every push. `impact_gate` (off by default)
81
+ denies a commit until its impact was shown once. At session start the
82
+ plugin fetches the release binary for the platform, verified against
83
+ `checksums.json`, into its data directory, backs off for six hours after a
84
+ failed download, and puts the one-line recipe into the session's context;
85
+ `impact_hook` turns all of it off (#310).
86
+ - **`/diffctx:commit`**: the impact first, then the commit.
87
+ - **`diffctx_context` is always loaded** under Claude Code's tool search
88
+ (`_meta.anthropic/alwaysLoad`), and its description names `impact` as the
89
+ pre-commit call; both skills default to the server's own range choice.
90
+ - **The PR review comment opens with the impact view.**
91
+ - **Plugin eval `breaking-caller`** measures the number under the goal: with
92
+ a contract change uncommitted and two callers about to break, does the
93
+ agent handle or name them before committing.
94
+
10
95
  ## [1.17.0] - 2026-09-23
11
96
 
12
97
  ### Added
13
98
 
99
+ - **The site lives at <https://diffctx.com>.** It was a 301 to
100
+ `nikolay-e.github.io/diffctx/`, so search engines indexed the github.io
101
+ URL and the product domain had no presence of its own. It is now the
102
+ GitHub Pages custom domain (github.io redirects to it), with a
103
+ `robots.txt` and `sitemap.xml`; package metadata points at it.
14
104
  - **Windows and Intel-Mac are release targets.** Every test matrix runs on
15
105
  Linux, macOS and Windows on x64 and ARM; wheels and binaries ship for
16
106
  `x86_64-apple-darwin` and `aarch64-pc-windows-msvc` as well, and `npm
@@ -1626,7 +1716,9 @@ Earlier releases shipped as `treemapper`; see
1626
1716
  <https://github.com/nikolay-e/diffctx/releases> for the corresponding GitHub
1627
1717
  release notes (`1.0.0` through `1.6.1`).
1628
1718
 
1629
- [Unreleased]: https://github.com/nikolay-e/diffctx/compare/v1.17.0...HEAD
1719
+ [Unreleased]: https://github.com/nikolay-e/diffctx/compare/v1.18.1...HEAD
1720
+ [1.18.1]: https://github.com/nikolay-e/diffctx/compare/v1.18.0...v1.18.1
1721
+ [1.18.0]: https://github.com/nikolay-e/diffctx/compare/v1.17.0...v1.18.0
1630
1722
  [1.17.0]: https://github.com/nikolay-e/diffctx/compare/v1.16.0...v1.17.0
1631
1723
  [1.16.0]: https://github.com/nikolay-e/diffctx/compare/v1.15.0...v1.16.0
1632
1724
  [1.15.0]: https://github.com/nikolay-e/diffctx/compare/v1.14.0...v1.15.0
@@ -194,7 +194,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
194
194
 
195
195
  [[package]]
196
196
  name = "diffctx"
197
- version = "1.17.0"
197
+ version = "1.18.1"
198
198
  dependencies = [
199
199
  "aho-corasick",
200
200
  "anyhow",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffctx
3
- Version: 1.17.0
3
+ Version: 1.18.1
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -44,15 +44,15 @@ Provides-Extra: mcp
44
44
  License-File: LICENSE
45
45
  Summary: Selects the minimum code an LLM needs to review a git diff
46
46
  Keywords: ai,chatgpt,claude,code-analysis,code-context,code-review,code-to-prompt,codebase,context,context-selection,diff-context,directory-tree,export,git-diff,json,llm,llm-context,mcp,model-context-protocol,tree,yaml
47
- Home-Page: https://github.com/nikolay-e/diffctx
47
+ Home-Page: https://diffctx.com/
48
48
  Author-email: Nikolay Eremeev <nikolay.eremeev@outlook.com>
49
49
  License-Expression: Apache-2.0
50
50
  Requires-Python: >=3.10
51
51
  Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
52
52
  Project-URL: Author, https://nikolay-eremeev.com
53
53
  Project-URL: Changelog, https://github.com/nikolay-e/diffctx/blob/main/CHANGELOG.md
54
- Project-URL: Documentation, https://nikolay-e.github.io/diffctx/
55
- Project-URL: Homepage, https://nikolay-e.github.io/diffctx/
54
+ Project-URL: Documentation, https://diffctx.com/
55
+ Project-URL: Homepage, https://diffctx.com/
56
56
  Project-URL: Issues, https://github.com/nikolay-e/diffctx/issues
57
57
  Project-URL: Paper, https://doi.org/10.5281/zenodo.18824579
58
58
  Project-URL: Repository, https://github.com/nikolay-e/diffctx
@@ -154,7 +154,7 @@ keeps its git meaning.
154
154
  | `--full` | false | Only the changed files, every fragment, no related-code context |
155
155
  | `--timeout` | 300 | Wall-clock deadline in seconds; on expiry the run stops cooperatively and emits a partial artifact whose `coverage` block names the limit (exit 0). 124 is the watchdog behind it, 30 s later, for a phase that could not stop |
156
156
  | `--with-raw-diff` | false | Also embed git's raw unified diff ahead of the selected fragments — additive (selection unchanged), not charged to `--budget`, lock/ignored/secret-like sections omitted. Python CLI only |
157
- | `--mode` | `pack` | `locate` emits the same ranked selection as compact `diffctx.locate.v1` JSON — path, lines, score, provenance reasons, a blast-radius `summary` and per-item impact `group` (`test`/`type`/`config`), NO source bodies. Adds a `coverage` block naming what the run could not see (`unparsed_files`, `zero_edge_files`, `ppr_truncated`, `next_up`, a heuristic `confidence`) and an `overflow` ranking of what the budget left behind — omitted entirely when there is nothing to disclose. `diffctx . --diff --mode locate` = impact of your uncommitted change. The MCP tool takes it as `mode="locate"` |
157
+ | `--mode` | `pack` | `locate` emits the same ranked selection as compact `diffctx.locate.v1` JSON — path, lines, score, provenance reasons, a blast-radius `summary` and per-item impact `group` (`test`/`type`/`config`), NO source bodies. Adds a `coverage` block naming what the run could not see (`unparsed_files`, `zero_edge_files`, `ppr_truncated`, `next_up`, a heuristic `confidence`) and an `overflow` ranking of what the budget left behind — omitted entirely when there is nothing to disclose. `impact` answers, under 2k tokens, what the change reaches outside its diff: callers in files the diff does not touch, whether a test guards each, cross-commit overlap, and the public API and schema changes (text by default, `diffctx.impact.v1` JSON with `-f json`). The MCP tool takes both as `mode` |
158
158
 
159
159
  Every JSON/YAML artifact opens with `schema: diffctx.context.v1` and validates
160
160
  against [`schemas/diffctx.context.v1.json`](schemas/diffctx.context.v1.json),
@@ -248,7 +248,10 @@ as `io.github.nikolay-e/diffctx`. One-line setup (zero-install via
248
248
  [uv](https://docs.astral.sh/uv/)):
249
249
 
250
250
  ```bash
251
- # Claude Code
251
+ # Claude Code plugin: the server plus /diffctx:diffctx and /diffctx:impact
252
+ claude plugin marketplace add nikolay-e/diffctx
253
+ claude plugin install diffctx@diffctx
254
+ # Claude Code, server only
252
255
  claude mcp add diffctx -- uvx --from 'diffctx[mcp]' diffctx-mcp
253
256
  # Codex CLI
254
257
  codex mcp add diffctx -- uvx --from 'diffctx[mcp]' diffctx-mcp
@@ -343,7 +346,7 @@ Apache 2.0
343
346
 
344
347
  ---
345
348
 
346
- - [Documentation site](https://nikolay-e.github.io/diffctx/) — the pipeline
349
+ - [Documentation site](https://diffctx.com/) — the pipeline
347
350
  end to end: diff → fragments → graph → relevance → selection
348
351
  - [Command-line reference](docs/product/cli.md) — every flag with its default
349
352
  and meaning, rendered from `diffctx --help`
@@ -95,7 +95,7 @@ keeps its git meaning.
95
95
  | `--full` | false | Only the changed files, every fragment, no related-code context |
96
96
  | `--timeout` | 300 | Wall-clock deadline in seconds; on expiry the run stops cooperatively and emits a partial artifact whose `coverage` block names the limit (exit 0). 124 is the watchdog behind it, 30 s later, for a phase that could not stop |
97
97
  | `--with-raw-diff` | false | Also embed git's raw unified diff ahead of the selected fragments — additive (selection unchanged), not charged to `--budget`, lock/ignored/secret-like sections omitted. Python CLI only |
98
- | `--mode` | `pack` | `locate` emits the same ranked selection as compact `diffctx.locate.v1` JSON — path, lines, score, provenance reasons, a blast-radius `summary` and per-item impact `group` (`test`/`type`/`config`), NO source bodies. Adds a `coverage` block naming what the run could not see (`unparsed_files`, `zero_edge_files`, `ppr_truncated`, `next_up`, a heuristic `confidence`) and an `overflow` ranking of what the budget left behind — omitted entirely when there is nothing to disclose. `diffctx . --diff --mode locate` = impact of your uncommitted change. The MCP tool takes it as `mode="locate"` |
98
+ | `--mode` | `pack` | `locate` emits the same ranked selection as compact `diffctx.locate.v1` JSON — path, lines, score, provenance reasons, a blast-radius `summary` and per-item impact `group` (`test`/`type`/`config`), NO source bodies. Adds a `coverage` block naming what the run could not see (`unparsed_files`, `zero_edge_files`, `ppr_truncated`, `next_up`, a heuristic `confidence`) and an `overflow` ranking of what the budget left behind — omitted entirely when there is nothing to disclose. `impact` answers, under 2k tokens, what the change reaches outside its diff: callers in files the diff does not touch, whether a test guards each, cross-commit overlap, and the public API and schema changes (text by default, `diffctx.impact.v1` JSON with `-f json`). The MCP tool takes both as `mode` |
99
99
 
100
100
  Every JSON/YAML artifact opens with `schema: diffctx.context.v1` and validates
101
101
  against [`schemas/diffctx.context.v1.json`](schemas/diffctx.context.v1.json),
@@ -189,7 +189,10 @@ as `io.github.nikolay-e/diffctx`. One-line setup (zero-install via
189
189
  [uv](https://docs.astral.sh/uv/)):
190
190
 
191
191
  ```bash
192
- # Claude Code
192
+ # Claude Code plugin: the server plus /diffctx:diffctx and /diffctx:impact
193
+ claude plugin marketplace add nikolay-e/diffctx
194
+ claude plugin install diffctx@diffctx
195
+ # Claude Code, server only
193
196
  claude mcp add diffctx -- uvx --from 'diffctx[mcp]' diffctx-mcp
194
197
  # Codex CLI
195
198
  codex mcp add diffctx -- uvx --from 'diffctx[mcp]' diffctx-mcp
@@ -284,7 +287,7 @@ Apache 2.0
284
287
 
285
288
  ---
286
289
 
287
- - [Documentation site](https://nikolay-e.github.io/diffctx/) — the pipeline
290
+ - [Documentation site](https://diffctx.com/) — the pipeline
288
291
  end to end: diff → fragments → graph → relevance → selection
289
292
  - [Command-line reference](docs/product/cli.md) — every flag with its default
290
293
  and meaning, rendered from `diffctx --help`
@@ -1,12 +1,12 @@
1
1
  [package]
2
2
  name = "diffctx"
3
- version = "1.17.0"
3
+ version = "1.18.1"
4
4
  edition = "2024"
5
5
  rust-version = "1.85"
6
6
  description = "Selects the minimum code an LLM needs to review a git diff: walks the dependency graph outward from changed lines and stops when extra context stops paying for itself"
7
7
  license = "Apache-2.0"
8
8
  repository = "https://github.com/nikolay-e/diffctx"
9
- homepage = "https://github.com/nikolay-e/diffctx"
9
+ homepage = "https://diffctx.com/"
10
10
  documentation = "https://docs.rs/diffctx"
11
11
  readme = "README.md"
12
12
  keywords = ["git", "diff", "llm", "context", "tree-sitter"]
@@ -95,7 +95,7 @@ keeps its git meaning.
95
95
  | `--full` | false | Only the changed files, every fragment, no related-code context |
96
96
  | `--timeout` | 300 | Wall-clock deadline in seconds; on expiry the run stops cooperatively and emits a partial artifact whose `coverage` block names the limit (exit 0). 124 is the watchdog behind it, 30 s later, for a phase that could not stop |
97
97
  | `--with-raw-diff` | false | Also embed git's raw unified diff ahead of the selected fragments — additive (selection unchanged), not charged to `--budget`, lock/ignored/secret-like sections omitted. Python CLI only |
98
- | `--mode` | `pack` | `locate` emits the same ranked selection as compact `diffctx.locate.v1` JSON — path, lines, score, provenance reasons, a blast-radius `summary` and per-item impact `group` (`test`/`type`/`config`), NO source bodies. Adds a `coverage` block naming what the run could not see (`unparsed_files`, `zero_edge_files`, `ppr_truncated`, `next_up`, a heuristic `confidence`) and an `overflow` ranking of what the budget left behind — omitted entirely when there is nothing to disclose. `diffctx . --diff --mode locate` = impact of your uncommitted change. The MCP tool takes it as `mode="locate"` |
98
+ | `--mode` | `pack` | `locate` emits the same ranked selection as compact `diffctx.locate.v1` JSON — path, lines, score, provenance reasons, a blast-radius `summary` and per-item impact `group` (`test`/`type`/`config`), NO source bodies. Adds a `coverage` block naming what the run could not see (`unparsed_files`, `zero_edge_files`, `ppr_truncated`, `next_up`, a heuristic `confidence`) and an `overflow` ranking of what the budget left behind — omitted entirely when there is nothing to disclose. `impact` answers, under 2k tokens, what the change reaches outside its diff: callers in files the diff does not touch, whether a test guards each, cross-commit overlap, and the public API and schema changes (text by default, `diffctx.impact.v1` JSON with `-f json`). The MCP tool takes both as `mode` |
99
99
 
100
100
  Every JSON/YAML artifact opens with `schema: diffctx.context.v1` and validates
101
101
  against [`schemas/diffctx.context.v1.json`](schemas/diffctx.context.v1.json),
@@ -189,7 +189,10 @@ as `io.github.nikolay-e/diffctx`. One-line setup (zero-install via
189
189
  [uv](https://docs.astral.sh/uv/)):
190
190
 
191
191
  ```bash
192
- # Claude Code
192
+ # Claude Code plugin: the server plus /diffctx:diffctx and /diffctx:impact
193
+ claude plugin marketplace add nikolay-e/diffctx
194
+ claude plugin install diffctx@diffctx
195
+ # Claude Code, server only
193
196
  claude mcp add diffctx -- uvx --from 'diffctx[mcp]' diffctx-mcp
194
197
  # Codex CLI
195
198
  codex mcp add diffctx -- uvx --from 'diffctx[mcp]' diffctx-mcp
@@ -284,7 +287,7 @@ Apache 2.0
284
287
 
285
288
  ---
286
289
 
287
- - [Documentation site](https://nikolay-e.github.io/diffctx/) — the pipeline
290
+ - [Documentation site](https://diffctx.com/) — the pipeline
288
291
  end to end: diff → fragments → graph → relevance → selection
289
292
  - [Command-line reference](docs/product/cli.md) — every flag with its default
290
293
  and meaning, rendered from `diffctx --help`
@@ -117,6 +117,132 @@ fn expand_by_rare_identifiers(ctx: &DiscoveryContext) -> Vec<PathBuf> {
117
117
  result
118
118
  }
119
119
 
120
+ /// Files that name what the change defines: the callers a change reaches
121
+ /// (#310). Forward discovery follows what the changed file imports; nothing
122
+ /// followed the other way, so a function with a hundred importers surfaced
123
+ /// one of them and the impact view called the rest silence. Only the
124
+ /// impact mode asks for this: widening the universe moves selection, and
125
+ /// the corpus operating point is calibrated without it.
126
+ pub struct ReverseReferenceDiscovery;
127
+
128
+ /// A name held by more candidate files than this is vocabulary, not a
129
+ /// reference, and following it would admit the repository.
130
+ const MAX_REVERSE_FILES_PER_NAME: usize = 64;
131
+ const MAX_REVERSE_FILES: usize = 256;
132
+ const MIN_REVERSE_NAME_LEN: usize = 4;
133
+
134
+ static DEFINITION_RE: OnceLock<regex::Regex> = OnceLock::new();
135
+
136
+ fn definition_names(content: &str) -> Vec<String> {
137
+ let re = DEFINITION_RE.get_or_init(|| {
138
+ regex::Regex::new(
139
+ r"(?m)^\s*(?:export\s+(?:default\s+)?|pub(?:\([^)]*\))?\s+|public\s+|static\s+|async\s+|abstract\s+|final\s+|data\s+|sealed\s+|open\s+)*(?:fn|def|class|struct|enum|interface|trait|type|function|const|let|var|val|object|impl|protocol|module|record)\s+([A-Za-z_][A-Za-z0-9_]*)",
140
+ )
141
+ .expect("definition regex")
142
+ });
143
+ re.captures_iter(content)
144
+ .filter_map(|c| c.get(1).map(|m| m.as_str().to_string()))
145
+ .filter(|n| n.len() >= MIN_REVERSE_NAME_LEN)
146
+ .collect()
147
+ }
148
+
149
+ impl DiscoveryStrategy for ReverseReferenceDiscovery {
150
+ fn name(&self) -> &'static str {
151
+ "reverse_reference"
152
+ }
153
+
154
+ fn discover(&self, ctx: &DiscoveryContext) -> Vec<PathBuf> {
155
+ let changed_set: FxHashSet<&Path> = ctx.changed_files.iter().map(|p| p.as_path()).collect();
156
+ // Per changed file: the module name a reference would import it by,
157
+ // and the names it defines. A file that carries both is a reference
158
+ // however common the name; one that carries the name alone counts
159
+ // only while the name is rare enough to mean this definition.
160
+ let mut per_file: Vec<(Option<String>, FxHashSet<String>)> = Vec::new();
161
+ for path in &ctx.changed_files {
162
+ let stem = path
163
+ .file_stem()
164
+ .and_then(|s| s.to_str())
165
+ .filter(|s| s.len() >= MIN_REVERSE_NAME_LEN)
166
+ .map(|s| s.to_lowercase());
167
+ let names: FxHashSet<String> = ctx
168
+ .read_file(path)
169
+ .map(|c| {
170
+ definition_names(&c)
171
+ .into_iter()
172
+ .map(|n| n.to_lowercase())
173
+ .collect()
174
+ })
175
+ .unwrap_or_default();
176
+ if stem.is_some() || !names.is_empty() {
177
+ per_file.push((stem, names));
178
+ }
179
+ }
180
+ if per_file.is_empty() {
181
+ return Vec::new();
182
+ }
183
+ let mut name_holders: FxHashMap<&str, usize> = FxHashMap::default();
184
+ let mut strong: Vec<&PathBuf> = Vec::new();
185
+ let mut weak: Vec<(&PathBuf, Vec<&str>)> = Vec::new();
186
+ for (path, doc) in &ctx.shared_corpus().docs {
187
+ if changed_set.contains(path.as_path()) {
188
+ continue;
189
+ }
190
+ let mut is_strong = false;
191
+ let mut named: Vec<&str> = Vec::new();
192
+ for (stem, names) in &per_file {
193
+ let has_stem = stem
194
+ .as_deref()
195
+ .is_some_and(|s| doc.term_counts.contains_key(s));
196
+ for name in names {
197
+ if doc.term_counts.contains_key(name) {
198
+ *name_holders.entry(name.as_str()).or_default() += 1;
199
+ named.push(name.as_str());
200
+ if has_stem {
201
+ is_strong = true;
202
+ }
203
+ }
204
+ }
205
+ }
206
+ if is_strong {
207
+ strong.push(path);
208
+ } else if !named.is_empty() {
209
+ weak.push((path, named));
210
+ }
211
+ }
212
+ strong.sort();
213
+ tracing::debug!(
214
+ "reverse references: {} definition sets, {} strong, {} weak, corpus {}",
215
+ per_file.len(),
216
+ strong.len(),
217
+ weak.len(),
218
+ ctx.shared_corpus().docs.len()
219
+ );
220
+ let mut result: Vec<PathBuf> = Vec::new();
221
+ let mut seen: FxHashSet<&PathBuf> = FxHashSet::default();
222
+ for f in strong {
223
+ if result.len() >= MAX_REVERSE_FILES {
224
+ return result;
225
+ }
226
+ if seen.insert(f) {
227
+ result.push(f.clone());
228
+ }
229
+ }
230
+ weak.sort_by(|a, b| a.0.cmp(b.0));
231
+ for (f, named) in weak {
232
+ if result.len() >= MAX_REVERSE_FILES {
233
+ break;
234
+ }
235
+ let rare = named
236
+ .iter()
237
+ .any(|n| name_holders.get(n).copied().unwrap_or(0) <= MAX_REVERSE_FILES_PER_NAME);
238
+ if rare && seen.insert(f) {
239
+ result.push(f.clone());
240
+ }
241
+ }
242
+ result
243
+ }
244
+ }
245
+
120
246
  pub struct TestFileDiscovery;
121
247
 
122
248
  const TEST_PREFIXES: &[&str] = &["test_", "spec_"];
@@ -32,7 +32,10 @@ static FN_DEF_RE: Lazy<Regex> = Lazy::new(|| {
32
32
  });
33
33
  static TYPE_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b([A-Z]\w*)\b").unwrap());
34
34
  static FN_CALL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b([a-z_]\w+)\s*[(<]").unwrap());
35
- static PATH_CALL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b(\w+)::(\w+)").unwrap());
35
+ /// Whole `a::b::c` paths; every adjacent pair is a (module, symbol) call so
36
+ /// `crate::pricing::total(..)` names `pricing::total` and not only
37
+ /// `crate::pricing`, which a pairwise regex stopped at.
38
+ static PATH_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\b\w+(?:::\w+)+").unwrap());
36
39
  static IMPL_RE: Lazy<Regex> =
37
40
  Lazy::new(|| Regex::new(r"(?m)^\s*impl(?:<[^>]*>)?\s+(\w+)\s+for\s+(\w+)").unwrap());
38
41
  static PUB_USE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^\s*pub\s+use\s+([\w:]+)").unwrap());
@@ -107,9 +110,15 @@ fn extract_references(content: &str) -> References {
107
110
  fn_calls.insert(name.to_string());
108
111
  }
109
112
  }
110
- let path_calls: FxHashSet<(String, String)> = PATH_CALL_RE
111
- .captures_iter(content)
112
- .map(|c| (c[1].to_string(), c[2].to_string()))
113
+ let path_calls: FxHashSet<(String, String)> = PATH_RE
114
+ .find_iter(content)
115
+ .flat_map(|m| {
116
+ let segments: Vec<&str> = m.as_str().split("::").collect();
117
+ segments
118
+ .windows(2)
119
+ .map(|w| (w[0].to_string(), w[1].to_string()))
120
+ .collect::<Vec<_>>()
121
+ })
113
122
  .collect();
114
123
  References {
115
124
  type_refs,
@@ -196,6 +196,7 @@ pub const NON_SEMANTIC_ENV: &[&str] = &[
196
196
  "DIFFCTX_ALLOWED_PATHS",
197
197
  "DIFFCTX_BENCH_TIMEOUT_SEC",
198
198
  "DIFFCTX_BUILD_SHA",
199
+ "DIFFCTX_CACHE_DIR",
199
200
  "DIFFCTX_CONFIG_DIR",
200
201
  "DIFFCTX_DIR_IGNORE",
201
202
  "DIFFCTX_DIR_WHITELIST",
@@ -204,6 +205,7 @@ pub const NON_SEMANTIC_ENV: &[&str] = &[
204
205
  "DIFFCTX_EVAL_STRICT",
205
206
  "DIFFCTX_IGNORE_RELPATH",
206
207
  "DIFFCTX_MCP_LEGACY_TOOLS",
208
+ "DIFFCTX_NO_MARKER",
207
209
  "DIFFCTX_PROBE_MODE",
208
210
  "DIFFCTX_PROBE_TAU",
209
211
  "DIFFCTX_PROVENANCE",
@@ -426,6 +426,39 @@ impl Graph {
426
426
  }
427
427
  }
428
428
 
429
+ /// Invoke `f(source_id, weight)` for each node with an edge INTO `node`,
430
+ /// with the weight of that incoming edge.
431
+ pub fn for_each_reverse_neighbor<F: FnMut(&FragmentId, f64)>(
432
+ &self,
433
+ node: &FragmentId,
434
+ mut f: F,
435
+ ) {
436
+ let rev = match self.rev_csr() {
437
+ Some(c) => c,
438
+ None => return,
439
+ };
440
+ let idx = match rev.node_to_idx.get(node) {
441
+ Some(&i) => i as usize,
442
+ None => return,
443
+ };
444
+ let s = rev.indptr[idx] as usize;
445
+ let e = rev.indptr[idx + 1] as usize;
446
+ for k in s..e {
447
+ let src_idx = rev.indices[k] as usize;
448
+ f(&rev.idx_to_node[src_idx], rev.weights[k]);
449
+ }
450
+ }
451
+
452
+ /// Whether `a -> b` is the stronger direction of the pair. Every builder
453
+ /// writes the reverse of an edge at `weight * reverse_factor` with the
454
+ /// factor below one, so the comparison recovers the direction the builder
455
+ /// meant (caller -> callee, importer -> imported) without a second table.
456
+ pub fn is_forward(&self, a: &FragmentId, b: &FragmentId) -> bool {
457
+ let ab = self.forward_edge_weight(a, b).unwrap_or(0.0);
458
+ let ba = self.forward_edge_weight(b, a).unwrap_or(0.0);
459
+ ab > ba
460
+ }
461
+
429
462
  pub fn ego_graph(
430
463
  &self,
431
464
  seeds: &FxHashSet<FragmentId>,