Skip to content

Wire v3, the plugin contract, and a terminal frontend - #4

Merged
sidkmenon merged 20 commits into
mainfrom
diffr-wire-v3
Sep 16, 2026
Merged

sidkmenon merged 20 commits into
mainfrom
diffr-wire-v3

Conversation

@sidkmenon

Copy link
Copy Markdown
Contributor

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 v3 streams start / file / complete as NDJSON. A file is two
region trees, one per side. A Region carries an id, a fold state, a range,
tags, visibility, and is either a Leaf (with the alignment_id that lines
it up with the other side) or a Fold over children. alignment_id means row
alignment and nothing else; fold_state_id means "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 owns
at most one fold: two queries capturing the same node merge their tags, and
two capturing it with different ranges fail the file with query_conflict
rather than producing an ambiguous pairing.

The plugin contract

wit/plugin.wit is 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 a
plugin.wasm as a component, and calls the same three functions on both:
new, classify, mutate.

classify adds tags to a file before it is diffed; mutate returns moves
over the finished trees. There are six moves — Cut, JoinFolds,
LinkFoldState, SetCollapsed, SetLabel, SetTags — and diffr carries
them 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.rs runs them both ways and asserts the streams match.

Configuration

Three sources, in order: the global file (or --config), CLI flags, and
.gitattributes. diffr config opens a settings screen; config schema,
config show and config set are the non-interactive surface. A value is
parsed 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 Packaging job fails on this branch. cargo package cannot
package a crate whose path dependencies are unpublished, and difftastic now
depends on diffr-plugin-sdk and 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 --ignored set).

https://claude.ai/code/session_01WzHbAqhxaLKNQbYyqfCyTp

sidkmenon and others added 20 commits September 16, 2026 00:55
`--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
@sidkmenon
sidkmenon merged commit 28c8835 into main Sep 16, 2026
22 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant