diffctx 1.12.1 → 1.12.3

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.
package/README.md CHANGED
@@ -3,15 +3,16 @@
3
3
  [![CI](https://github.com/nikolay-e/diffctx/actions/workflows/ci.yml/badge.svg)](https://github.com/nikolay-e/diffctx/actions/workflows/ci.yml)
4
4
  [![PyPI](https://img.shields.io/pypi/v/diffctx)](https://pypi.org/project/diffctx/)
5
5
  [![crates.io](https://img.shields.io/crates/v/diffctx)](https://crates.io/crates/diffctx)
6
+ [![npm](https://img.shields.io/npm/v/diffctx)](https://www.npmjs.com/package/diffctx)
6
7
  [![License](https://img.shields.io/pypi/l/diffctx)](https://pypi.org/project/diffctx/)
7
8
 
8
9
  **diffctx selects the minimum code an LLM needs to review a git diff.**
9
- Instead of pasting whole files, it walks the dependency graph from the changed
10
- lines outward and stops as soon as additional context stops paying for itself.
10
+ Instead of pasting whole files, it walks the dependency graph outward from the
11
+ changed lines and stops once more context stops paying for itself.
11
12
 
12
13
  > Coming from [`treemapper`](https://pypi.org/project/treemapper/)? That name is
13
- > deprecated — it was a thin wrapper around diffctx. Every command, flag, and
14
- > API call works unchanged: `treemapper` → `diffctx`, `treemapper-mcp` → `diffctx-mcp`.
14
+ > deprecated. Every command, flag, and API call works unchanged:
15
+ > `treemapper` → `diffctx`, `treemapper-mcp` → `diffctx-mcp`.
15
16
 
16
17
  ## Why not just use `tree` or repomix?
17
18
 
@@ -26,6 +27,9 @@ lines outward and stops as soon as additional context stops paying for itself.
26
27
  | Python API | ✗ | ✗ | ✗ | ✓ |
27
28
  | MCP server | ✗ | ✗ | ✗ | ✓ |
28
29
 
30
+ Fuller positioning — measured results, and when a whole-repo packer or a
31
+ persistent code-graph server fits better: [COMPARISON.md](COMPARISON.md).
32
+
29
33
  ## Install
30
34
 
31
35
  ```bash
@@ -35,21 +39,32 @@ pip install diffctx # or: into an active environment
35
39
  pipx install 'diffctx[mcp]' # + MCP server for AI assistants
36
40
  ```
37
41
 
38
- The `[tree-sitter]` extra adds AST-level parsing for more accurate context
39
- selection across 30+ languages.
40
-
41
42
  Without Python:
42
43
 
43
44
  ```bash
44
45
  cargo install diffctx # native CLI from crates.io
46
+ npx diffctx . --diff HEAD~1 # npm wrapper over the native binary
45
47
  docker run --rm -v "$PWD:/repo" ghcr.io/nikolay-e/diffctx . --diff HEAD~1
46
48
  ```
47
49
 
50
+ On Windows, via Scoop (this repository is the bucket):
51
+
52
+ ```powershell
53
+ scoop bucket add diffctx https://github.com/nikolay-e/diffctx
54
+ scoop install diffctx/diffctx
55
+ ```
56
+
57
+ The image runs as a non-root user (uid 10001) and writes to stdout — the
58
+ native binary has no `-o` flag, so redirect to capture: `... --diff HEAD~1 >
59
+ context.yaml`.
60
+
48
61
  Prebuilt binaries for linux (x86_64/aarch64), macOS (arm64) and Windows (x64)
49
62
  are attached to every [release](https://github.com/nikolay-e/diffctx/releases/latest).
50
63
  The native binary covers diff mode with YAML/JSON output; tree mode, Markdown
51
64
  output, the `graph` subcommand and the MCP server live in the Python package.
52
- `cargo add diffctx` embeds the selection pipeline in a Rust project
65
+ `cargo add diffctx` embeds the pipeline in a Rust project — the library is
66
+ imported as `_diffctx` (`use _diffctx::pipeline::build_diff_context`), since
67
+ the crate doubles as the Python extension module
53
68
  ([docs.rs](https://docs.rs/diffctx)).
54
69
 
55
70
  ## Quick start
@@ -59,32 +74,34 @@ diffctx . --diff HEAD~1 # smart context for last commit → paste into Cla
59
74
  diffctx . -f md -c # full codebase export → clipboard in Markdown
60
75
  ```
61
76
 
62
- ![diffctx demo](https://raw.githubusercontent.com/nikolay-e/diffctx/main/docs/demo.gif)
77
+ ![diffctx demo](https://raw.githubusercontent.com/nikolay-e/diffctx/main/docs/demo/demo.gif)
63
78
 
64
- *`diffctx . --diff HEAD~1` selects only the fragments — functions, imports,
65
- type definitions — that an LLM actually needs to review the last commit,
66
- instead of dumping every changed file in full.*
79
+ *`diffctx . --diff HEAD~1` selects only the fragments an LLM needs to review the
80
+ last commit, instead of dumping every changed file in full.*
67
81
 
68
82
  ## Diff context mode
69
83
 
70
- Finds the minimal set of code fragments needed to understand a change —
71
- imports, callers, type definitions, config dependencies — across 50+ file
72
- types. It builds a code graph (imports, co-changes, type refs), propagates
73
- relevance from the changed lines outward, and stops when relevance drops below
74
- `--tau` or the `--budget` token cap is reached.
84
+ Finds the minimal set of fragments needed to understand a change — imports,
85
+ callers, type definitions, config dependencies — across 50+ file types. It
86
+ builds a code graph (imports, co-changes, type refs), propagates relevance
87
+ outward from the changed lines, and stops when relevance drops below `--tau` or
88
+ the `--budget` token cap is hit.
75
89
 
76
90
  | Flag | Default | Description |
77
91
  |-------------|---------|--------------------------------------------------------------------------|
78
92
  | `--scoring` | `ego` | `ego` = bounded expansion around changed nodes (fast, predictable radius); `ppr` = Personalized PageRank (global, smoother decay, slower); `bm25` = lexical retrieval against the diff hunks (baseline for sparse graphs) |
79
- | `--budget` | auto | Hard token cap: `N` enforces a fixed cap, `-1` disables it, `0` is a strict-zero floor (empty selection; use `--full` for changed files only) |
93
+ | `--budget` | auto | Hard cap in o200k_base tokens (see [Token counting](docs/product/token-budget.md)): `N` enforces a fixed cap, `-1` disables it, `0` is a strict-zero floor (empty selection; use `--full` for changed files only) |
80
94
  | `--alpha` | 0.60 | PPR damping; higher = context clusters tighter around changes (`--scoring ppr` only) |
81
95
  | `--tau` | 0.12 | Relevance threshold for full fragment content; lower-scoring fragments are stubbed or dropped (lower = more context) |
82
96
  | `--full` | false | Only the changed files, every fragment, no related-code context |
83
97
  | `--timeout` | 300 | Wall-clock deadline in seconds; on expiry diffctx exits 124 instead of hanging |
98
+ | `--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 |
84
99
 
85
100
  Calibration of `--alpha`, `--tau`, and the edge-weight priors:
86
- [`docs/parameter-strategy.md`](docs/parameter-strategy.md). Theory:
87
- [Context-Selection for Git Diff (Zenodo, 2026)](https://doi.org/10.5281/zenodo.18824580).
101
+ [`docs/engineering/parameter-strategy.md`](docs/engineering/parameter-strategy.md).
102
+ Theory:
103
+ [diffctx: Budgeted Typed-Graph Retrieval for Diff-Aware Code Context
104
+ Selection (Zenodo, 2026)](https://doi.org/10.5281/zenodo.18824579).
88
105
 
89
106
  ### `graph` subcommand
90
107
 
@@ -102,28 +119,34 @@ diffctx graph . --level file -f graphml -o g.xml # file-level graph as GraphML
102
119
  <!-- BEGIN USAGE -->
103
120
  ```bash
104
121
  # full codebase export:
105
- diffctx . # Markdown to stdout + token count
106
- diffctx . -f md -c # Markdown → clipboard
107
- diffctx . -f json -o tree.json # JSON → file
108
- diffctx . --no-content # structure only, no file contents
109
- diffctx . --max-depth 3 # limit depth
110
- diffctx . -i custom.ignore # custom ignore patterns
122
+ diffctx . # Markdown to stdout + token count
123
+ diffctx . -f md -c # Markdown → clipboard
124
+ diffctx . -f json -o tree.json # JSON → file
125
+ diffctx . --no-content # structure only, no file contents
126
+ diffctx . --max-depth 3 # limit depth
127
+ diffctx . -i custom.ignore # custom ignore patterns
111
128
 
112
129
  # diff context mode (requires git repo):
113
- diffctx . --diff # uncommitted changes (working tree vs HEAD)
114
- diffctx . --diff HEAD~1 # context for last commit
115
- diffctx . --diff main..feature # context for feature branch
116
- diffctx . --diff HEAD~1 --budget 30000 # limit to ~30k tokens
117
- diffctx . --diff HEAD~1 -c # diff context to clipboard
130
+ diffctx . --diff # uncommitted changes (working tree vs HEAD)
131
+ diffctx . --diff HEAD~1 # context for last commit
132
+ diffctx . --diff main..feature # context for feature branch
133
+ diffctx . --diff HEAD~1 --budget 30000 # limit to ~30k tokens
134
+ diffctx . --diff HEAD~1 -c # diff context to clipboard
135
+ diffctx . --diff HEAD~1 --with-raw-diff # raw patch + selected context
118
136
  ```
119
137
  <!-- END USAGE -->
120
138
 
121
139
  Every run reports token count and size on stderr — `12,847 tokens
122
- (o200k_base), 52.3 KB` (tiktoken, the GPT-4o tokenizer; `~`-prefixed
123
- approximation above 1 MB). `-c/--copy` sends output to the clipboard via
124
- `pbcopy` (macOS), `clip` (Windows), or `wl-copy`/`xclip`/`xsel` (Linux).
125
- Unreadable files are replaced by placeholders such as `<binary file: N bytes>`,
126
- `<file too large: N bytes>`, or `<unreadable content: not utf-8>`.
140
+ (o200k_base), 52.3 KB` (tiktoken, the GPT-4o tokenizer; `~`-prefixed above
141
+ 1 MB). `-c/--copy` copies output via `pbcopy` (macOS), `clip` (Windows), or
142
+ `wl-copy`/`xclip`/`xsel` (Linux). Unreadable files become placeholders like
143
+ `<binary file: N bytes>`, `<file too large: N bytes>`, or
144
+ `<unreadable content: not utf-8>`.
145
+
146
+ Counts come from tiktoken's `o200k_base` encoder and are exact only for the
147
+ GPT-4o family; Claude, Gemini and others tokenize differently, so treat
148
+ `--budget` as an upper bound in o200k tokens and leave headroom. Details:
149
+ [`docs/product/token-budget.md`](docs/product/token-budget.md).
127
150
 
128
151
  ## Python API
129
152
 
@@ -140,6 +163,7 @@ ctx = build_diff_context(
140
163
  full=False,
141
164
  scoring_mode="ego",
142
165
  timeout=300,
166
+ with_raw_diff=False, # True also embeds the raw unified diff (not charged to budget)
143
167
  )
144
168
  print(to_markdown(ctx))
145
169
 
@@ -159,32 +183,48 @@ print(to_yaml(tree))
159
183
 
160
184
  diffctx includes an [MCP](https://modelcontextprotocol.io) server that lets AI
161
185
  assistants (Claude Code, Cursor, Windsurf, etc.) call diff context analysis
162
- automatically during code review. Install with `pip install 'diffctx[mcp]'`
163
- and add to your MCP client config (e.g. `~/.claude/mcp.json` for Claude Code):
164
-
165
- ```json
166
- {
167
- "mcpServers": {
168
- "diffctx": {
169
- "command": "diffctx-mcp"
170
- }
171
- }
172
- }
186
+ automatically during code review. Install with `pip install 'diffctx[mcp]'`,
187
+ then register it — for Claude Code:
188
+
189
+ ```bash
190
+ claude mcp add diffctx -- diffctx-mcp
173
191
  ```
174
192
 
175
- The server exposes a `get_diff_context` tool that assistants call when
176
- reviewing PRs, explaining changes, or investigating broken tests. Configs for
193
+ The server exposes three tools — `get_diff_context`, `get_tree_map`, and
194
+ `get_file_context` — that assistants call when reviewing PRs, explaining
195
+ changes, or investigating broken tests. Tool reference and configs for
177
196
  Cursor, Continue, Windsurf, and Zed:
178
197
  [`src/diffctx/mcp/README.md`](src/diffctx/mcp/README.md).
179
198
 
180
199
  ## Ignore patterns
181
200
 
182
201
  Respects `.gitignore` and `.diffctx/ignore` automatically — hierarchically at
183
- every directory level, with gitignore semantics (negation `!important.log`,
184
- anchored `/root_only.txt`). `.diffctx/whitelist` acts as an include-only
185
- filter, and the output file is always auto-ignored. `--no-default-ignores`
186
- disables the built-in patterns; `--no-ignores` disables all ignore rules
187
- (tree mode only).
202
+ every directory level, with full gitignore semantics (negation `!important.log`,
203
+ anchored `/root_only.txt`). `.diffctx/whitelist` acts as an include-only filter,
204
+ and the output file is always auto-ignored. `--no-default-ignores` disables the
205
+ built-in patterns; `--no-ignores` disables all ignore rules (tree mode only).
206
+
207
+ An excluded path never appears in the output in any role: in diff mode it is
208
+ dropped both from `changed_files` and from the candidate universe, so it cannot
209
+ come back as a related-context fragment either (including under `--full`). The
210
+ same guarantee covers secret-like paths (`id_rsa`, `*.pem`, `*.key`, ...),
211
+ which are filtered even without an ignore entry.
212
+
213
+ ## Token cache
214
+
215
+ Diff mode caches per-blob tokenization under
216
+ `~/Library/Caches/diffctx/token-cache` (macOS),
217
+ `$XDG_CACHE_HOME/diffctx/token-cache` (Linux) or
218
+ `%LOCALAPPDATA%\diffctx\token-cache` (Windows). It is a pure speedup: deleting
219
+ it only costs one cold run.
220
+
221
+ | Variable | Effect |
222
+ |----------|--------|
223
+ | `DIFFCTX_TOKEN_CACHE_DIR` | Relocate the cache |
224
+ | `DIFFCTX_TOKEN_CACHE_MAX_BYTES` | Size cap, default `536870912` (512 MB); `0` disables eviction |
225
+
226
+ Eviction is amortized: each run trims one of the cache's 256 shards back under
227
+ its share of the cap, oldest entries first.
188
228
 
189
229
  ## Exit codes
190
230
 
@@ -194,17 +234,38 @@ disables the built-in patterns; `--no-ignores` disables all ignore rules
194
234
  | `1` | Runtime error (bad path, permission denied, etc.) |
195
235
  | `2` | Usage error (invalid flags/arguments) |
196
236
  | `3` | Environment error (`--diff` outside a git repo, git not installed, no commits yet) |
197
- | `4` | `--diff` produced no semantic context (clean tree, binary-only, everything filtered); output is still emitted. Deletion/rename-only diffs list `deleted_files`/`renamed_files` and exit `0` |
237
+ | `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` |
238
+ | `124`| `--diff` exceeded the `--timeout` wall-clock deadline |
198
239
  | `130`| Interrupted (Ctrl-C) |
199
240
  | `141`| Broken pipe (e.g. piping into `head`) |
200
241
 
242
+ ## Repository layout
243
+
244
+ Rust product code lives in `crates/diffctx-native/`; the Python CLI and MCP
245
+ package live in `src/diffctx/`. Evaluation code, immutable datasets, and paper
246
+ artifacts are intentionally separated under `eval/`, `datasets/`, and `paper/`.
247
+ See [repository ownership boundaries](docs/architecture/repository-layout.md)
248
+ for the lifecycle and entry point of each area.
249
+
201
250
  ## License
202
251
 
203
252
  Apache 2.0
204
253
 
254
+ <!-- mcp-name: io.github.nikolay-e/diffctx -->
255
+ <!-- Ownership marker read from the PyPI description by the MCP registry. -->
256
+ <!-- Must survive edits verbatim: one space after the colon, case-sensitive. -->
257
+
205
258
  ---
206
259
 
260
+ - [Documentation site](https://nikolay-e.github.io/diffctx/) — the pipeline
261
+ end to end: diff → fragments → graph → relevance → selection
262
+ - [GitHub Action](docs/product/github-action.md) — diff context as a CI step
263
+ for LLM review
264
+ - [Token counting](docs/product/token-budget.md) — which encoder, and what
265
+ `--budget` means for non-GPT models
266
+ - [Comparison](COMPARISON.md) — measured results, and when a whole-repo packer
267
+ or a persistent code-graph server fits better
207
268
  - [Changelog](CHANGELOG.md)
208
269
  - [Security policy](SECURITY.md) — threat model and vulnerability reporting
209
- - [Parameter strategy](docs/parameter-strategy.md) — how `--alpha`,
270
+ - [Parameter strategy](docs/engineering/parameter-strategy.md) — how `--alpha`,
210
271
  `--tau`, and edge weights are calibrated
package/bin/diffctx.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const fs = require('fs');
5
- const path = require('path');
6
- const { spawnSync } = require('child_process');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
7
 
8
8
  const binaryName = process.platform === 'win32' ? 'diffctx.exe' : 'diffctx';
9
9
  const binaryPath = path.join(__dirname, binaryName);
package/checksums.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "diffctx-1.12.1-aarch64-apple-darwin.tar.gz": "93eec66793bbf7f7d62f4288e990852da7d7a6ab79d3b6b29bea03ae2ac7e434",
3
- "diffctx-1.12.1-aarch64-unknown-linux-gnu.tar.gz": "55610bfe7856f96a73e87451eddc1aa1a546b84ddc2778cf67738e2333f16f02",
4
- "diffctx-1.12.1-x86_64-pc-windows-msvc.zip": "731177d8af8807e7bc1ba7f021a1723c48da3dfc75bb544415b55b268d5f03b4",
5
- "diffctx-1.12.1-x86_64-unknown-linux-gnu.tar.gz": "f37da41a6381497c15df881f35b8caf324d8ac5fa331118d2736537a246a71b1"
2
+ "diffctx-1.12.3-aarch64-apple-darwin.tar.gz": "d62da2cd29e08130dc82936080d0b5734dc91bf392b9d7beb7696d7869bbd56c",
3
+ "diffctx-1.12.3-aarch64-unknown-linux-gnu.tar.gz": "a064e1edf242ddd448d8ed12445dacdcc32c5084ee82c62d54854e3b6b15abca",
4
+ "diffctx-1.12.3-x86_64-pc-windows-msvc.zip": "daf1064d4a4983305812443f79cb9a3d350c53468a513a127a4d5f079a19489c",
5
+ "diffctx-1.12.3-x86_64-unknown-linux-gnu.tar.gz": "83019a9eb2fa8a49eaa7999f5f6f9d88c72a88719c6ab456135ef0ed38f6f7fa"
6
6
  }
package/install.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- const fs = require('fs');
5
- const os = require('os');
6
- const path = require('path');
7
- const crypto = require('crypto');
8
- const https = require('https');
9
- const { execFileSync } = require('child_process');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const crypto = require('node:crypto');
8
+ const https = require('node:https');
9
+ const { execFileSync } = require('node:child_process');
10
10
 
11
11
  const { version } = require('./package.json');
12
12
  const checksums = require('./checksums.json');
@@ -65,16 +65,33 @@ function verify(archivePath, expectedSha256) {
65
65
  }
66
66
  }
67
67
 
68
+ function systemBinary(candidates, name) {
69
+ const found = candidates.find((candidate) => fs.existsSync(candidate));
70
+ if (!found) {
71
+ throw new Error(`cannot locate ${name}; expected one of ${candidates.join(', ')}`);
72
+ }
73
+ return found;
74
+ }
75
+
68
76
  function extract(archivePath, archiveKind, destinationDir) {
69
77
  if (archiveKind === 'zip') {
78
+ const systemRoot = process.env.SystemRoot || String.raw`C:\Windows`;
79
+ const powershell = systemBinary(
80
+ [
81
+ path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
82
+ path.join(systemRoot, 'SysWOW64', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
83
+ ],
84
+ 'powershell.exe'
85
+ );
70
86
  execFileSync(
71
- 'powershell',
87
+ powershell,
72
88
  ['-NoProfile', '-Command', `Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${destinationDir}' -Force`],
73
89
  { stdio: 'inherit' }
74
90
  );
75
91
  return;
76
92
  }
77
- execFileSync('tar', ['-xzf', archivePath, '-C', destinationDir], { stdio: 'inherit' });
93
+ const tar = systemBinary(['/usr/bin/tar', '/bin/tar'], 'tar');
94
+ execFileSync(tar, ['-xzf', archivePath, '-C', destinationDir], { stdio: 'inherit' });
78
95
  }
79
96
 
80
97
  async function main() {
@@ -96,10 +113,12 @@ async function main() {
96
113
  extract(archivePath, archive, workDir);
97
114
 
98
115
  const binaryName = process.platform === 'win32' ? 'diffctx.exe' : 'diffctx';
99
- fs.copyFileSync(path.join(workDir, binaryName), path.join(binDir, binaryName));
100
- if (process.platform !== 'win32') {
101
- fs.chmodSync(path.join(binDir, binaryName), 0o755);
102
- }
116
+ const extracted = path.join(workDir, binaryName);
117
+ const installed = path.join(binDir, binaryName);
118
+ fs.copyFileSync(extracted, installed);
119
+ // Carry over the archive's mode instead of a hardcoded literal, so the
120
+ // binary stays executable without widening permissions beyond the release.
121
+ fs.chmodSync(installed, fs.statSync(extracted).mode & 0o777);
103
122
  fs.rmSync(workDir, { recursive: true, force: true });
104
123
  }
105
124
 
package/package.json CHANGED
@@ -1,13 +1,21 @@
1
1
  {
2
2
  "name": "diffctx",
3
- "version": "1.12.1",
3
+ "mcpName": "io.github.nikolay-e/diffctx",
4
+ "version": "1.12.3",
4
5
  "description": "Selects the minimum code an LLM needs to review a git diff",
5
6
  "keywords": [
6
7
  "git",
7
8
  "diff",
8
9
  "llm",
9
10
  "code-review",
10
- "context"
11
+ "context",
12
+ "mcp",
13
+ "code-context",
14
+ "context-engineering",
15
+ "ai-agents",
16
+ "diff-context",
17
+ "llm-context",
18
+ "codebase"
11
19
  ],
12
20
  "homepage": "https://github.com/nikolay-e/diffctx",
13
21
  "bugs": "https://github.com/nikolay-e/diffctx/issues",