diffctx 1.10.2__tar.gz → 1.11.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 (160) hide show
  1. {diffctx-1.10.2 → diffctx-1.11.0}/CHANGELOG.md +12 -0
  2. {diffctx-1.10.2 → diffctx-1.11.0}/PKG-INFO +16 -4
  3. {diffctx-1.10.2 → diffctx-1.11.0}/README.md +15 -3
  4. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/Cargo.lock +3 -3
  5. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/Cargo.toml +1 -1
  6. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/analytics.rs +6 -8
  7. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/limits.rs +4 -1
  8. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/needs.rs +4 -2
  9. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/scoring.rs +2 -2
  10. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/mod.rs +49 -31
  11. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/filtering.rs +8 -8
  12. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/fragmentation.rs +12 -1
  13. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/git.rs +50 -0
  14. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/graph.rs +375 -92
  15. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/graph_export.rs +9 -12
  16. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/memory_pipeline.rs +4 -0
  17. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/pipeline.rs +108 -8
  18. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/postpass.rs +2 -4
  19. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/project_graph.rs +4 -7
  20. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/pybridge.rs +33 -13
  21. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/render.rs +54 -7
  22. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/scoring.rs +4 -6
  23. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/select.rs +15 -0
  24. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/utility/boltzmann.rs +2 -0
  25. {diffctx-1.10.2 → diffctx-1.11.0}/pyproject.toml +3 -3
  26. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/cli.py +8 -3
  27. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/diffctx/pipeline.py +21 -2
  28. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/main.py +8 -1
  29. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/mcp/README.md +6 -0
  30. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/mcp/server.py +35 -0
  31. diffctx-1.11.0/src/diffctx/version.py +1 -0
  32. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/writer.py +54 -17
  33. diffctx-1.10.2/src/diffctx/version.py +0 -1
  34. {diffctx-1.10.2 → diffctx-1.11.0}/LICENSE +0 -0
  35. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/candidate_files.rs +0 -0
  36. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/analytics.rs +0 -0
  37. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/bm25.rs +0 -0
  38. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/budget.rs +0 -0
  39. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/category_weights.rs +0 -0
  40. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/edge_weights.rs +0 -0
  41. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/env_overrides.rs +0 -0
  42. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/extensions.rs +0 -0
  43. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/filtering.rs +0 -0
  44. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/fragmentation.rs +0 -0
  45. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/git.rs +0 -0
  46. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/graph_filtering.rs +0 -0
  47. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/importance.rs +0 -0
  48. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/mod.rs +0 -0
  49. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/mode.rs +0 -0
  50. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/parsers.rs +0 -0
  51. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/render.rs +0 -0
  52. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/selection.rs +0 -0
  53. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/tokenization.rs +0 -0
  54. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/config/weights.rs +0 -0
  55. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/core.rs +0 -0
  56. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/discovery.rs +0 -0
  57. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/base.rs +0 -0
  58. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/config_edges/build_system.rs +0 -0
  59. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/config_edges/cicd.rs +0 -0
  60. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/config_edges/docker.rs +0 -0
  61. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/config_edges/generic.rs +0 -0
  62. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/config_edges/helm.rs +0 -0
  63. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/config_edges/kubernetes.rs +0 -0
  64. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/config_edges/mod.rs +0 -0
  65. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/document/mod.rs +0 -0
  66. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/history/cochange.rs +0 -0
  67. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/history/mod.rs +0 -0
  68. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/ansible.rs +0 -0
  69. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/bazel.rs +0 -0
  70. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/c_family.rs +0 -0
  71. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/cargo_edges.rs +0 -0
  72. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/clojure.rs +0 -0
  73. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/css.rs +0 -0
  74. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/dart.rs +0 -0
  75. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/dbt.rs +0 -0
  76. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/dotnet.rs +0 -0
  77. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/elixir.rs +0 -0
  78. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/erlang.rs +0 -0
  79. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/go.rs +0 -0
  80. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/graphql.rs +0 -0
  81. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/haskell.rs +0 -0
  82. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/javascript.rs +0 -0
  83. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/julia.rs +0 -0
  84. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/jvm.rs +0 -0
  85. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/latex.rs +0 -0
  86. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/lua.rs +0 -0
  87. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/mod.rs +0 -0
  88. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/nim.rs +0 -0
  89. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/nix.rs +0 -0
  90. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/ocaml.rs +0 -0
  91. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/openapi.rs +0 -0
  92. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/perl.rs +0 -0
  93. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/php.rs +0 -0
  94. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/prisma.rs +0 -0
  95. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/protobuf.rs +0 -0
  96. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/python.rs +0 -0
  97. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/r_lang.rs +0 -0
  98. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/ruby.rs +0 -0
  99. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/rust_lang.rs +0 -0
  100. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/shell.rs +0 -0
  101. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/sql.rs +0 -0
  102. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/swift.rs +0 -0
  103. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/tags.rs +0 -0
  104. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/terraform.rs +0 -0
  105. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/semantic/zig.rs +0 -0
  106. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/similarity/lexical.rs +0 -0
  107. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/similarity/mod.rs +0 -0
  108. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/structural/containment.rs +0 -0
  109. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/structural/mod.rs +0 -0
  110. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/structural/sibling.rs +0 -0
  111. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/edges/structural/testing.rs +0 -0
  112. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/interval.rs +0 -0
  113. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/languages.rs +0 -0
  114. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/lib.rs +0 -0
  115. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/main.rs +0 -0
  116. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/mode.rs +0 -0
  117. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/parsers/config_parser.rs +0 -0
  118. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/parsers/generic.rs +0 -0
  119. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/parsers/markdown.rs +0 -0
  120. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/parsers/mod.rs +0 -0
  121. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/parsers/tree_sitter_strategy.rs +0 -0
  122. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/ppr.rs +0 -0
  123. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/signatures.rs +0 -0
  124. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/stopwords.rs +0 -0
  125. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/test_harness.rs +0 -0
  126. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/tokenizer.rs +0 -0
  127. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/types.rs +0 -0
  128. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/utility/importance.rs +0 -0
  129. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/utility/mod.rs +0 -0
  130. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/utility/needs.rs +0 -0
  131. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/src/utility/scoring.rs +0 -0
  132. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/tests/common/mod.rs +0 -0
  133. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/tests/fixtures/garbage/garbage_api.py +0 -0
  134. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/tests/fixtures/garbage/garbage_constants.py +0 -0
  135. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/tests/fixtures/garbage/garbage_handlers.py +0 -0
  136. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/tests/fixtures/garbage/garbage_models.py +0 -0
  137. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/tests/fixtures/garbage/garbage_module.js +0 -0
  138. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/tests/fixtures/garbage/garbage_services.py +0 -0
  139. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/tests/fixtures/garbage/garbage_types.py +0 -0
  140. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/tests/fixtures/garbage/garbage_unrelated.yaml +0 -0
  141. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/tests/fixtures/garbage/garbage_utils.py +0 -0
  142. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/tests/fixtures/garbage/garbage_validators.py +0 -0
  143. {diffctx-1.10.2 → diffctx-1.11.0}/diffctx/tests/yaml_cases.rs +0 -0
  144. {diffctx-1.10.2 → diffctx-1.11.0}/rust-toolchain.toml +0 -0
  145. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/__init__.py +0 -0
  146. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/__main__.py +0 -0
  147. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/clipboard.py +0 -0
  148. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/diffctx/__init__.py +0 -0
  149. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/diffctx/graph_analytics.py +0 -0
  150. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/diffctx/graph_export.py +0 -0
  151. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/diffctx/project_graph.py +0 -0
  152. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/ignore.py +0 -0
  153. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/logger.py +0 -0
  154. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/mcp/__init__.py +0 -0
  155. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/mcp/__main__.py +0 -0
  156. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/mcp/formatting.py +0 -0
  157. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/mcp/security.py +0 -0
  158. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/py.typed +0 -0
  159. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/tokens.py +0 -0
  160. {diffctx-1.10.2 → diffctx-1.11.0}/src/diffctx/tree.py +0 -0
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.11.0] - 2026-07-07
11
+
12
+ ### Changed
13
+
14
+ - **The default `--format` is now `md` (Markdown), previously `yaml`.** Markdown
15
+ is ~7% more token-efficient than YAML on real diffs and is preferred by
16
+ reviewers for code-fragment-heavy output (evidence:
17
+ `benchmarks/real_world_diff_bench/`). YAML remains available via `-f yaml`.
18
+ This also changes the default stdout format of `treemapper` (a passthrough
19
+ wrapper over this engine); downstream scripts that parse the default output
20
+ must now pass `-f yaml` explicitly. (#104)
21
+
10
22
  ## [1.10.0] - 2026-06-20
11
23
 
12
24
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffctx
3
- Version: 1.10.2
3
+ Version: 1.11.0
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -101,9 +101,9 @@ lines outward and stops as soon as additional context stops paying for itself.
101
101
  ## Install (30 seconds)
102
102
 
103
103
  ```bash
104
- pipx install diffctx # recommended isolated CLI, no venv needed
104
+ uvx diffctx . --diff HEAD~1 # zero-install, run once via uv
105
+ pipx install diffctx # recommended for repeat use — isolated CLI, no venv needed
105
106
  pip install diffctx # or: into an active environment
106
- pipx install 'diffctx[tree-sitter]' # + AST parsing for smarter diff context
107
107
  pipx install 'diffctx[mcp]' # + MCP server for AI assistants
108
108
  ```
109
109
 
@@ -201,7 +201,7 @@ diffctx graph . --level file -f graphml -o g.xml # file-level graph as GraphML
201
201
  <!-- BEGIN USAGE -->
202
202
  ```bash
203
203
  # full codebase export:
204
- diffctx . # YAML to stdout + token count
204
+ diffctx . # Markdown to stdout + token count
205
205
  diffctx . -f md -c # Markdown → clipboard
206
206
  diffctx . -f json -o tree.json # JSON → file
207
207
  diffctx . --no-content # structure only, no file contents
@@ -356,6 +356,18 @@ Auto-discovered files:
356
356
  - `.diffctx/ignore` — diffctx-specific ignore patterns
357
357
  - `.diffctx/whitelist` — Include-only filter (only matched files included)
358
358
 
359
+ ## Exit Codes
360
+
361
+ | Code | Meaning |
362
+ |------|---------|
363
+ | `0` | Success — output contains content |
364
+ | `1` | Runtime error (bad path, permission denied, etc.) |
365
+ | `2` | Usage error (invalid flags/arguments) |
366
+ | `3` | Environment error (`--diff` outside a git repo, git not installed, no commits yet) |
367
+ | `4` | `--diff` produced no semantic context (clean working tree, binary-only, everything filtered out) — output is still emitted, but scripts can detect the empty case. Deletion/rename-only diffs are NOT empty: they list `deleted_files`/`renamed_files` and exit `0` |
368
+ | `130`| Interrupted (Ctrl-C) |
369
+ | `141`| Broken pipe (e.g. piping into `head`) |
370
+
359
371
  ## Content Placeholders
360
372
 
361
373
  - `<file too large: N bytes>` — exceeds `--max-file-bytes`
@@ -24,9 +24,9 @@ lines outward and stops as soon as additional context stops paying for itself.
24
24
  ## Install (30 seconds)
25
25
 
26
26
  ```bash
27
- pipx install diffctx # recommended isolated CLI, no venv needed
27
+ uvx diffctx . --diff HEAD~1 # zero-install, run once via uv
28
+ pipx install diffctx # recommended for repeat use — isolated CLI, no venv needed
28
29
  pip install diffctx # or: into an active environment
29
- pipx install 'diffctx[tree-sitter]' # + AST parsing for smarter diff context
30
30
  pipx install 'diffctx[mcp]' # + MCP server for AI assistants
31
31
  ```
32
32
 
@@ -124,7 +124,7 @@ diffctx graph . --level file -f graphml -o g.xml # file-level graph as GraphML
124
124
  <!-- BEGIN USAGE -->
125
125
  ```bash
126
126
  # full codebase export:
127
- diffctx . # YAML to stdout + token count
127
+ diffctx . # Markdown to stdout + token count
128
128
  diffctx . -f md -c # Markdown → clipboard
129
129
  diffctx . -f json -o tree.json # JSON → file
130
130
  diffctx . --no-content # structure only, no file contents
@@ -279,6 +279,18 @@ Auto-discovered files:
279
279
  - `.diffctx/ignore` — diffctx-specific ignore patterns
280
280
  - `.diffctx/whitelist` — Include-only filter (only matched files included)
281
281
 
282
+ ## Exit Codes
283
+
284
+ | Code | Meaning |
285
+ |------|---------|
286
+ | `0` | Success — output contains content |
287
+ | `1` | Runtime error (bad path, permission denied, etc.) |
288
+ | `2` | Usage error (invalid flags/arguments) |
289
+ | `3` | Environment error (`--diff` outside a git repo, git not installed, no commits yet) |
290
+ | `4` | `--diff` produced no semantic context (clean working tree, binary-only, everything filtered out) — output is still emitted, but scripts can detect the empty case. Deletion/rename-only diffs are NOT empty: they list `deleted_files`/`renamed_files` and exit `0` |
291
+ | `130`| Interrupted (Ctrl-C) |
292
+ | `141`| Broken pipe (e.g. piping into `head`) |
293
+
282
294
  ## Content Placeholders
283
295
 
284
296
  - `<file too large: N bytes>` — exceeds `--max-file-bytes`
@@ -63,9 +63,9 @@ dependencies = [
63
63
 
64
64
  [[package]]
65
65
  name = "anyhow"
66
- version = "1.0.102"
66
+ version = "1.0.103"
67
67
  source = "registry+https://github.com/rust-lang/crates.io-index"
68
- checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
68
+ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
69
69
 
70
70
  [[package]]
71
71
  name = "base64"
@@ -194,7 +194,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
194
194
 
195
195
  [[package]]
196
196
  name = "diffctx"
197
- version = "1.10.0"
197
+ version = "1.11.0"
198
198
  dependencies = [
199
199
  "anyhow",
200
200
  "clap",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "diffctx"
3
- version = "1.10.0"
3
+ version = "1.11.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.85"
6
6
 
@@ -190,9 +190,7 @@ pub fn quotient_graph(
190
190
  None => return,
191
191
  };
192
192
  let cat = graph
193
- .edge_categories
194
- .get(&(src.clone(), dst.clone()))
195
- .copied()
193
+ .edge_category(src, dst)
196
194
  .unwrap_or(EdgeCategory::Generic);
197
195
 
198
196
  if src_key == dst_key {
@@ -415,15 +413,15 @@ pub fn hotspots(
415
413
  }
416
414
 
417
415
  let mut out_deg: FxHashMap<Arc<str>, u32> = FxHashMap::default();
418
- for ((src, _dst), cat) in &graph.edge_categories {
416
+ graph.for_each_categorized_edge(|src, _dst, cat| {
419
417
  if let Some(filter) = edge_types
420
- && !filter.contains(cat)
418
+ && !filter.contains(&cat)
421
419
  {
422
- continue;
420
+ return;
423
421
  }
424
422
  let rel: Arc<str> = Arc::from(relative_path(src.path.as_ref(), root));
425
423
  *out_deg.entry(rel).or_insert(0) += 1;
426
- }
424
+ });
427
425
 
428
426
  let max_deg = out_deg.values().copied().max().unwrap_or(0).max(1);
429
427
  let max_churn = churn
@@ -601,7 +599,7 @@ mod tests {
601
599
  }
602
600
  for (s, d, w, c) in edges {
603
601
  g.add_edge(s.clone(), d.clone(), *w);
604
- g.edge_categories.insert((s.clone(), d.clone()), *c);
602
+ g.insert_edge_category(s.clone(), d.clone(), *c);
605
603
  }
606
604
  g.freeze();
607
605
  g
@@ -28,7 +28,10 @@ impl Default for AlgorithmLimits {
28
28
  max_generated_lines: 30,
29
29
  skip_expensive_threshold: 2000,
30
30
  rare_identifier_threshold: 3,
31
- overhead_per_fragment: 18,
31
+ // Measured against real YAML serialization (path/lines/kind/symbol
32
+ // keys + quoting): actual scaffold runs ~40-45 tokens/fragment,
33
+ // not 18 - the old estimate let --budget overshoot by ~18%.
34
+ overhead_per_fragment: 40,
32
35
  }
33
36
  }
34
37
  }
@@ -1,5 +1,7 @@
1
1
  use once_cell::sync::Lazy;
2
2
 
3
+ use super::env_overrides::read_env_fraction;
4
+
3
5
  pub struct NeedsConfig {
4
6
  pub min_symbol_length: usize,
5
7
  pub background_min_ident_length: usize,
@@ -54,8 +56,8 @@ impl Default for NeedsConfig {
54
56
  test_mentions: 0.6,
55
57
  mentions_fallback: 0.3,
56
58
 
57
- min_rel_for_bonus: 0.03,
58
- relatedness_bonus: 0.25,
59
+ min_rel_for_bonus: read_env_fraction("DIFFCTX_MIN_REL_FOR_BONUS", 0.03),
60
+ relatedness_bonus: read_env_fraction("DIFFCTX_RELATEDNESS_BONUS", 0.25),
59
61
  }
60
62
  }
61
63
  }
@@ -1,6 +1,6 @@
1
1
  use once_cell::sync::Lazy;
2
2
 
3
- use super::env_overrides::read_env_open_fraction;
3
+ use super::env_overrides::{read_env_fraction, read_env_open_fraction};
4
4
 
5
5
  pub struct EgoScoringConfig {
6
6
  pub identifier_overlap_epsilon: f64,
@@ -11,7 +11,7 @@ pub struct EgoScoringConfig {
11
11
  impl Default for EgoScoringConfig {
12
12
  fn default() -> Self {
13
13
  Self {
14
- identifier_overlap_epsilon: 0.1,
14
+ identifier_overlap_epsilon: read_env_fraction("DIFFCTX_EGO_LEXICAL_EPS", 0.1),
15
15
  identifier_overlap_cap: 10,
16
16
  per_hop_decay: read_env_open_fraction("DIFFCTX_EGO_PER_HOP_DECAY", 0.5),
17
17
  }
@@ -12,7 +12,9 @@ use rayon::prelude::*;
12
12
  use rustc_hash::FxHashMap;
13
13
  use tracing::debug;
14
14
 
15
- use crate::graph::EdgeCategory;
15
+ use crate::graph::{
16
+ CompactEdge, CompactEdges, EdgeCategory, dedup_compact_edges, intern_fragment_nodes,
17
+ };
16
18
  use crate::types::FragmentId;
17
19
 
18
20
  pub type EdgeDict = FxHashMap<(FragmentId, FragmentId), f64>;
@@ -66,11 +68,18 @@ pub fn get_all_builders() -> Vec<Box<dyn EdgeBuilder>> {
66
68
  all
67
69
  }
68
70
 
71
+ /// Collect edges from all builders directly into the interned compact
72
+ /// representation. Each builder's FragmentId-keyed map is converted and
73
+ /// dropped inside the parallel closure, so at no point does the full
74
+ /// edge universe exist as string-keyed hashmaps — the peak that used to
75
+ /// OOM-kill multi-million-edge instances. Merge semantics match the
76
+ /// historical maps exactly: max weight across builders, category from
77
+ /// the first builder (in registration order) that produced the edge.
69
78
  pub fn collect_all_edges(
70
79
  fragments: &[Fragment],
71
80
  repo_root: Option<&Path>,
72
81
  skip_expensive: bool,
73
- ) -> (EdgeDict, EdgeCategories) {
82
+ ) -> CompactEdges {
74
83
  let mut all_builders: Vec<(&str, Box<dyn EdgeBuilder>)> = Vec::new();
75
84
  for cat in builder_categories() {
76
85
  if skip_expensive && EXPENSIVE_CATEGORIES.contains(&cat.name) {
@@ -82,39 +91,48 @@ pub fn collect_all_edges(
82
91
  }
83
92
  }
84
93
 
94
+ let (node_to_idx, idx_to_node) = intern_fragment_nodes(fragments);
85
95
  let category_weights = *crate::config::category_weights::CATEGORY_WEIGHTS;
86
- let per_builder_edges: Vec<(EdgeDict, Vec<((FragmentId, FragmentId), EdgeCategory)>)> =
87
- all_builders
88
- .par_iter()
89
- .map(|(cat_name, builder)| {
90
- let cat_label = builder.category_label().unwrap_or(cat_name);
91
- let category = EdgeCategory::from_str(cat_label);
92
- let multiplier = category_weights.multiplier(category);
93
- let mut edges = builder.build(fragments, repo_root);
94
- if multiplier != 1.0 {
95
- for w in edges.values_mut() {
96
- *w *= multiplier;
97
- }
98
- }
99
- let cats: Vec<_> = edges.keys().map(|k| (k.clone(), category)).collect();
100
- (edges, cats)
101
- })
102
- .collect();
103
-
104
- let mut all_edges: EdgeDict = FxHashMap::default();
105
- let mut edge_categories: EdgeCategories = FxHashMap::default();
106
- for (edges, cats) in per_builder_edges {
107
- for ((src, dst), weight) in edges {
108
- if weight > *all_edges.get(&(src.clone(), dst.clone())).unwrap_or(&0.0) {
109
- all_edges.insert((src.clone(), dst.clone()), weight);
96
+ let per_builder_edges: Vec<Vec<CompactEdge>> = all_builders
97
+ .par_iter()
98
+ .map(|(cat_name, builder)| {
99
+ let cat_label = builder.category_label().unwrap_or(cat_name);
100
+ let category = EdgeCategory::from_str(cat_label);
101
+ let multiplier = category_weights.multiplier(category);
102
+ let edges = builder.build(fragments, repo_root);
103
+ let mut compact = Vec::with_capacity(edges.len());
104
+ for ((src, dst), weight) in edges {
105
+ let s = match node_to_idx.get(&src) {
106
+ Some(&i) => i,
107
+ None => continue,
108
+ };
109
+ let d = match node_to_idx.get(&dst) {
110
+ Some(&i) => i,
111
+ None => continue,
112
+ };
113
+ compact.push(CompactEdge {
114
+ src: s,
115
+ dst: d,
116
+ weight: weight * multiplier,
117
+ category,
118
+ });
110
119
  }
111
- }
112
- for (key, cat) in cats {
113
- edge_categories.entry(key).or_insert(cat);
114
- }
120
+ compact
121
+ })
122
+ .collect();
123
+
124
+ let total: usize = per_builder_edges.iter().map(|v| v.len()).sum();
125
+ let mut edges: Vec<CompactEdge> = Vec::with_capacity(total);
126
+ for v in per_builder_edges {
127
+ edges.extend(v);
115
128
  }
129
+ dedup_compact_edges(&mut edges);
116
130
 
117
- (all_edges, edge_categories)
131
+ CompactEdges {
132
+ node_to_idx,
133
+ idx_to_node,
134
+ edges,
135
+ }
118
136
  }
119
137
 
120
138
  pub fn discover_all_related_files(
@@ -85,14 +85,14 @@ fn classify_semantic_edges(
85
85
  let mut reverse_deps: FxHashMap<Arc<str>, FxHashSet<Arc<str>>> = FxHashMap::default();
86
86
  let mut direct_edge_paths: FxHashSet<Arc<str>> = FxHashSet::default();
87
87
 
88
- for ((src, dst), category) in &graph.edge_categories {
89
- if *category != EdgeCategory::Semantic {
90
- continue;
88
+ graph.for_each_categorized_edge(|src, dst, category| {
89
+ if category != EdgeCategory::Semantic {
90
+ return;
91
91
  }
92
92
  let src_changed = changed_paths.contains(&src.path);
93
93
  let dst_changed = changed_paths.contains(&dst.path);
94
94
  if !(src_changed ^ dst_changed) {
95
- continue;
95
+ return;
96
96
  }
97
97
 
98
98
  let (changed_frag, other_frag) = if src_changed { (src, dst) } else { (dst, src) };
@@ -112,7 +112,7 @@ fn classify_semantic_edges(
112
112
  } else {
113
113
  direct_edge_paths.insert(other_frag.path.clone());
114
114
  }
115
- }
115
+ });
116
116
 
117
117
  (reverse_deps, direct_edge_paths)
118
118
  }
@@ -172,11 +172,11 @@ fn find_config_generic_code_files(
172
172
  })
173
173
  .collect();
174
174
 
175
- for ((src, dst), category) in &graph.edge_categories {
175
+ graph.for_each_categorized_edge(|src, dst, category| {
176
176
  let src_changed = changed_paths.contains(&src.path);
177
177
  let dst_changed = changed_paths.contains(&dst.path);
178
178
  if !(src_changed ^ dst_changed) {
179
- continue;
179
+ return;
180
180
  }
181
181
  let other_path = if src_changed { &dst.path } else { &src.path };
182
182
  match category {
@@ -189,7 +189,7 @@ fn find_config_generic_code_files(
189
189
  }
190
190
  _ => {}
191
191
  }
192
- }
192
+ });
193
193
 
194
194
  let generic_only: FxHashSet<Arc<str>> = has_generic_config
195
195
  .difference(&has_real_edge)
@@ -293,7 +293,18 @@ pub fn process_files_for_fragments(
293
293
  // truncation), which can drop the small fragment covering the
294
294
  // edited hunk before core identification runs.
295
295
  let generated = !is_changed && is_generated_file(file_path, content);
296
- let cap = if generated { max_generated } else { max_frags };
296
+ // Changed files also get 10x cap headroom: the biggest-N
297
+ // truncation below keeps the LONGEST fragments, and the
298
+ // edited hunk is typically a small leaf that would be
299
+ // dropped first (hunks are only known later, in core
300
+ // identification). max_changed_file_size still bounds cost.
301
+ let cap = if generated {
302
+ max_generated
303
+ } else if is_changed {
304
+ max_frags.saturating_mul(10)
305
+ } else {
306
+ max_frags
307
+ };
297
308
  if raw_frags.len() > cap {
298
309
  raw_frags.sort_by(|a, b| b.line_count().cmp(&a.line_count()));
299
310
  raw_frags.truncate(cap);
@@ -143,6 +143,21 @@ pub fn is_git_repo(path: &Path) -> bool {
143
143
  run_git(path, &["rev-parse", "--git-dir"]).is_ok()
144
144
  }
145
145
 
146
+ /// Resolves the actual working-tree root for `path`, which may be a
147
+ /// subdirectory of the repository. `git diff`/`git cat-file` paths are
148
+ /// always reported relative to this root, not to an arbitrary `-C` cwd -
149
+ /// running the pipeline with `path` still set to a subdirectory silently
150
+ /// produces zero fragments because file lookups get double-prefixed
151
+ /// (e.g. `src/src/app.py`).
152
+ pub fn find_toplevel(path: &Path) -> Option<PathBuf> {
153
+ let out = run_git(path, &["rev-parse", "--show-toplevel"]).ok()?;
154
+ let trimmed = out.trim();
155
+ if trimmed.is_empty() {
156
+ return None;
157
+ }
158
+ Some(PathBuf::from(trimmed))
159
+ }
160
+
146
161
  pub fn get_diff_text(repo_root: &Path, diff_range: Option<&str>) -> Result<String> {
147
162
  let mut args: Vec<&str> = vec!["diff"];
148
163
  args.extend_from_slice(SAFE_DIFF_FLAGS);
@@ -455,6 +470,41 @@ pub fn get_renamed_paths(
455
470
  Ok((old_paths, pure_new_paths))
456
471
  }
457
472
 
473
+ /// Rename pairs as repo-relative display paths (`old -> new`), for the output
474
+ /// header. Unlike `get_renamed_paths` this preserves the pairing and does not
475
+ /// canonicalize (the old path no longer exists on disk).
476
+ pub fn get_rename_pairs(
477
+ repo_root: &Path,
478
+ diff_range: Option<&str>,
479
+ ) -> Result<Vec<(String, String)>> {
480
+ let mut args: Vec<&str> = vec!["diff"];
481
+ args.extend_from_slice(SAFE_DIFF_FLAGS);
482
+ args.extend_from_slice(&["--diff-filter=R", "--name-status", "-M", "-z"]);
483
+ if let Some(range) = diff_range {
484
+ validate_diff_range(range)?;
485
+ args.push(range);
486
+ }
487
+ let output = run_git(repo_root, &args)?;
488
+ let parts: Vec<&str> = output.split('\0').collect();
489
+
490
+ let mut pairs = Vec::new();
491
+ let mut i = 0;
492
+ while i < parts.len() {
493
+ if parts[i].starts_with('R') {
494
+ if i + 2 < parts.len() && !parts[i + 1].is_empty() && !parts[i + 2].is_empty() {
495
+ pairs.push((
496
+ parts[i + 1].replace('\\', "/"),
497
+ parts[i + 2].replace('\\', "/"),
498
+ ));
499
+ }
500
+ i += 3;
501
+ } else {
502
+ i += 1;
503
+ }
504
+ }
505
+ Ok(pairs)
506
+ }
507
+
458
508
  pub fn split_diff_range(range: &str) -> (Option<String>, Option<String>) {
459
509
  match RANGE_RE.captures(range) {
460
510
  None => (None, None),