diffctx 1.16.0__tar.gz → 1.18.0__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 (171) hide show
  1. {diffctx-1.16.0 → diffctx-1.18.0}/CHANGELOG.md +124 -1
  2. {diffctx-1.16.0 → diffctx-1.18.0}/Cargo.lock +14 -7
  3. {diffctx-1.16.0 → diffctx-1.18.0}/PKG-INFO +23 -12
  4. {diffctx-1.16.0 → diffctx-1.18.0}/README.md +19 -8
  5. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/Cargo.toml +3 -2
  6. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/README.md +19 -8
  7. diffctx-1.18.0/crates/diffctx-native/src/candidate_files.rs +201 -0
  8. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/weights.rs +58 -18
  9. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/discovery.rs +126 -0
  10. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/base.rs +9 -1
  11. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/mod.rs +45 -2
  12. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/bazel.rs +11 -4
  13. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/cargo_edges.rs +3 -3
  14. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/go.rs +1 -1
  15. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/javascript.rs +9 -1
  16. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/perl.rs +1 -1
  17. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/shell.rs +17 -3
  18. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/terraform.rs +4 -4
  19. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/structural/testing.rs +1 -1
  20. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/effective_config.rs +5 -0
  21. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/fragmentation.rs +217 -97
  22. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/git.rs +311 -73
  23. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/graph.rs +33 -0
  24. diffctx-1.18.0/crates/diffctx-native/src/hook.rs +644 -0
  25. diffctx-1.18.0/crates/diffctx-native/src/impact.rs +707 -0
  26. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/in_memory_harness.rs +40 -4
  27. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/languages.rs +116 -1
  28. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/lib.rs +3 -1
  29. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/locate.rs +63 -11
  30. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/main.rs +236 -31
  31. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/parsers/config_parser.rs +6 -15
  32. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/parsers/tree_sitter_strategy.rs +75 -14
  33. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/paths.rs +25 -3
  34. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/pipeline.rs +353 -96
  35. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/ppr.rs +4 -0
  36. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/project_graph.rs +7 -3
  37. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/pybridge.rs +80 -10
  38. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/render.rs +183 -3
  39. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/resource.rs +45 -5
  40. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/sanitize.rs +2 -2
  41. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/select.rs +39 -0
  42. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/token_corpus.rs +5 -2
  43. {diffctx-1.16.0 → diffctx-1.18.0}/pyproject.toml +3 -3
  44. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/_app.py +94 -15
  45. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/_diffctx.pyi +14 -1
  46. diffctx-1.18.0/src/diffctx/_native/__init__.py +7 -0
  47. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/_native/graph_analytics.py +1 -0
  48. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/_native/pipeline.py +55 -33
  49. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/cli.py +101 -38
  50. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/ignore.py +15 -6
  51. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/mcp/fetch.py +51 -39
  52. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/mcp/legacy.py +22 -21
  53. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/mcp/security.py +8 -3
  54. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/mcp/server.py +128 -34
  55. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/tree.py +41 -14
  56. diffctx-1.18.0/src/diffctx/version.py +1 -0
  57. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/writer.py +107 -29
  58. diffctx-1.16.0/crates/diffctx-native/src/candidate_files.rs +0 -134
  59. diffctx-1.16.0/src/diffctx/_native/__init__.py +0 -7
  60. diffctx-1.16.0/src/diffctx/version.py +0 -1
  61. {diffctx-1.16.0 → diffctx-1.18.0}/Cargo.toml +0 -0
  62. {diffctx-1.16.0 → diffctx-1.18.0}/LICENSE +0 -0
  63. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/analytics.rs +0 -0
  64. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/change_class.rs +0 -0
  65. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/analytics.rs +0 -0
  66. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/bm25.rs +0 -0
  67. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/budget.rs +0 -0
  68. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/category_weights.rs +0 -0
  69. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/edge_weights.rs +0 -0
  70. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/env_overrides.rs +0 -0
  71. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/extensions.rs +0 -0
  72. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/filtering.rs +0 -0
  73. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/fragmentation.rs +0 -0
  74. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/git.rs +0 -0
  75. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/graph_filtering.rs +0 -0
  76. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/importance.rs +0 -0
  77. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/limits.rs +0 -0
  78. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/mod.rs +0 -0
  79. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/mode.rs +0 -0
  80. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/needs.rs +0 -0
  81. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/parsers.rs +0 -0
  82. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/render.rs +0 -0
  83. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/scoring.rs +0 -0
  84. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/selection.rs +0 -0
  85. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/config/tokenization.rs +0 -0
  86. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/core.rs +0 -0
  87. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/config_edges/build_system.rs +0 -0
  88. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/config_edges/cicd.rs +0 -0
  89. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/config_edges/docker.rs +0 -0
  90. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/config_edges/generic.rs +0 -0
  91. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/config_edges/helm.rs +0 -0
  92. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/config_edges/kubernetes.rs +0 -0
  93. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/config_edges/mod.rs +0 -0
  94. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/document/mod.rs +0 -0
  95. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/history/cochange.rs +0 -0
  96. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/history/mod.rs +0 -0
  97. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/ansible.rs +0 -0
  98. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/c_family.rs +0 -0
  99. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/clojure.rs +0 -0
  100. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/css.rs +0 -0
  101. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/dart.rs +0 -0
  102. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/dbt.rs +0 -0
  103. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/dotnet.rs +0 -0
  104. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/elixir.rs +0 -0
  105. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/erlang.rs +0 -0
  106. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/graphql.rs +0 -0
  107. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/haskell.rs +0 -0
  108. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/julia.rs +0 -0
  109. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/jvm.rs +0 -0
  110. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/latex.rs +0 -0
  111. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/lua.rs +0 -0
  112. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/mod.rs +0 -0
  113. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/nim.rs +0 -0
  114. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/nix.rs +0 -0
  115. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/ocaml.rs +0 -0
  116. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/openapi.rs +0 -0
  117. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/php.rs +0 -0
  118. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/prisma.rs +0 -0
  119. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/protobuf.rs +0 -0
  120. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/python.rs +0 -0
  121. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/r_lang.rs +0 -0
  122. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/ruby.rs +0 -0
  123. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/rust_lang.rs +0 -0
  124. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/sql.rs +0 -0
  125. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/swift.rs +0 -0
  126. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/tags.rs +0 -0
  127. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/semantic/zig.rs +0 -0
  128. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/similarity/lexical.rs +0 -0
  129. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/similarity/mod.rs +0 -0
  130. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/structural/containment.rs +0 -0
  131. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/structural/mod.rs +0 -0
  132. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/edges/structural/sibling.rs +0 -0
  133. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/excerpt.rs +0 -0
  134. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/facts/mod.rs +0 -0
  135. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/facts/scala.rs +0 -0
  136. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/filtering.rs +0 -0
  137. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/graph_export.rs +0 -0
  138. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/interval.rs +0 -0
  139. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/mode.rs +0 -0
  140. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/parsers/generic.rs +0 -0
  141. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/parsers/markdown.rs +0 -0
  142. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/parsers/mod.rs +0 -0
  143. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/peak_rss.rs +0 -0
  144. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/postpass.rs +0 -0
  145. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/provenance.rs +0 -0
  146. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/run_provenance.rs +0 -0
  147. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/scoring.rs +0 -0
  148. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/signatures.rs +0 -0
  149. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/stopwords/code.txt +0 -0
  150. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/stopwords/docs.txt +0 -0
  151. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/stopwords.rs +0 -0
  152. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/test_rng.rs +0 -0
  153. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/testfiles.rs +0 -0
  154. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/tokenizer.rs +0 -0
  155. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/types.rs +0 -0
  156. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/utility/boltzmann.rs +0 -0
  157. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/utility/importance.rs +0 -0
  158. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/utility/mod.rs +0 -0
  159. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/utility/needs.rs +0 -0
  160. {diffctx-1.16.0 → diffctx-1.18.0}/crates/diffctx-native/src/utility/objective.rs +0 -0
  161. {diffctx-1.16.0 → diffctx-1.18.0}/rust-toolchain.toml +0 -0
  162. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/__init__.py +0 -0
  163. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/__main__.py +0 -0
  164. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/_native/graph_export.py +0 -0
  165. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/_native/project_graph.py +0 -0
  166. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/clipboard.py +0 -0
  167. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/logger.py +0 -0
  168. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/mcp/__init__.py +0 -0
  169. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/mcp/__main__.py +0 -0
  170. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/py.typed +0 -0
  171. {diffctx-1.16.0 → diffctx-1.18.0}/src/diffctx/tokens.py +0 -0
@@ -7,6 +7,127 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.18.0] - 2026-09-29
11
+
12
+ ### Added
13
+
14
+ - **`--mode impact`: what a change reaches outside its own diff, under 2k
15
+ tokens.** For each changed symbol, the callers in files the diff does not
16
+ touch, whether a test guards each one, and which symbols more than one
17
+ commit of the range edited; public API and schema changes are listed as
18
+ facts. It walks the graph the other modes already build, with a discovery
19
+ pass that follows references *into* the changed files (the other modes
20
+ only follow what the changed files import), and filters name-only links
21
+ the way a reader would: the caller must name the symbol in code and
22
+ reference its module. An empty impact is an answer, not an error. CLI
23
+ (`-f md` is the text form), MCP `mode="impact"`, the GitHub Action's
24
+ `mode` input, and `schemas/diffctx.impact.v1.json`.
25
+ - **`diffctx hook pretooluse` / `posttooluse`: the Claude Code plugin hands
26
+ the agent a change's impact without asking it to call anything.** Eight
27
+ agent sessions with the tool installed made zero unprompted calls (#289).
28
+ Before a commit, merge, cherry-pick, push or pull request the impact of
29
+ what that command records is injected as context — a plain `git commit`
30
+ reviews the index, `-a` the working tree, a merge the branch since its
31
+ merge base; after a `git diff` or `git status` the agent ran itself, the
32
+ pending change's impact rides on the answer it asked for. Once per change
33
+ (content-keyed with `git patch-id`, so a manual `--mode impact` run
34
+ counts), silent when nothing outside the diff depends on the change, and
35
+ never blocking: a range that runs out of the 30 s deadline is marked
36
+ reviewed rather than retried on every push. `impact_gate` (off by default)
37
+ denies a commit until its impact was shown once. At session start the
38
+ plugin fetches the release binary for the platform, verified against
39
+ `checksums.json`, into its data directory, backs off for six hours after a
40
+ failed download, and puts the one-line recipe into the session's context;
41
+ `impact_hook` turns all of it off (#310).
42
+ - **`/diffctx:commit`**: the impact first, then the commit.
43
+ - **`diffctx_context` is always loaded** under Claude Code's tool search
44
+ (`_meta.anthropic/alwaysLoad`), and its description names `impact` as the
45
+ pre-commit call; both skills default to the server's own range choice.
46
+ - **The PR review comment opens with the impact view.**
47
+ - **Plugin eval `breaking-caller`** measures the number under the goal: with
48
+ a contract change uncommitted and two callers about to break, does the
49
+ agent handle or name them before committing.
50
+
51
+ ## [1.17.0] - 2026-09-23
52
+
53
+ ### Added
54
+
55
+ - **The site lives at <https://diffctx.com>.** It was a 301 to
56
+ `nikolay-e.github.io/diffctx/`, so search engines indexed the github.io
57
+ URL and the product domain had no presence of its own. It is now the
58
+ GitHub Pages custom domain (github.io redirects to it), with a
59
+ `robots.txt` and `sitemap.xml`; package metadata points at it.
60
+ - **Windows and Intel-Mac are release targets.** Every test matrix runs on
61
+ Linux, macOS and Windows on x64 and ARM; wheels and binaries ship for
62
+ `x86_64-apple-darwin` and `aarch64-pc-windows-msvc` as well, and `npm
63
+ install diffctx` and the Scoop bucket select them. What that exposed is
64
+ fixed: `--output-file` on Windows, backslashes in MCP output and in the
65
+ Rust path matching (discovery, C#/Go/Cargo edges), datasets checked out
66
+ byte-exact under a CRLF `autocrlf`, and a `--timeout 0` that now always
67
+ reports the timeout instead of racing the subprocess.
68
+ - **The landing page installs and opens offline** — a web manifest, icons
69
+ and a service worker that keeps every visit network-first.
70
+ - **Free-threaded CPython gets a wheel.** Each platform ships a `cp314t`
71
+ wheel beside the `abi3` one; `uvx` on 3.14t no longer compiles the sdist
72
+ (which timed MCP clients out), and the extension keeps the GIL off.
73
+ - **The GitHub Action works on a default shallow checkout**: with no
74
+ `diff-range` it fetches the pull request's base and head (or `HEAD~1`)
75
+ itself, so `fetch-depth: 0` is no longer required.
76
+ - **`provenance.engine.build` names the release commit** in wheels, binaries
77
+ and the image.
78
+ - **A glossary line under the scoring modes** on the landing page: three
79
+ independent first-visit probes stalled on `ego-network`, `PPR`, `rrf`,
80
+ `pit`, `τ` and `o200k_base` with nothing on the page defining them.
81
+
82
+ ### Removed
83
+
84
+ - **`build_diff_context(ignore_file=…, whitelist_file=…, no_default_ignores=…)`.**
85
+ The engine never applied a custom path-spec layer in diff mode and the three
86
+ arguments existed only to raise `NotImplementedError`; passing one is now a
87
+ `TypeError`. `.gitignore` and `.diffctx/ignore` still apply.
88
+
89
+ ### Fixed
90
+
91
+ - **The MCP server no longer hangs on Windows.** Every git child inherited the
92
+ server's stdin, which under an MCP client is the JSON-RPC pipe the client
93
+ holds open, and on Windows such a git never exited: any tool call that reached
94
+ git hung. Child processes now get a null stdin in both the engine and the
95
+ Python layer.
96
+ - **A long range says how many commits it held.** The commit list stops at
97
+ the newest twenty; the artifact read "20 commits" over a 42-commit range. It
98
+ now carries `commit_count` and reads "newest 20 of 42 commits" when the list
99
+ is cut, in both CLIs, pack and locate.
100
+ - **`--budget` fitting never passes context off as the change.** The witness
101
+ clip shortened any file's last fragment, so a context file shrank line by line
102
+ under a "more lines of this change" marker before it dropped; only a changed
103
+ fragment is shortened now.
104
+ - **MCP `fragment_ids` on a bare `diff_ref` read the code the change
105
+ replaced.** `HEAD` (or `HEAD~3`, or a duration window) is git's
106
+ base-versus-working-tree, so the locate ranking describes the files on
107
+ disk; the fetch read the base blob instead and returned the old body under
108
+ the ranking's own id. Bodies for a `..`-less ref now come from the working
109
+ tree, and a file the named revision lacks (or a revision git cannot
110
+ resolve) is reported as not found instead of being substituted with the
111
+ working-tree file under a historical label.
112
+ - **The native binary fits the written artifact to `--budget`** (#277).
113
+ npm, Scoop, Docker and the release archives ship the Rust binary, which
114
+ documented `--budget` as a cap on the whole artifact and enforced nothing on
115
+ the document it wrote: 9,121 tokens at `--budget 8000` on a real range. It
116
+ now runs the same render-count-drop loop as the Python CLI (context from the
117
+ tail first, a changed file's second fragment next, mechanical witnesses
118
+ before hand-written ones), records `selection_budget_exceeded` in
119
+ `coverage`, and exits 4 when nothing fits — the two CLIs deliver one set.
120
+ - **Eval harness: gold paths git had quoted** (`"b/caf\303\251.py"`) were
121
+ taken literally, so every non-ASCII gold file scored as a miss; the patch
122
+ header parser unquotes them. A checkpoint whose last line a kill left
123
+ unterminated no longer swallows the next appended row on resume.
124
+
125
+ ### Changed
126
+
127
+ - The GitHub Action's `python-version` defaults to `3.13` (`--output-file`
128
+ needs `os.fchmod`, which Windows Python gained in 3.13); the action
129
+ reference page says so. `--budget --help` names what `auto` resolves to.
130
+
10
131
  ## [1.16.0] - 2026-09-16
11
132
 
12
133
  ### Added
@@ -1551,7 +1672,9 @@ Earlier releases shipped as `treemapper`; see
1551
1672
  <https://github.com/nikolay-e/diffctx/releases> for the corresponding GitHub
1552
1673
  release notes (`1.0.0` through `1.6.1`).
1553
1674
 
1554
- [Unreleased]: https://github.com/nikolay-e/diffctx/compare/v1.16.0...HEAD
1675
+ [Unreleased]: https://github.com/nikolay-e/diffctx/compare/v1.18.0...HEAD
1676
+ [1.18.0]: https://github.com/nikolay-e/diffctx/compare/v1.17.0...v1.18.0
1677
+ [1.17.0]: https://github.com/nikolay-e/diffctx/compare/v1.16.0...v1.17.0
1555
1678
  [1.16.0]: https://github.com/nikolay-e/diffctx/compare/v1.15.0...v1.16.0
1556
1679
  [1.15.0]: https://github.com/nikolay-e/diffctx/compare/v1.14.0...v1.15.0
1557
1680
  [1.14.0]: https://github.com/nikolay-e/diffctx/compare/v1.13.0...v1.14.0
@@ -123,9 +123,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
123
123
 
124
124
  [[package]]
125
125
  name = "clap"
126
- version = "4.6.6"
126
+ version = "4.6.7"
127
127
  source = "registry+https://github.com/rust-lang/crates.io-index"
128
- checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
128
+ checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
129
129
  dependencies = [
130
130
  "clap_builder",
131
131
  "clap_derive",
@@ -133,9 +133,9 @@ dependencies = [
133
133
 
134
134
  [[package]]
135
135
  name = "clap_builder"
136
- version = "4.6.6"
136
+ version = "4.6.7"
137
137
  source = "registry+https://github.com/rust-lang/crates.io-index"
138
- checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
138
+ checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
139
139
  dependencies = [
140
140
  "anstream",
141
141
  "anstyle",
@@ -145,9 +145,9 @@ dependencies = [
145
145
 
146
146
  [[package]]
147
147
  name = "clap_derive"
148
- version = "4.6.4"
148
+ version = "4.6.7"
149
149
  source = "registry+https://github.com/rust-lang/crates.io-index"
150
- checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
150
+ checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
151
151
  dependencies = [
152
152
  "heck",
153
153
  "proc-macro2",
@@ -194,11 +194,12 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
194
194
 
195
195
  [[package]]
196
196
  name = "diffctx"
197
- version = "1.16.0"
197
+ version = "1.18.0"
198
198
  dependencies = [
199
199
  "aho-corasick",
200
200
  "anyhow",
201
201
  "clap",
202
+ "dunce",
202
203
  "glob",
203
204
  "libc",
204
205
  "libtest-mimic",
@@ -260,6 +261,12 @@ dependencies = [
260
261
  "walkdir",
261
262
  ]
262
263
 
264
+ [[package]]
265
+ name = "dunce"
266
+ version = "1.0.5"
267
+ source = "registry+https://github.com/rust-lang/crates.io-index"
268
+ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
269
+
263
270
  [[package]]
264
271
  name = "dyn-clone"
265
272
  version = "1.0.20"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffctx
3
- Version: 1.16.0
3
+ Version: 1.18.0
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
@@ -104,8 +104,15 @@ scoop bucket add diffctx https://github.com/nikolay-e/diffctx
104
104
  scoop install diffctx/diffctx
105
105
  ```
106
106
 
107
- Prebuilt binaries for linux (x86_64/aarch64), macOS (arm64) and Windows (x64)
108
- are attached to every [release](https://github.com/nikolay-e/diffctx/releases/latest).
107
+ Every [release](https://github.com/nikolay-e/diffctx/releases/latest) carries
108
+ prebuilt binaries; the current target set is Linux (x86_64/aarch64), macOS
109
+ (arm64/x86_64) and Windows (x64/arm64), and a release older than a target's
110
+ first build lacks it (v1.16.0 predates the macOS x86_64 and Windows arm64
111
+ archives). On Windows on ARM, `diffctx[mcp]` builds `cryptography` from source,
112
+ which needs OpenSSL: that project publishes no wheel for the platform.
113
+ Free-threaded CPython (`3.14t`) cannot load the `abi3` wheel, so each release
114
+ after 1.16.0 also ships a `cp314t` wheel per platform (the extension runs with
115
+ the GIL off); on 1.16.0 itself `pip` and `uv` fall back to compiling the sdist.
109
116
  The native binary and Docker image cover diff mode with YAML/JSON output and
110
117
  write to stdout (redirect to capture); tree mode, Markdown output, the `graph`
111
118
  subcommand and the MCP server live in the Python package.
@@ -241,7 +248,10 @@ as `io.github.nikolay-e/diffctx`. One-line setup (zero-install via
241
248
  [uv](https://docs.astral.sh/uv/)):
242
249
 
243
250
  ```bash
244
- # 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
245
255
  claude mcp add diffctx -- uvx --from 'diffctx[mcp]' diffctx-mcp
246
256
  # Codex CLI
247
257
  codex mcp add diffctx -- uvx --from 'diffctx[mcp]' diffctx-mcp
@@ -293,8 +303,9 @@ map a directory named `mcp` on older releases.
293
303
 
294
304
  Respects `.gitignore` and `.diffctx/ignore` automatically — hierarchically at
295
305
  every directory level, with full gitignore semantics (negation `!important.log`,
296
- anchored `/root_only.txt`), and the output file is always auto-ignored. Three
297
- controls are tree mode only and are refused with `--diff`: `.diffctx/whitelist`
306
+ anchored `/root_only.txt`), and in tree mode the output file is always
307
+ auto-ignored (`--diff` reports a saved `tree.md` like any other change). Three
308
+ controls are tree mode only and are refused with `--diff` and `graph`: `.diffctx/whitelist`
298
309
  (`-w`) as an include-only filter, `-i` for an extra ignore file, and
299
310
  `--no-default-ignores` / `--no-ignores` to drop the built-in patterns or every
300
311
  ignore rule.
@@ -318,8 +329,8 @@ its size (default 512 MB, `0` disables eviction).
318
329
  |------|---------|
319
330
  | `0` | Success — output contains content |
320
331
  | `1` | Runtime error (bad path, permission denied, etc.) |
321
- | `2` | Usage error (invalid flags/arguments) |
322
- | `3` | Environment error (`--diff` outside a git repo, git not installed, no commits yet) |
332
+ | `2` | Usage error (invalid flags/arguments, an empty `--diff ""`, a malformed duration such as `1.5h`, an out-of-range `--alpha`/`--tau`/`--budget`/`--timeout`, or a tree-mode flag — `-i`/`-w`/`--no-default-ignores`/`--no-ignores` — given with `--diff` or `graph`) |
333
+ | `3` | Environment error (`--diff` outside a git repo, in a bare one, or on a missing path, git not installed or refusing to start — its `fatal:` line is printed, no commits yet, a revision missing from a shallow clone) |
323
334
  | `4` | `--diff` produced no semantic context (clean tree, binary-only, everything filtered); output is still emitted. Deletion/rename/lockfile-only diffs list `deleted_files`/`renamed_files`/`lockfile_changes` and exit `0` |
324
335
  | `124`| `--diff` ran 30 s past the `--timeout` deadline without stopping cooperatively (the deadline itself yields a partial artifact and exit 0) |
325
336
  | `130`| Interrupted (Ctrl-C) |
@@ -335,7 +346,7 @@ Apache 2.0
335
346
 
336
347
  ---
337
348
 
338
- - [Documentation site](https://nikolay-e.github.io/diffctx/) — the pipeline
349
+ - [Documentation site](https://diffctx.com/) — the pipeline
339
350
  end to end: diff → fragments → graph → relevance → selection
340
351
  - [Command-line reference](docs/product/cli.md) — every flag with its default
341
352
  and meaning, rendered from `diffctx --help`
@@ -45,8 +45,15 @@ scoop bucket add diffctx https://github.com/nikolay-e/diffctx
45
45
  scoop install diffctx/diffctx
46
46
  ```
47
47
 
48
- Prebuilt binaries for linux (x86_64/aarch64), macOS (arm64) and Windows (x64)
49
- are attached to every [release](https://github.com/nikolay-e/diffctx/releases/latest).
48
+ Every [release](https://github.com/nikolay-e/diffctx/releases/latest) carries
49
+ prebuilt binaries; the current target set is Linux (x86_64/aarch64), macOS
50
+ (arm64/x86_64) and Windows (x64/arm64), and a release older than a target's
51
+ first build lacks it (v1.16.0 predates the macOS x86_64 and Windows arm64
52
+ archives). On Windows on ARM, `diffctx[mcp]` builds `cryptography` from source,
53
+ which needs OpenSSL: that project publishes no wheel for the platform.
54
+ Free-threaded CPython (`3.14t`) cannot load the `abi3` wheel, so each release
55
+ after 1.16.0 also ships a `cp314t` wheel per platform (the extension runs with
56
+ the GIL off); on 1.16.0 itself `pip` and `uv` fall back to compiling the sdist.
50
57
  The native binary and Docker image cover diff mode with YAML/JSON output and
51
58
  write to stdout (redirect to capture); tree mode, Markdown output, the `graph`
52
59
  subcommand and the MCP server live in the Python package.
@@ -182,7 +189,10 @@ as `io.github.nikolay-e/diffctx`. One-line setup (zero-install via
182
189
  [uv](https://docs.astral.sh/uv/)):
183
190
 
184
191
  ```bash
185
- # 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
186
196
  claude mcp add diffctx -- uvx --from 'diffctx[mcp]' diffctx-mcp
187
197
  # Codex CLI
188
198
  codex mcp add diffctx -- uvx --from 'diffctx[mcp]' diffctx-mcp
@@ -234,8 +244,9 @@ map a directory named `mcp` on older releases.
234
244
 
235
245
  Respects `.gitignore` and `.diffctx/ignore` automatically — hierarchically at
236
246
  every directory level, with full gitignore semantics (negation `!important.log`,
237
- anchored `/root_only.txt`), and the output file is always auto-ignored. Three
238
- controls are tree mode only and are refused with `--diff`: `.diffctx/whitelist`
247
+ anchored `/root_only.txt`), and in tree mode the output file is always
248
+ auto-ignored (`--diff` reports a saved `tree.md` like any other change). Three
249
+ controls are tree mode only and are refused with `--diff` and `graph`: `.diffctx/whitelist`
239
250
  (`-w`) as an include-only filter, `-i` for an extra ignore file, and
240
251
  `--no-default-ignores` / `--no-ignores` to drop the built-in patterns or every
241
252
  ignore rule.
@@ -259,8 +270,8 @@ its size (default 512 MB, `0` disables eviction).
259
270
  |------|---------|
260
271
  | `0` | Success — output contains content |
261
272
  | `1` | Runtime error (bad path, permission denied, etc.) |
262
- | `2` | Usage error (invalid flags/arguments) |
263
- | `3` | Environment error (`--diff` outside a git repo, git not installed, no commits yet) |
273
+ | `2` | Usage error (invalid flags/arguments, an empty `--diff ""`, a malformed duration such as `1.5h`, an out-of-range `--alpha`/`--tau`/`--budget`/`--timeout`, or a tree-mode flag — `-i`/`-w`/`--no-default-ignores`/`--no-ignores` — given with `--diff` or `graph`) |
274
+ | `3` | Environment error (`--diff` outside a git repo, in a bare one, or on a missing path, git not installed or refusing to start — its `fatal:` line is printed, no commits yet, a revision missing from a shallow clone) |
264
275
  | `4` | `--diff` produced no semantic context (clean tree, binary-only, everything filtered); output is still emitted. Deletion/rename/lockfile-only diffs list `deleted_files`/`renamed_files`/`lockfile_changes` and exit `0` |
265
276
  | `124`| `--diff` ran 30 s past the `--timeout` deadline without stopping cooperatively (the deadline itself yields a partial artifact and exit 0) |
266
277
  | `130`| Interrupted (Ctrl-C) |
@@ -276,7 +287,7 @@ Apache 2.0
276
287
 
277
288
  ---
278
289
 
279
- - [Documentation site](https://nikolay-e.github.io/diffctx/) — the pipeline
290
+ - [Documentation site](https://diffctx.com/) — the pipeline
280
291
  end to end: diff → fragments → graph → relevance → selection
281
292
  - [Command-line reference](docs/product/cli.md) — every flag with its default
282
293
  and meaning, rendered from `diffctx --help`
@@ -1,12 +1,12 @@
1
1
  [package]
2
2
  name = "diffctx"
3
- version = "1.16.0"
3
+ version = "1.18.0"
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"]
@@ -71,6 +71,7 @@ lang-extra = [
71
71
  [dependencies]
72
72
  mimalloc = { version = "0.1", default-features = false }
73
73
  clap = { version = "4", features = ["derive"] }
74
+ dunce = "1"
74
75
  tree-sitter = "0.25"
75
76
  tree-sitter-python = { version = "0.25", optional = true }
76
77
  tree-sitter-javascript = { version = "0.25", optional = true }
@@ -45,8 +45,15 @@ scoop bucket add diffctx https://github.com/nikolay-e/diffctx
45
45
  scoop install diffctx/diffctx
46
46
  ```
47
47
 
48
- Prebuilt binaries for linux (x86_64/aarch64), macOS (arm64) and Windows (x64)
49
- are attached to every [release](https://github.com/nikolay-e/diffctx/releases/latest).
48
+ Every [release](https://github.com/nikolay-e/diffctx/releases/latest) carries
49
+ prebuilt binaries; the current target set is Linux (x86_64/aarch64), macOS
50
+ (arm64/x86_64) and Windows (x64/arm64), and a release older than a target's
51
+ first build lacks it (v1.16.0 predates the macOS x86_64 and Windows arm64
52
+ archives). On Windows on ARM, `diffctx[mcp]` builds `cryptography` from source,
53
+ which needs OpenSSL: that project publishes no wheel for the platform.
54
+ Free-threaded CPython (`3.14t`) cannot load the `abi3` wheel, so each release
55
+ after 1.16.0 also ships a `cp314t` wheel per platform (the extension runs with
56
+ the GIL off); on 1.16.0 itself `pip` and `uv` fall back to compiling the sdist.
50
57
  The native binary and Docker image cover diff mode with YAML/JSON output and
51
58
  write to stdout (redirect to capture); tree mode, Markdown output, the `graph`
52
59
  subcommand and the MCP server live in the Python package.
@@ -182,7 +189,10 @@ as `io.github.nikolay-e/diffctx`. One-line setup (zero-install via
182
189
  [uv](https://docs.astral.sh/uv/)):
183
190
 
184
191
  ```bash
185
- # 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
186
196
  claude mcp add diffctx -- uvx --from 'diffctx[mcp]' diffctx-mcp
187
197
  # Codex CLI
188
198
  codex mcp add diffctx -- uvx --from 'diffctx[mcp]' diffctx-mcp
@@ -234,8 +244,9 @@ map a directory named `mcp` on older releases.
234
244
 
235
245
  Respects `.gitignore` and `.diffctx/ignore` automatically — hierarchically at
236
246
  every directory level, with full gitignore semantics (negation `!important.log`,
237
- anchored `/root_only.txt`), and the output file is always auto-ignored. Three
238
- controls are tree mode only and are refused with `--diff`: `.diffctx/whitelist`
247
+ anchored `/root_only.txt`), and in tree mode the output file is always
248
+ auto-ignored (`--diff` reports a saved `tree.md` like any other change). Three
249
+ controls are tree mode only and are refused with `--diff` and `graph`: `.diffctx/whitelist`
239
250
  (`-w`) as an include-only filter, `-i` for an extra ignore file, and
240
251
  `--no-default-ignores` / `--no-ignores` to drop the built-in patterns or every
241
252
  ignore rule.
@@ -259,8 +270,8 @@ its size (default 512 MB, `0` disables eviction).
259
270
  |------|---------|
260
271
  | `0` | Success — output contains content |
261
272
  | `1` | Runtime error (bad path, permission denied, etc.) |
262
- | `2` | Usage error (invalid flags/arguments) |
263
- | `3` | Environment error (`--diff` outside a git repo, git not installed, no commits yet) |
273
+ | `2` | Usage error (invalid flags/arguments, an empty `--diff ""`, a malformed duration such as `1.5h`, an out-of-range `--alpha`/`--tau`/`--budget`/`--timeout`, or a tree-mode flag — `-i`/`-w`/`--no-default-ignores`/`--no-ignores` — given with `--diff` or `graph`) |
274
+ | `3` | Environment error (`--diff` outside a git repo, in a bare one, or on a missing path, git not installed or refusing to start — its `fatal:` line is printed, no commits yet, a revision missing from a shallow clone) |
264
275
  | `4` | `--diff` produced no semantic context (clean tree, binary-only, everything filtered); output is still emitted. Deletion/rename/lockfile-only diffs list `deleted_files`/`renamed_files`/`lockfile_changes` and exit `0` |
265
276
  | `124`| `--diff` ran 30 s past the `--timeout` deadline without stopping cooperatively (the deadline itself yields a partial artifact and exit 0) |
266
277
  | `130`| Interrupted (Ctrl-C) |
@@ -276,7 +287,7 @@ Apache 2.0
276
287
 
277
288
  ---
278
289
 
279
- - [Documentation site](https://nikolay-e.github.io/diffctx/) — the pipeline
290
+ - [Documentation site](https://diffctx.com/) — the pipeline
280
291
  end to end: diff → fragments → graph → relevance → selection
281
292
  - [Command-line reference](docs/product/cli.md) — every flag with its default
282
293
  and meaning, rendered from `diffctx --help`
@@ -0,0 +1,201 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use rayon::prelude::*;
4
+ use rustc_hash::{FxHashMap, FxHashSet};
5
+ use walkdir::WalkDir;
6
+
7
+ use crate::config::graph_filtering::GRAPH_FILTERING;
8
+ use crate::config::limits::LIMITS;
9
+ use crate::git;
10
+ use crate::languages::get_language_for_file;
11
+
12
+ const SHEBANG_SNIFF_BYTES: usize = 128;
13
+
14
+ fn is_allowed_file(path: &Path) -> bool {
15
+ let name = path.to_string_lossy();
16
+ get_language_for_file(&name).is_some()
17
+ || (path.extension().is_none() && has_known_shebang(path, &name))
18
+ }
19
+
20
+ fn has_known_shebang(path: &Path, name: &str) -> bool {
21
+ use std::io::Read;
22
+ let mut head = [0u8; SHEBANG_SNIFF_BYTES];
23
+ let Ok(read) = std::fs::File::open(path).and_then(|mut f| f.read(&mut head)) else {
24
+ return false;
25
+ };
26
+ crate::languages::sniff_language(name, &String::from_utf8_lossy(&head[..read])).is_some()
27
+ }
28
+
29
+ fn is_candidate_file(
30
+ file_path: &Path,
31
+ root_dir: &Path,
32
+ included_set: &FxHashSet<PathBuf>,
33
+ ctx: &crate::resource::RunContext,
34
+ ) -> bool {
35
+ // `is_file()` and `metadata()` both follow symlinks, and every path here is
36
+ // only LEXICALLY under the root — `git ls-files` reports the link, not its
37
+ // target. Without this the universe admits `repo/evil -> /etc/shadow`,
38
+ // discovery reads it with a bare `read_to_string`, and the content is a
39
+ // context fragment. The boundary is checked once, here, so the readers
40
+ // downstream stay simple; a link pointing INSIDE the repo still resolves.
41
+ if crate::paths::resolve_within(root_dir, file_path).is_none() {
42
+ return false;
43
+ }
44
+ if !file_path.is_file() {
45
+ return false;
46
+ }
47
+ if !is_allowed_file(file_path) {
48
+ return false;
49
+ }
50
+ if included_set.contains(file_path) {
51
+ return false;
52
+ }
53
+ match file_path.metadata() {
54
+ Ok(meta) if meta.len() as usize > LIMITS.max_file_size => {
55
+ // A source file the universe will not read is a hole in the
56
+ // context, and the artifact says so rather than rendering as
57
+ // complete (#T4.2: a 237 KB helper importing the changed file
58
+ // vanished with no coverage block).
59
+ ctx.note(crate::resource::LimitReason::FileTooLarge);
60
+ return false;
61
+ }
62
+ Err(_) => return false,
63
+ _ => {}
64
+ }
65
+ true
66
+ }
67
+
68
+ /// The discovery universe under `scope` (repo-relative pathspecs; empty is
69
+ /// the whole repository), with every size exclusion recorded on `ctx`.
70
+ pub fn collect_candidate_files(
71
+ root_dir: &Path,
72
+ included_set: &FxHashSet<PathBuf>,
73
+ scope: &[String],
74
+ ctx: &crate::resource::RunContext,
75
+ ) -> Vec<PathBuf> {
76
+ match tracked_candidates(root_dir, included_set, scope, ctx) {
77
+ Some(files) => filter_ignored_and_secret(root_dir, files),
78
+ // The walk sees untracked paths, so ancestor-inherited rules must count
79
+ // (`.venv/x.py` is ignored BY `.venv/`); the attribution variant drops them.
80
+ None => filter_ignored_and_secret_walked(
81
+ root_dir,
82
+ walked_candidates(root_dir, included_set, scope, ctx),
83
+ ),
84
+ }
85
+ }
86
+
87
+ fn tracked_candidates(
88
+ root_dir: &Path,
89
+ included_set: &FxHashSet<PathBuf>,
90
+ scope: &[String],
91
+ ctx: &crate::resource::RunContext,
92
+ ) -> Option<Vec<PathBuf>> {
93
+ let mut args: Vec<&str> = vec!["ls-files", "-z"];
94
+ if !scope.is_empty() {
95
+ args.push("--");
96
+ args.extend(scope.iter().map(String::as_str));
97
+ }
98
+ let parts = git::run_git_z(root_dir, &args).ok()?;
99
+ Some(
100
+ parts
101
+ .into_iter()
102
+ .map(|f| crate::paths::repo_join(root_dir, &f))
103
+ .collect::<Vec<PathBuf>>()
104
+ .into_par_iter()
105
+ .filter(|f| is_candidate_file(f, root_dir, included_set, ctx))
106
+ .collect(),
107
+ )
108
+ }
109
+
110
+ fn is_walk_skipped_dir(entry: &walkdir::DirEntry) -> bool {
111
+ entry.depth() > 0
112
+ && entry.file_type().is_dir()
113
+ && entry.file_name().to_str().is_some_and(|name| {
114
+ name.starts_with('.') || name == "node_modules" || name == "__pycache__"
115
+ })
116
+ }
117
+
118
+ fn walked_candidates(
119
+ root_dir: &Path,
120
+ included_set: &FxHashSet<PathBuf>,
121
+ scope: &[String],
122
+ ctx: &crate::resource::RunContext,
123
+ ) -> Vec<PathBuf> {
124
+ let walk_roots: Vec<PathBuf> = if scope.is_empty() {
125
+ vec![root_dir.to_path_buf()]
126
+ } else {
127
+ scope
128
+ .iter()
129
+ .map(|rel| crate::paths::repo_join(root_dir, rel))
130
+ .collect()
131
+ };
132
+ let mut admitted: Vec<PathBuf> = Vec::new();
133
+ for walk_root in walk_roots {
134
+ let files = WalkDir::new(walk_root)
135
+ .sort_by_file_name()
136
+ .into_iter()
137
+ .filter_entry(|e| !is_walk_skipped_dir(e))
138
+ .filter_map(Result::ok)
139
+ .filter(|e| e.file_type().is_file());
140
+ for entry in files {
141
+ let path = entry.into_path();
142
+ if is_candidate_file(&path, root_dir, included_set, ctx) {
143
+ admitted.push(path);
144
+ }
145
+ // Counted AFTER admission: capping raw entries meant a repository
146
+ // whose first N walked files are ignored (a vendored tree, a build
147
+ // directory) produced a nearly empty universe while the useful files
148
+ // sat just past the cut.
149
+ if admitted.len() >= GRAPH_FILTERING.fallback_max_files {
150
+ return admitted;
151
+ }
152
+ }
153
+ }
154
+ admitted
155
+ }
156
+
157
+ fn filter_ignored_and_secret_walked(root_dir: &Path, files: Vec<PathBuf>) -> Vec<PathBuf> {
158
+ let rel_paths: Vec<String> = files
159
+ .iter()
160
+ .filter_map(|f| crate::pipeline::rel_path_string(root_dir, f))
161
+ .collect();
162
+ let ignored: FxHashMap<String, git::IgnoreSource> =
163
+ git::find_ignored_paths_any(root_dir, &rel_paths)
164
+ .into_iter()
165
+ .map(|p| (p, git::IgnoreSource::Gitignore))
166
+ .collect();
167
+ files
168
+ .into_par_iter()
169
+ .filter(|f| !crate::pipeline::is_withheld(root_dir, f, &ignored))
170
+ .collect()
171
+ }
172
+
173
+ /// Discovery's candidate universe otherwise skips straight from a language
174
+ /// check to the graph: an unchanged file a changed file imports would render
175
+ /// as neighbour context even when `.diffctx/ignore` explicitly excludes it,
176
+ /// or when it is secret-like (`id_rsa`, `*.pem`, ...) — `changed_files` is
177
+ /// filtered this way already (`pipeline::compute_scored_state`), the
178
+ /// discovery universe was not. One batched `git check-ignore` call covers
179
+ /// every survivor of the language filter, so cost stays O(1) subprocess
180
+ /// invocations regardless of repo size, and the final filter still runs in
181
+ /// parallel via rayon.
182
+ fn filter_ignored_and_secret(root_dir: &Path, files: Vec<PathBuf>) -> Vec<PathBuf> {
183
+ let rel_paths: Vec<String> = files
184
+ .iter()
185
+ .filter_map(|f| crate::pipeline::rel_path_string(root_dir, f))
186
+ .collect();
187
+ let ignored_rel_paths = git::find_ignored_paths_with_source(root_dir, &rel_paths);
188
+ files
189
+ .into_par_iter()
190
+ .filter(|f| !crate::pipeline::is_withheld(root_dir, f, &ignored_rel_paths))
191
+ .collect()
192
+ }
193
+
194
+ pub fn normalize_path(path: &Path, root_dir: &Path) -> PathBuf {
195
+ if path.is_absolute() {
196
+ dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
197
+ } else {
198
+ let joined = crate::paths::repo_join(root_dir, &path.to_string_lossy());
199
+ dunce::canonicalize(&joined).unwrap_or(joined)
200
+ }
201
+ }