Wire v3, the plugin contract, and a terminal frontend - #4
Merged
Merged
Conversation
`--format ndjson` now writes protocol v3 (src/protocol/): a `start`
record with per-side file identity (path, oid, mode), one `file` record
per diff holding each side's text and a strict tree of regions, and a
`complete` footer. The projection (src/protocol/project.rs) builds the
trees from the diff as it is: leaves come from the full-file row
alignment and tile each side, folds come from the parse, and nothing is
hidden or starts collapsed. Leaves are split at fold edges, mirrored on
the paired side, so every fold's children tile it and paired leaves stay
the same length.
Every region carries an `id` unique within the file across both sides.
The projection numbers regions as it builds them, lhs preorder then rhs
preorder, with `id` dense from 1: id 0 (`ROOT`) names the file itself.
Only leaves carry an `alignment_id`, from a counter of their own, and it
is row alignment exactly as difftastic's line alignment produces rows.
The two regions of a paired leaf or a matched fold share one
`fold_state_id`, so they open and close together.
A fold is a region of a file. Its lines are exactly the lines collapsing
it hides: `folds::line_span` rounds its byte range in to whole lines, so
a body fold starts on the line after the `{` or `:` that opens it and
stops above the line its `}` sits on, whatever column that brace is in.
Code before a fold on the line it opens on, or after it on the line it
closes on, belongs to the leaf beside it.
A fold is owned by a syntax node: `Fold` carries the node's `SyntaxId`,
a node has at most one fold, and two folds align exactly when the
matcher paired their nodes, both ways (`folds::partner`). Flattening a
wrapper moves its fold onto a child that has none, and keeps the wrapper
when the child has a fold of its own, so every fold still has a node.
Two captures that cover the same lines are one fold whose tags are the
union of theirs, owned by the innermost node whose extent that region is
(`folds::merge_spans`); folds of fewer than two lines hide nothing and
merge with nothing.
A file diffed by line records which limit it hit as a `FallbackCause`;
the projection maps it to the `stats.fallback` code and keeps the
engine's prose, with the numbers, as the message.
The stream writer diffs files on `--jobs` workers and emits each record
as it finishes. Errors are `anyhow::Error` until the writer turns one
into a wire record. `Pairing` moves to src/pairing.rs so line layout can
use it for run sides without depending on the protocol.
The projection replaces the old outputs, which are deleted: the previous
stream (src/stream.rs) and its Python checker, `--format json` with the
domain-object encoding behind it (src/review/wire.rs), and `--format
snapshot` with the rest of the experimental src/review module, its golden
CLI tests (tests/review.rs, tests/review_support, the fixtures'
review.snap files) and the static fixture viewer that read the old stream.
The review module's fold checks move to src/parse/fold_tests.rs; the
checks that asserted on snapshot text or terminal hunks are dropped. The
old stream writer was the only caller of the JSON-RPC fold hook, so the
hook goes with it: src/hook.rs, its `folds.hook` settings, the jsonrpsee
dependency, and the example and test servers. docs/streaming.md describes
the wire.
Two fold query patterns that capture one syntax node with different
ranges are a query conflict: that file is not diffed and its record
carries a `query_conflict` error naming the node, the line and both
pattern indexes, rather than silently dropping the fold and letting the
result depend on query order. Other files in the run keep diffing.
Written with AI assistance (Claude Code).
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp
Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823
Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f
Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee
Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668
Agent-Session: 01a0a925-9df2-7c02-9334-147bb9c9f780
When the structural matcher gives up on a parsed file (graph limit or parse-error limit), the engine still collects each side's folds from the parse before taking the line diff, so a file diffed by line has the same regions as one diffed structurally. The parse-error fallback numbers both sides' syntax together, since a fold's identity is its node's syntax id. A fold is paired only by the matcher, and the matcher did not run, so these folds are novel: `folds::unmatched` collects every fold of one side with no opposite, and the projection gives each its own fold state. What is new inside such a fold is still the leaves' answer. Written with AI assistance (Claude Code). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668 Agent-Session: 01a0a925-9df2-7c02-9334-147bb9c9f780
Configuration comes from exactly three sources: the global file (`$XDG_CONFIG_HOME/diffr/config.toml`, or `--config PATH` in its place, which must exist), command-line flags, and git attributes. The file is plain TOML read with serde defaults: every omitted key keeps its default, and an unknown key or mistyped value is an error naming the file and the key's dotted path. There is no repository config file and no environment layer. A schemars JSON Schema describes every setting with a title and a settings group. `diffr config schema`, `show` and `set` print it, print the resolved configuration, or edit one key of the global file while keeping the rest as written (src/config/store.rs). `set` reads the value as the type the schema gives the key, and nothing else. The `[diff]` table holds the engine limits, with the `--byte-limit`, `--graph-limit` and `--parse-error-limit` flags on top, and fallback reasons point at those keys. `[theme]` names the terminal frontend's theme. docs/config.md describes the file and the commands. Written with AI assistance (Claude Code). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668 Agent-Session: 01a0a925-9df2-7c02-9334-147bb9c9f780
Every manifest entry carries `tags`, sorted and deduplicated and omitted when empty. Bundled rules have the lowest precedence: a port of GitHub Linguist's generated.rb (names and paths, header markers in a file's first lines, minified JavaScript and CSS by average line length), Linguist's vendor.yml and documentation.yml path patterns for `vendored` and `docs` (vendored with Linguist's MIT license), and diffr's own test path rules. Git attributes then decide, with git's own precedence between info/attributes, .gitattributes and the user-wide attributes file. `linguist-generated`, `linguist-vendored` and `linguist-documentation` add their tag when set and remove it when unset or `false`, whatever the bundled rules matched. `diffr-tags=a,b` adds tags; a malformed value is a setup error naming the path. `diffr-classify` is gone, and `--order` ranks by tags. Content rules read at most the first 8 KiB of a file, during discovery and only when nothing has already decided `generated`. A failed read becomes that file's `read_failed` error record. A file tagged `generated` is diffed by line without parsing, and its `stats.fallback` has code `generated`. Written with AI assistance (Claude Code). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668 Agent-Session: 01a0a925-9df2-7c02-9334-147bb9c9f780
Plugins decide how each diffed file is shown. The contract is one WIT world, `wit/plugin.wit`: a plugin is a resource with a static `new` that takes its validated options, and two calls on a file — `classify`, which adds tags before the `start` header is written, and `mutate`, which reads the region trees and returns moves. It imports three host functions, `read_head`, `git` and `log`. `crates/diffr-plugin-sdk` is that contract as Rust. `types` holds the contract's records; the one `Plugin` trait every plugin implements mirrors the world's exports, taking exactly those records, with an associated `Options` type diffr deserializes into; `export!` turns an implementation into a component's exports on wasm32 and expands to nothing elsewhere. The host functions are one API, calling the imports in a component and, natively, whatever host diffr installs for the call. `tree` rebuilds the records as trees and back and holds the helpers for reading them; `apply` carries moves out, and `Draft` carries a plugin's own moves out on a copy as it makes them, so a plugin predicts exactly the ids diffr will assign. `src/plugin/` is the host, and knows nothing about any one plugin. `config` reads each enabled entry's folder — its `plugin.toml` (name, title, options schema, query files) — embedded for a bundled plugin, on disk for one an entry's `path` names. `Pipeline::from_config` makes each plugin's one instance for the run from its options; a plugin that cannot be made is a setup error naming it, printed with its whole chain. Every call then gets the contract's records, built once per call from the file's manifest entry and its current trees, and the SDK's applier carries the moves out, so the next plugin sees the result. `plugins/context` is the first plugin, and teaches the contract: its queries tag the constructs a change can sit in `context:scope`, and the plugin keeps each enclosing scope's first and last line, plus the lines within its `lines` option of a change, collapsing every other unchanged stretch. It reads only regions, their tags and the text. This replaces the enclosing-context machinery it was carved out of: the `@context` captures, `parse/context.rs`, `Source.contexts` and the projection's context plumbing are gone, and so is the `[languages]` fold and context query configuration — a plugin's queries are its own files, and every tag a query sets is written `<plugin>:<name>`, naming a plugin in `plugins.order`. Two query files that capture one node with different fold ranges are a conflict, and that file is not diffed. No fold takes a label from its tags any more: a label is the plugin's that collapsed the fold. Written with AI assistance (Claude Code). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668
Five more plugins, each a folder under `plugins/` like `context`: - `hide-files` hides a file carrying a listed tag (`generated`, `vendored`, `test`) and any deleted file, behind the reason. - `deleted-bodies` collapses a function body of at least 12 lines with nothing paired under it: `"20 lines removed"`. - `test-bodies` collapses test function bodies and Rust `#[cfg(test)]` modules, on both sides. - `removed-runs` keeps the first and last line of a long one-sided run of removed lines and collapses the rest. - `group` wraps a run of adjacent collapsed regions in one collapsed fold: `"3 collapsed regions · 42 lines"`. The structure they all fold — bodies, collections, imports, comments and strings — is one shared query file per language, `plugins/shared/queries/`, which every plugin's query file imports and which is compiled once however many import it. Each plugin then tags what it needs: a tag belongs to the plugin that set it, so a fold can carry several. This replaces the bundled fold rules the queries were carved out of: `src/config/defaults.toml` and the code compiling it are gone, and every fold query is now a plugin's file. The docstring queries are shared the same way. A plugin that collapses a function body links the body's docstring to it, so the two open and close together, and `docstring_of` finds it: the body's first fold child where the body starts (Python), or the fold just before it in document order, searching out through enclosing folds so another plugin's scope does not hide it. Newness is the leaves' answer. `one_sided` asks only whether a line inside a region pairs with the other side, so a body in a file diffed by line is new only where its lines are, and a body grown in place, whose signature still pairs, is a rewrite. Written with AI assistance (Claude Code). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668
The `summarize` plugin collapses a new function body of at least 20 lines behind pseudocode from a model, so a large addition reads as a few lines and opens to the source. A body is new when no line inside it pairs with the other side, which it asks before its docstring is linked to it, so the pseudocode may quote the docstring. The plugin holds its HTTP client, its runtime and a semaphore bounding the calls in flight, and checks its key in `new`: it ships `enabled = false` and turning it on without `api_key`, `GEMINI_API_KEY` or `GOOGLE_API_KEY` stops diffr before the stream starts, with an error naming the plugin and the key. `system_prompt` is the model's system instruction, and diffr's own is the default; each request's message, the file's numbered lines and the folds to summarize, is built by the plugin. `diffr config show` redacts the API key unless `--reveal` is given. Written with AI assistance (Claude Code). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668
A plugin folder that holds `plugin.wasm` runs as a WASM component. The wasmtime runner (`src/plugin/wasm.rs`) compiles the component once, links the host functions, instantiates it once per plugin, keeps the resource handle and serialises calls through a mutex around its store. Nothing else changes: a component implements the same `Plugin` trait through the SDK's `export!`, is made from the same options string, and is called with the same records as the native code, so `Pipeline` treats the two the same. An entry may name a folder on disk with `path`, relative to the configuration file. The folder is shaped like a bundled plugin's — `plugin.toml`, its query files and `plugin.wasm` — and its options are checked against the schema its own `plugin.toml` declares, which `diffr config set` reads too. A bundled plugin's entry may set `path`, and that folder then runs in its place. `scripts/build-wasm-plugins.sh` builds each bundled plugin's crate as a component beside its `plugin.toml`, from the same source the native registry compiles in. `examples/plugins/fixtures` is a plugin that exercises every point of the contract, and `tests/wasm.rs` runs the bundled plugins as components and checks they shape files exactly as they do natively. docs/plugins.md describes the contract and how diffr loads a plugin. Written with AI assistance (Claude Code). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668
The TUI reads per-side region trees: rows zip by leaf `alignment_id`, folds open and close by `fold_state_id`, collapsed regions show their header then their label, and hidden files show a "Load diff" placeholder with the reason. A collapsed fold is a row of its own, between the rows around it, so the line that opens a construct is an ordinary row and nothing a fold covers stays on screen; an open fold marks the first line it covers so it can still be collapsed from there. Counts come from `stats.visible`; context gaps are derived from the wire as collapsed, untagged, paired regions holding no change. Folds sharing a header line show the outermost collapsed one. Colours come from tree-sitter capture names, which `--syntax` adds to the stream as each side's `syntax` spans for structurally parsed files, through bundled Helix-style themes. Bare `diffr config [query]` opens the frontend's settings screen, driven by `diffr config schema`, `show` and `set`. The frontend trusts diffr's wire: it parses each record's shape and the protocol version, and rejects malformed JSON and a stream that ends before `complete`, without re-checking invariants across records. The frontend's previous fold model (folds.ts) and its launch PTY tests are deleted. With the terminal UI in place, difftastic's terminal renderer goes too. A comparison without `--format` opens the terminal UI, and fails with exit 2 when stdin or stdout is not a terminal; `--format` takes only `ndjson`; `--display`, `--color` and `--no-color` are gone, and `--width` only sizes `--stat`. Deleted: src/display/ (side by side and inline printing, hunks and their context padding, styles, the `--display json` output), `DisplayOptions`, `DiffResult`'s `hunks`, `extra_info`, `display_path`, `has_byte_changes` and `has_syntactic_changes`, `--check-only` and CR stripping, and the difftastic file, directory and conflict-marker modes of `diffr debug` with the helpers only they used (conflicts.rs, directory walking, file permissions, stdin arguments, line-number formatting). `diffr debug` keeps the syntax dumps and `--list-languages`. The renderer's output regression script (sample_files/compare_all.sh, compare.expected, its CI job and just recipe) goes with it. Line alignment, which the projection still builds leaves from, moves out of src/display/ to src/line_layout.rs, with the line matching it needs in src/line_layout/matched_lines.rs. Written with AI assistance (Claude Code). Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668
The default system prompt no longer asks for python-flavored pseudocode; summaries are language-neutral pseudocode. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0a773-71f6-78f3-912a-a0cfa9fb7fee Agent-Session: 01a0a8e2-e825-7a53-8fce-0ab9801d3668
`wit/plugin.wit` is the plugin contract, but the SDK's `types` module
hand-mirrored its records in plain Rust and the component export glue
converted between the two. Two definitions of every record, free to drift,
and a field added to one side had to be added to the other by hand.
`wit_bindgen::generate!` emits those records as plain Rust; only the export
macro it also emits is wasm-specific, and `export!` already calls that behind
`#[cfg(target_arch = "wasm32")]`. So generate the bindings for every target
and let `types` re-export them: one definition of each record, taken from the
contract itself. `types` keeps only what the WIT cannot say, `ROOT` and the
`Range::lines` and `Visibility::default` impls, and the guest resource now
hands the plugin the records it was given rather than converting them.
The generated `move` variant is a WIT variant, so its cases carry their
payload positionally: `Move::SetCollapsed((region, collapsed))` rather than
`Move::SetCollapsed { region, collapsed }`. The names are unchanged.
diffr's own side of a component call still converts, since wasmtime's
`bindgen!` generates host records of its own that must implement its
lowering traits; those records come from the same WIT, and a drift between
them and the contract fails to compile.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp
Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823
Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f
Agent-Session: 01a0ab99-9486-74f2-ba5e-cdb10b5dbb28
`read-head` handed a plugin the first bytes of one side of a file, and diffr carried a closure down every classify and mutate call to serve it: a handle on the repository of its own, a `Head` type, a `no_head` for `new`, and a slice of the diffed text during `mutate`. A plugin does not need diffr for this. A component has the repository's working directory preopened, so it reads the working-tree file itself; any other side is `git show`, over the `git` import it already has. During `mutate` it has both sides' text in the records it was handed. So remove it from the WIT, the SDK, the wasmtime linker and the native path, and with it the error stashing on the native side: `read-head` returned no error, so a host failure had to be kept aside and raised when the plugin returned. `git` has an error case, and now uses it — a host failure (git cannot be started, or wrote something that is not UTF-8) is the call's error natively and in a component alike, rather than being a trap in one and a deferred abort in the other. The fixtures example reads the file it is classifying from the working directory. A file the working tree no longer has, a deleted one, is classified by its path alone. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0ab99-9486-74f2-ba5e-cdb10b5dbb28
A plugin had to call `log` to say anything, because a component's stdout went to diffr's stderr unprefixed and there was nothing else. A component should just print: it is a program, and it has stderr. So configure the WASI context with stdout and stderr of diffr's own, which write what the guest writes to diffr's stderr a line at a time, each line prefixed with `[<plugin name>] `. Neither may reach diffr's stdout, which is the NDJSON stream. A line the guest leaves unfinished is written when the store is dropped. With that, `log` goes from the WIT, the SDK and the host, and `git` is the only host import. A native plugin prints to diffr's stderr directly; it is not prefixed, since diffr would have to capture its own stderr to do it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0ab99-9486-74f2-ba5e-cdb10b5dbb28
The settings screen read the config during its first render, so a config the binary cannot parse threw inside the reconciler: the alternate screen was already up with raw input on, nothing tore it down, and the window froze behind a React stack trace. Read the config before the renderer exists, so the failure prints as diffr wrote it and exits 2, and hand the screen the settings it should show rather than a client to fetch them from. For anything else that throws once a renderer is live, put the terminal back before reporting: both screens now destroy their renderer on an uncaught exception or rejection. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f Agent-Session: 01a0ab99-9486-74f2-ba5e-cdb10b5dbb28
`file-entry` flattened the manifest entry it comes from: `Pairing<FileRef>` became `path` and `old-path`, and each side's `oid` and `mode` were dropped. So a plugin could not read the content of the file it was classifying. The fixtures example worked around it by reading the working tree, which has no answer at all for a deleted file. Carry the pairing instead, as the wire already does. `file-entry.file` is `both`, `left-only` or `right-only` of a `file-ref` of `path`, `oid` and `mode`: a deleted file cannot have an after side, and each blob comes attached to the path it belongs to. `status` stays as it was — it says how the two sides relate, which is a different question from which sides exist, and the one that `renamed` and `type-changed` answer. The SDK keeps `Pairing<T>` as the type its own code speaks and converts at the boundary, so `FileEntry::sides()` hands a plugin the pairing while the record stays generated from the WIT. `side()` is the side a file is named by, the rule `FileChange::path` already had, in one place rather than per plugin. The example now reads the blob over the `git` it already has, so it classifies a deleted file as readily as a new one, and asks `mode` rather than the filesystem whether the file is a regular one. The test deletes a marked file to cover it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f
`mutate` took `lhs: option<source>, rhs: option<source>`, which can say a file has no sides at all. Every plugin's first line was the same `tree::sides(lhs, rhs)?`, rebuilding the pairing the WIT could not express and carrying an error case none of them could hit. Take `source-sides` instead, shaped like the `file-sides` of the entry and naming the same sides. The SDK rebuilds them as trees on the way in, once, so `Plugin::mutate` receives `&Pairing<Source>`: plugins speak the SDK's own type and the record stays generated from the WIT. That is the SDK's one conversion, and it is the same on both paths — a component converts in the guest bindings, diffr in its native runner. Seven plugins lose their rebuild line and the example loses its `tree` import. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f
The Linguist port names esy's lockfile, which the typo check reads as a misspelling of "easy"; allow the word, as the other real names here are allowed. Three doc comments in the context plugin link to private items, and one in the plugin config writes `<title>` as bare HTML rustdoc cannot close. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f
Three things the test suite assumed about the machine running it. It assumed `wasm32-wasip2` was installed: `tests/wasm.rs` builds every plugin as a component, and no workflow installs that target, so all three tests failed on every platform. Component loading is host behaviour, not per-target, so it is tested once, natively, on a Linux job that installs the target — behind `wasm-plugin-tests` rather than skipped silently when the target is missing. It assumed it could execute the binary it built. Under `cross` the binary is built for another architecture and only cargo's runner can run it, which `tests/cli.rs` has always handled and the newer tests did not. That helper is now `tests/support`, shared by all four. It assumed paths are written with `/`, so three assertions failed on Windows, where the same real paths carry `\`. The product was right and the tests were wrong: they now compare separators the one way they spell them. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f
`cargo package` tarballs the crate for crates.io, and cannot: diffr depends on `diffr-plugin-sdk` and the seven bundled plugin crates by path, and a dependency without a version is refused, while a version sends cargo looking for crates that were never published. Nothing local fixes that. The check came from upstream difftastic, which does publish. This fork builds a `diffr` binary and does not, so it was testing something that does not happen. The jobs that build and test diffr are untouched. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f
The helper that runs the built binary under cargo's runner is upstream's, and has sat in tests/cli.rs since before this branch. Sharing it, I restyled it on the way — renamed it, rewrote its branch as a let-else, replaced its unwrap, dropped its comment — so a move read as new code, and the reason any of it exists went with the comment. Move it as it was. The only change it needed was `pub`. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp Agent-Session: 0025c2a5-4c51-4e7e-be92-b0659a205823 Agent-Session: 01a0a69d-0833-70e0-8670-db9a5a039c5f
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Turns diffr into a structural-diff service: a documented NDJSON wire, a plugin
contract that native and WASM plugins implement identically, layered
configuration, and a terminal frontend that reads the wire.
Seventeen commits, each buildable on its own. Read them in order — the large
no-op restructurings come first, the behaviour changes after.
The wire
wire v3streamsstart/file/completeas NDJSON. A file is tworegion trees, one per side. A
Regioncarries an id, a fold state, a range,tags, visibility, and is either a
Leaf(with thealignment_idthat linesit up with the other side) or a
Foldover children.alignment_idmeans rowalignment and nothing else;
fold_state_idmeans "opens and closes together",within a side or across them.
A fold covers only the lines whose code it wholly contains, so a body folds
and its
fn ...{line stays an ordinary leaf above it. One syntax node ownsat most one fold: two queries capturing the same node merge their tags, and
two capturing it with different ranges fail the file with
query_conflictrather than producing an ambiguous pairing.
The plugin contract
wit/plugin.witis the contract, and the only definition of its records —both the SDK and diffr's host side generate from it. A plugin is a folder with
a
plugin.toml; diffr loads a native one from its registry and aplugin.wasmas a component, and calls the same three functions on both:new,classify,mutate.classifyadds tags to a file before it is diffed;mutatereturns movesover the finished trees. There are six moves —
Cut,JoinFolds,LinkFoldState,SetCollapsed,SetLabel,SetTags— and diffr carriesthem out itself, so a plugin cannot express a tree diffr could not have built.
Everything a plugin sees is on the wire: no hidden fields, and no
language-specific code in Rust, since language differences live in the
tree-sitter query files.
The host gives a plugin one import,
git. A component additionally gets WASI,with its stdout and stderr prefixed and forwarded to diffr's stderr.
Seven plugins ship bundled, each a crate that also builds as a component:
context, hide-files, deleted-bodies, test-bodies, removed-runs, summarize,
group.
tests/wasm.rsruns them both ways and asserts the streams match.Configuration
Three sources, in order: the global file (or
--config), CLI flags, and.gitattributes.diffr configopens a settings screen;config schema,config showandconfig setare the non-interactive surface. A value isparsed as its key's schema type and nothing else.
Frontend
tui/is a Bun/OpenTUI/React frontend that reads the wire.Known CI failure
The
Check Linux Packagingjob fails on this branch.cargo packagecannotpackage a crate whose path dependencies are unpublished, and difftastic now
depends on
diffr-plugin-sdkand the seven plugin crates. Publishing them,or dropping the job, is a decision I did not want to make inside this PR.
Every other check passes: fmt, typos, rustdoc, and the test suite (23 test
binaries, plus the
--ignoredset).https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp