diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4dedc0758..b5937e739 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -117,3 +117,23 @@ jobs:
run: curl -LsSf https://github.com/crate-ci/typos/releases/download/$TYPOS_VERSION/typos-$TYPOS_VERSION-x86_64-unknown-linux-musl.tar.gz | tar zxf - -C ${CARGO_HOME:-~/.cargo}/bin
- name: Check for typos
run: typos
+
+ test_typescript:
+ name: Test @dev.fast/diffr
+ permissions:
+ contents: read
+ runs-on: ubuntu-22.04
+ steps:
+ # v4.2.2
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ bun-version: 1.3.10
+ - uses: dtolnay/rust-toolchain@1.85.0
+ - run: cargo build --locked
+ - working-directory: diffr-ts
+ run: |
+ bun install --frozen-lockfile
+ bun run typecheck
+ bun run build
+ bun test
diff --git a/diffr-ts/.gitignore b/diffr-ts/.gitignore
new file mode 100644
index 000000000..b94707787
--- /dev/null
+++ b/diffr-ts/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/diffr-ts/LICENSE b/diffr-ts/LICENSE
new file mode 100644
index 000000000..368d86c3e
--- /dev/null
+++ b/diffr-ts/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2021-2025 Wilfred Hughes
+Copyright (c) 2026 dev.fast
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/diffr-ts/README.md b/diffr-ts/README.md
new file mode 100644
index 000000000..98e91e8d7
--- /dev/null
+++ b/diffr-ts/README.md
@@ -0,0 +1,40 @@
+# @dev.fast/diffr
+
+TypeScript types, zod validators, and a binary fetcher for diffr's NDJSON protocol.
+The package version matches the diffr release.
+
+```ts
+import { decodeStructuralDiffEvent } from "@dev.fast/diffr";
+const event = decodeStructuralDiffEvent(line);
+```
+
+```sh
+npx --package @dev.fast/diffr@0.1.1 diffr-fetch --into ./bin --required
+```
+
+Supports macOS arm64 and Linux x64. `--check` verifies an existing install without
+network access. Downloads warn on network failure unless `--required`; invalid
+hashes or archives always fail.
+
+## Wire changes
+
+Keep these files in sync:
+
+| Files (from repo root) | Check |
+|---|---|
+| `src/protocol/mod.rs`, `src/pairing.rs`, `diffr-ts/src/contract.ts` | Contract tests against the binary; Rust version check |
+| `docs/streaming.md` | Review against the wire format |
+| `tui/packages/hunk/src/diffr/wire.ts` (v3 only) | TUI fixture tests |
+| Review's `packages/review-protocol/package.json` and `packages/review/package.json` | Exact package version pins |
+
+## Release
+
+After tagging the matching Rust release and building with `cargo build --locked`,
+run from `diffr-ts`:
+
+```sh
+bun install --frozen-lockfile
+npm run pin
+bun run typecheck && bun test
+npm publish --access public
+```
diff --git a/diffr-ts/bin/fetch.mjs b/diffr-ts/bin/fetch.mjs
new file mode 100755
index 000000000..2e5fba1f2
--- /dev/null
+++ b/diffr-ts/bin/fetch.mjs
@@ -0,0 +1,86 @@
+#!/usr/bin/env node
+import { createHash } from "node:crypto";
+import { spawnSync } from "node:child_process";
+import { chmodSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { parseArgs } from "node:util";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const targets = { "darwin-arm64": "aarch64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu" };
+
+try {
+ await main();
+} catch (error) {
+ console.error(`diffr-fetch: ${error.message}`);
+ process.exitCode = 1;
+}
+
+async function main() {
+ const { values } = parseArgs({ options: {
+ into: { type: "string" },
+ check: { type: "boolean" },
+ required: { type: "boolean" },
+ pins: { type: "string", default: join(root, "pins.json") },
+ } });
+ if (!values.into) throw new Error("--into
is required");
+ const into = resolve(values.into);
+ const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
+ const pins = JSON.parse(readFileSync(values.pins, "utf8"));
+ if (pins.version !== version) throw new Error(`pins.json is for ${pins.version}, package is ${version}; run \`npm run pin\``);
+ const target = targets[`${process.platform}-${process.arch}`];
+ if (!target) throw new Error(`no diffr release for ${process.platform}-${process.arch}`);
+ const expected = pins.sha256?.[target];
+ if (typeof expected !== "string" || !/^[a-f0-9]{64}$/.test(expected)) {
+ throw new Error(`pins.json has no valid sha256 for ${target}`);
+ }
+
+ const binary = join(into, "diffr");
+ const stampPath = join(into, "diffr.stamp.json");
+ let installed = false;
+ try {
+ const stamp = JSON.parse(readFileSync(stampPath, "utf8"));
+ const stat = lstatSync(binary);
+ installed = stat.isFile() && (stat.mode & 0o111) !== 0 && stamp.version === version && stamp.target === target;
+ } catch { /* Missing or damaged installations are fetched again. */ }
+ if (installed) {
+ console.log(`diffr ${version} (${target}) already at ${binary}`);
+ return;
+ }
+ if (values.check) throw new Error(`${binary} is missing or not diffr ${version} (${target})`);
+
+ const asset = `diffr-${version}-${target}.tar.gz`;
+ const url = `https://github.com/devdotfast/diffr/releases/download/${version}/${asset}`;
+ let bytes;
+ try {
+ const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
+ bytes = Buffer.from(await response.arrayBuffer());
+ } catch (error) {
+ if (values.required) throw new Error(`could not download ${url}: ${error.message}`);
+ console.warn(`diffr-fetch: could not download ${url}: ${error.message}; continuing without a bundled diffr`);
+ return;
+ }
+ const actual = createHash("sha256").update(bytes).digest("hex");
+ if (actual !== expected) throw new Error(`${asset} sha256 ${actual} does not match pinned ${expected}`);
+
+ mkdirSync(into, { recursive: true });
+ const staging = mkdtempSync(join(into, ".diffr-"));
+ try {
+ const archive = join(staging, "archive.tar.gz");
+ writeFileSync(archive, bytes);
+ const tar = spawnSync("tar", ["-xzf", archive, "-C", staging, "diffr"], { encoding: "utf8" });
+ const extracted = join(staging, "diffr");
+ if (tar.status !== 0 || !lstatSync(extracted).isFile()) {
+ throw new Error(`${asset} does not contain a regular diffr file at its root`);
+ }
+ chmodSync(extracted, 0o755);
+ const stamp = join(staging, "stamp.json");
+ writeFileSync(stamp, `${JSON.stringify({ version, target }, null, 2)}\n`);
+ renameSync(extracted, binary);
+ renameSync(stamp, stampPath);
+ } finally {
+ rmSync(staging, { recursive: true, force: true });
+ }
+ console.log(`diffr ${version} (${target}) installed at ${binary}`);
+}
diff --git a/diffr-ts/bun.lock b/diffr-ts/bun.lock
new file mode 100644
index 000000000..fc08b6ab8
--- /dev/null
+++ b/diffr-ts/bun.lock
@@ -0,0 +1,30 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "@dev.fast/diffr",
+ "devDependencies": {
+ "@types/bun": "1.3.10",
+ "typescript": "5.9.3",
+ "zod": "4.1.12",
+ },
+ "peerDependencies": {
+ "zod": "^4.0.0",
+ },
+ },
+ },
+ "packages": {
+ "@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
+
+ "@types/node": ["@types/node@26.6.2", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g=="],
+
+ "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
+
+ "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
+ }
+}
diff --git a/diffr-ts/package.json b/diffr-ts/package.json
new file mode 100644
index 000000000..2f62050aa
--- /dev/null
+++ b/diffr-ts/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "@dev.fast/diffr",
+ "version": "0.1.1",
+ "description": "diffr's NDJSON wire contract, validators, and release binary fetcher",
+ "repository": "https://github.com/devdotfast/diffr",
+ "license": "MIT",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ },
+ "./package.json": "./package.json",
+ "./src/*": "./src/*"
+ },
+ "bin": {
+ "diffr-fetch": "bin/fetch.mjs"
+ },
+ "files": [
+ "dist",
+ "src",
+ "!src/*.test.ts",
+ "!src/test-binary.ts",
+ "bin",
+ "pins.json",
+ "README.md"
+ ],
+ "scripts": {
+ "build": "tsc -p tsconfig.build.json",
+ "typecheck": "tsc --noEmit",
+ "test": "bun test",
+ "pin": "node scripts/pin.mjs",
+ "prepack": "npm run build"
+ },
+ "peerDependencies": {
+ "zod": "^4.0.0"
+ },
+ "devDependencies": {
+ "@types/bun": "1.3.10",
+ "typescript": "5.9.3",
+ "zod": "4.1.12"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/diffr-ts/pins.json b/diffr-ts/pins.json
new file mode 100644
index 000000000..9e5a2f6de
--- /dev/null
+++ b/diffr-ts/pins.json
@@ -0,0 +1,7 @@
+{
+ "version": "0.1.1",
+ "sha256": {
+ "aarch64-apple-darwin": "7312213893286b804190da46d4c181cc9646320b6c651ada68a42fd97a84bb7f",
+ "x86_64-unknown-linux-gnu": "1cce58b34031b274b1ab511bf929f501d9e99a8710d4ca33b0ed4cdc94d9ab60"
+ }
+}
diff --git a/diffr-ts/scripts/pin.mjs b/diffr-ts/scripts/pin.mjs
new file mode 100755
index 000000000..60301e977
--- /dev/null
+++ b/diffr-ts/scripts/pin.mjs
@@ -0,0 +1,23 @@
+#!/usr/bin/env node
+import { createHash } from "node:crypto";
+import { readFileSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = join(here, "..");
+const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version;
+const targets = ["aarch64-apple-darwin", "x86_64-unknown-linux-gnu"];
+const sha256 = {};
+for (const target of targets) {
+ const url = `https://github.com/devdotfast/diffr/releases/download/${version}/diffr-${version}-${target}.tar.gz`;
+ const response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(120_000) });
+ if (!response.ok) {
+ console.error(`pin: GET ${url} failed with ${response.status} ${response.statusText}`);
+ process.exit(1);
+ }
+ sha256[target] = createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest("hex");
+ console.log(`${target} ${sha256[target]}`);
+}
+writeFileSync(join(root, "pins.json"), `${JSON.stringify({ version, sha256 }, null, 2)}\n`);
+console.log(`wrote pins.json for ${version}`);
diff --git a/diffr-ts/src/contract.test.ts b/diffr-ts/src/contract.test.ts
new file mode 100644
index 000000000..70b142ae2
--- /dev/null
+++ b/diffr-ts/src/contract.test.ts
@@ -0,0 +1,105 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import {
+ STRUCTURAL_DIFF_BASE_WIRE_VERSION,
+ STRUCTURAL_DIFF_WIRE_VERSION,
+ type StructuralDiffEvent,
+ decodeStructuralDiffEvent,
+} from "./index.js";
+import { diffrBinary, repositoryRoot } from "./test-binary.js";
+
+const dirs: string[] = [];
+function tempDir(prefix: string): string {
+ const dir = mkdtempSync(join(tmpdir(), prefix));
+ dirs.push(dir);
+ return dir;
+}
+afterEach(() => {
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
+});
+
+function run(args: string[], cwd = repositoryRoot): StructuralDiffEvent[] {
+ const result = Bun.spawnSync([diffrBinary(), ...args], {
+ cwd,
+ env: { ...process.env, XDG_CONFIG_HOME: tempDir("diffr-config-") },
+ });
+ expect(result.exitCode, result.stderr.toString()).toBe(0);
+ const stdout = new TextDecoder().decode(result.stdout);
+ const lines = stdout.split("\n").filter((line) => line.length > 0);
+ expect(lines.length).toBeGreaterThan(0);
+ return lines.map(decodeStructuralDiffEvent);
+}
+
+function git(cwd: string, ...args: string[]): void {
+ const result = Bun.spawnSync(["git", "-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", ...args], { cwd });
+ expect(result.exitCode).toBe(0);
+}
+
+const pairs: [string, string][] = [
+ ["sample_files/simple_1.js", "sample_files/simple_2.js"],
+ ["sample_files/if_1.py", "sample_files/if_2.py"],
+ ["sample_files/context_1.rs", "sample_files/context_2.rs"],
+ ["sample_files/comments_1.rs", "sample_files/comments_2.rs"],
+];
+
+describe("every record the binary writes validates", () => {
+ for (const [before, after] of pairs) {
+ test(`--no-index ${before} ${after}`, () => {
+ const events = run(["--no-index", "--format", "ndjson", "--", before, after]);
+ expect(events[0]).toMatchObject({ type: "start", version: STRUCTURAL_DIFF_BASE_WIRE_VERSION });
+ expect(events.at(-1)).toMatchObject({ type: "complete", failed: 0 });
+ expect(events.some((event) => event.type === "file")).toBe(true);
+ });
+ }
+
+ test("--syntax adds syntax spans that validate", () => {
+ const events = run(["--no-index", "--syntax", "--format", "ndjson", "--", ...pairs[2]]);
+ const file = events.find((event) => event.type === "file");
+ expect(file?.diff && file.diff.type === "text" && (file.diff.rhs?.syntax?.length ?? 0) > 0).toBe(true);
+ });
+
+ test("--stream-annotations emits version 4 and annotation records", () => {
+ const root = tempDir("diffr-contract-repo-");
+ git(root, "init", "-q", "-b", "main");
+ git(root, "config", "user.email", "test@example.com");
+ git(root, "config", "user.name", "test");
+ writeFileSync(join(root, "lib.rs"), "fn a() -> u32 {\n 1\n}\n");
+ git(root, "add", ".");
+ git(root, "commit", "-q", "-m", "one");
+ writeFileSync(join(root, "lib.rs"), "fn a() -> u32 {\n 2\n}\n\nfn b() -> u32 {\n a() + 1\n}\n");
+ git(root, "commit", "-q", "-am", "two");
+
+ const events = run(["--repo", root, "--format", "ndjson", "--stream-annotations", "HEAD~1", "HEAD"], root);
+ expect(events[0]).toMatchObject({ type: "start", version: STRUCTURAL_DIFF_WIRE_VERSION });
+ expect(events.some((event) => event.type === "file")).toBe(true);
+ expect(events.some((event) => event.type === "annotations")).toBe(true);
+ expect(events.at(-1)).toMatchObject({ type: "complete", failed: 0 });
+ });
+});
+
+test("the terminal UI's committed v3 fixture still validates", async () => {
+ const text = await Bun.file(join(repositoryRoot, "tui/test/fixtures/comparison.ndjson")).text();
+ const events = text.split("\n").filter((line) => line.length > 0).map(decodeStructuralDiffEvent);
+ expect(events[0]).toMatchObject({ type: "start", version: STRUCTURAL_DIFF_BASE_WIRE_VERSION });
+ expect(events.at(-1)).toMatchObject({ type: "complete", failed: 0 });
+});
+
+test("a record missing required fields is rejected", () => {
+ expect(() => decodeStructuralDiffEvent('{"type":"complete","succeeded":1}')).toThrow(
+ "Malformed diffr protocol record.",
+ );
+});
+
+test("invalid JSON preserves the parse failure as its cause", () => {
+ try {
+ decodeStructuralDiffEvent("{");
+ throw new Error("expected decoding to fail");
+ } catch (error) {
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error).message).toBe("Malformed diffr protocol record.");
+ expect((error as Error).cause).toBeInstanceOf(SyntaxError);
+ }
+});
diff --git a/diffr-ts/src/contract.ts b/diffr-ts/src/contract.ts
new file mode 100644
index 000000000..809483b5f
--- /dev/null
+++ b/diffr-ts/src/contract.ts
@@ -0,0 +1,364 @@
+/**
+ * Mirrors src/protocol/mod.rs and src/pairing.rs. See ../README.md for sync checks.
+ * Import only zod: Review embeds this file in generated source.
+ * Defaults are omitted, not null. Lines are zero-based, columns are UTF-8 bytes,
+ * and ranges are half-open.
+ */
+import { z } from "zod";
+
+/** A pairing always has at least one side; additions/deletions omit the other. */
+export type StructuralPairing =
+ | { lhs: T; rhs: T }
+ | { lhs: T; rhs?: never }
+ | { rhs: T; lhs?: never };
+
+function structuralPairingSchema(
+ value: z.ZodType,
+): z.ZodType> {
+ return z.union([
+ z.object({ lhs: value, rhs: value }),
+ z.object({ lhs: value, rhs: z.never().optional() }),
+ z.object({ rhs: value, lhs: z.never().optional() }),
+ ]);
+}
+
+const structuralU32 = z.number().int().min(0).max(0xffff_ffff);
+
+export const STRUCTURAL_DIFF_WIRE_VERSION = 4;
+/** Base stream version without --stream-annotations. */
+export const STRUCTURAL_DIFF_BASE_WIRE_VERSION = 3;
+
+export type StructuralDiffEvent =
+ | {
+ type: "start";
+ version: number;
+ lhs: StructuralSnapshot;
+ rhs: StructuralSnapshot;
+ files: StructuralFileChange[];
+ }
+ | ({
+ type: "file";
+ file: StructuralPairing;
+ visibility?: StructuralVisibility;
+ } & StructuralOutcome)
+ | {
+ type: "annotations";
+ file: StructuralPairing;
+ annotations: { region_id: number; label: string }[];
+ error?: StructuralProblem;
+ }
+ | {
+ type: "complete";
+ succeeded: number;
+ failed: number;
+ aborted?: StructuralProblem;
+ };
+
+export const StructuralDiffEventSchema: z.ZodType = z.lazy(
+ () =>
+ z.union([
+ z.object({
+ type: z.literal("start"),
+ version: structuralU32,
+ lhs: StructuralSnapshotSchema,
+ rhs: StructuralSnapshotSchema,
+ files: z.array(StructuralFileChangeSchema),
+ }),
+ z
+ .object({
+ type: z.literal("file"),
+ file: structuralPairingSchema(StructuralFileRefSchema),
+ visibility: StructuralVisibilitySchema.optional(),
+ })
+ .and(StructuralOutcomeSchema),
+ z.object({
+ type: z.literal("annotations"),
+ file: structuralPairingSchema(StructuralFileRefSchema),
+ annotations: z.array(
+ z.object({ region_id: structuralU32, label: z.string() }),
+ ),
+ error: StructuralProblemSchema.optional(),
+ }),
+ z.object({
+ type: z.literal("complete"),
+ succeeded: structuralU32,
+ failed: structuralU32,
+ aborted: StructuralProblemSchema.optional(),
+ }),
+ ]),
+);
+
+/** A file record contains exactly one result or error. */
+export type StructuralOutcome =
+ | { diff: StructuralDiff; error?: never }
+ | { error: StructuralProblem; diff?: never };
+
+export const StructuralOutcomeSchema: z.ZodType = z.lazy(
+ () =>
+ z.union([
+ z.object({ diff: StructuralDiffSchema, error: z.never().optional() }),
+ z.object({ error: StructuralProblemSchema, diff: z.never().optional() }),
+ ]),
+);
+
+export type StructuralSnapshot =
+ | { type: "revision"; rev: string }
+ | { type: "index" }
+ | { type: "working_tree" }
+ | { type: "empty_tree" }
+ | { type: "path"; path: string };
+
+export const StructuralSnapshotSchema: z.ZodType = z.lazy(
+ () =>
+ z.union([
+ z.object({ type: z.literal("revision"), rev: z.string() }),
+ z.object({ type: z.literal("index") }),
+ z.object({ type: z.literal("working_tree") }),
+ z.object({ type: z.literal("empty_tree") }),
+ z.object({ type: z.literal("path"), path: z.string() }),
+ ]),
+);
+
+export type StructuralProblem = { code: string; message: string };
+
+export const StructuralProblemSchema: z.ZodType = z.lazy(
+ () => z.object({ code: z.string(), message: z.string() }),
+);
+
+export type StructuralFileChange = {
+ file: StructuralPairing;
+ status: StructuralFileStatus;
+ tags?: string[];
+};
+
+export const StructuralFileChangeSchema: z.ZodType =
+ z.lazy(() =>
+ z.object({
+ file: structuralPairingSchema(StructuralFileRefSchema),
+ status: StructuralFileStatusSchema,
+ tags: z.array(z.string()).optional(),
+ }),
+ );
+
+export type StructuralFileStatus =
+ | "added"
+ | "deleted"
+ | "modified"
+ | "renamed"
+ | "copied"
+ | "type_changed";
+
+export const StructuralFileStatusSchema: z.ZodType =
+ z.lazy(() =>
+ z.union([
+ z.literal("added"),
+ z.literal("deleted"),
+ z.literal("modified"),
+ z.literal("renamed"),
+ z.literal("copied"),
+ z.literal("type_changed"),
+ ]),
+ );
+
+export type StructuralFileRef = { path: string; oid: string; mode: string };
+
+export const StructuralFileRefSchema: z.ZodType = z.lazy(
+ () => z.object({ path: z.string(), oid: z.string(), mode: z.string() }),
+);
+
+export type StructuralVisibility = { collapsed?: boolean; label?: string };
+
+export const StructuralVisibilitySchema: z.ZodType =
+ z.lazy(() =>
+ z.object({
+ collapsed: z.boolean().optional(),
+ label: z.string().optional(),
+ }),
+ );
+
+/** Structural changed lines, independent of presentation and fold state. */
+export type StructuralChanges = {
+ base: [number, number][];
+ head: [number, number][];
+};
+
+const structuralRangesSchema = z
+ .array(z.tuple([structuralU32, structuralU32]))
+ .refine(
+ (ranges) =>
+ ranges.every(
+ ([start, end], index) =>
+ start < end && (index === 0 || ranges[index - 1][1] < start),
+ ),
+ "Expected sorted, coalesced, nonempty half-open ranges",
+ );
+
+export const StructuralChangesSchema: z.ZodType = z.object({
+ base: structuralRangesSchema,
+ head: structuralRangesSchema,
+});
+
+export function structuralChangeCounts(
+ changes: StructuralChanges,
+): StructuralLineCounts {
+ const count = (ranges: [number, number][]) =>
+ ranges.reduce((sum, [start, end]) => sum + end - start, 0);
+
+ return { added: count(changes.head), removed: count(changes.base) };
+}
+
+export type StructuralDiff =
+ | ({
+ type: "text";
+ stats: StructuralStats;
+ structural_changes: StructuralChanges;
+ } & StructuralPairing)
+ | ({ type: "binary" } & StructuralPairing);
+
+export const StructuralDiffSchema: z.ZodType = z.lazy(() =>
+ z.union([
+ z
+ .object({
+ type: z.literal("text"),
+ stats: StructuralStatsSchema,
+ structural_changes: StructuralChangesSchema,
+ })
+ .and(structuralPairingSchema(StructuralSourceSchema)),
+ z
+ .object({ type: z.literal("binary") })
+ .and(structuralPairingSchema(StructuralBinaryRefSchema)),
+ ]),
+);
+
+export type StructuralSource = {
+ text: string;
+ syntax?: StructuralSyntaxSpan[];
+ regions?: StructuralRegion[];
+};
+
+export const StructuralSourceSchema: z.ZodType = z.lazy(() =>
+ z.object({
+ text: z.string(),
+ syntax: z.array(StructuralSyntaxSpanSchema).optional(),
+ regions: z.array(StructuralRegionSchema).optional(),
+ }),
+);
+
+export type StructuralBinaryRef = { size: number };
+
+export const StructuralBinaryRefSchema: z.ZodType = z.lazy(
+ () => z.object({ size: z.number().int().nonnegative() }),
+);
+
+export type StructuralSyntaxSpan = {
+ line: number;
+ start_column: number;
+ end_column: number;
+ capture: string;
+};
+
+export const StructuralSyntaxSpanSchema: z.ZodType =
+ z.lazy(() =>
+ z.object({
+ line: structuralU32,
+ start_column: structuralU32,
+ end_column: structuralU32,
+ capture: z.string(),
+ }),
+ );
+
+/** Region identity, collapse identity, and leaf alignment identity are distinct. */
+export type StructuralRegion = {
+ id: number;
+ fold_state_id: number;
+ tags?: string[];
+ visibility?: StructuralVisibility;
+} & StructuralSourceRange &
+ StructuralNode;
+
+export const StructuralRegionSchema: z.ZodType = z.lazy(() =>
+ z
+ .object({
+ id: structuralU32,
+ fold_state_id: structuralU32,
+ tags: z.array(z.string()).optional(),
+ visibility: StructuralVisibilitySchema.optional(),
+ })
+ .and(StructuralSourceRangeSchema)
+ .and(StructuralNodeSchema),
+);
+
+export type StructuralNode =
+ | { kind: "leaf"; alignment_id: number; changed?: StructuralSpan[] }
+ | { kind: "fold"; children: StructuralRegion[] };
+
+export const StructuralNodeSchema: z.ZodType = z.lazy(() =>
+ z.union([
+ z.object({
+ kind: z.literal("leaf"),
+ alignment_id: structuralU32,
+ changed: z.array(StructuralSpanSchema).optional(),
+ }),
+ z.object({
+ kind: z.literal("fold"),
+ children: z.array(StructuralRegionSchema),
+ }),
+ ]),
+);
+
+export type StructuralSpan = {
+ line: number;
+ start_column: number;
+ end_column: number;
+};
+
+export const StructuralSpanSchema: z.ZodType = z.lazy(() =>
+ z.object({
+ line: structuralU32,
+ start_column: structuralU32,
+ end_column: structuralU32,
+ }),
+);
+
+export type StructuralSourceRange = {
+ start: StructuralPos;
+ end: StructuralPos;
+};
+
+export const StructuralSourceRangeSchema: z.ZodType =
+ z.lazy(() =>
+ z.object({ start: StructuralPosSchema, end: StructuralPosSchema }),
+ );
+
+export type StructuralPos = { line: number; column: number };
+
+export const StructuralPosSchema: z.ZodType = z.lazy(() =>
+ z.object({
+ line: structuralU32,
+ column: structuralU32,
+ }),
+);
+
+export type StructuralStats = {
+ textual: StructuralLineCounts;
+ visible: StructuralLineCounts;
+ fallback?: StructuralProblem;
+};
+
+export const StructuralStatsSchema: z.ZodType = z.lazy(() =>
+ z.object({
+ textual: StructuralLineCountsSchema,
+ visible: StructuralLineCountsSchema,
+ fallback: StructuralProblemSchema.optional(),
+ }),
+);
+
+export type StructuralLineCounts = { added: number; removed: number };
+
+export const StructuralLineCountsSchema: z.ZodType =
+ z.lazy(() =>
+ z.object({
+ added: structuralU32,
+ removed: structuralU32,
+ }),
+ );
diff --git a/diffr-ts/src/fetch.test.ts b/diffr-ts/src/fetch.test.ts
new file mode 100644
index 000000000..d32bdbbd5
--- /dev/null
+++ b/diffr-ts/src/fetch.test.ts
@@ -0,0 +1,121 @@
+import { afterEach, expect, test } from "bun:test";
+import { createHash } from "node:crypto";
+import { chmodSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const fetchScript = join(import.meta.dir, "..", "bin", "fetch.mjs");
+const { version } = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8"));
+const targets: Record = { "darwin-arm64": "aarch64-apple-darwin", "linux-x64": "x86_64-unknown-linux-gnu" };
+const target = targets[`${process.platform}-${process.arch}`];
+const dirs: string[] = [];
+afterEach(() => {
+ for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
+});
+
+function fixture(entry = "diffr") {
+ if (!target) throw new Error("Fetch tests require a supported release platform");
+ const dir = mkdtempSync(join(tmpdir(), "diffr-fetch-test-"));
+ dirs.push(dir);
+ const content = "#!/bin/sh\necho diffr 0.1.1\n";
+ writeFileSync(join(dir, entry), content);
+ const archive = join(dir, "archive.tar.gz");
+ expect(Bun.spawnSync(["tar", "-czf", archive, "-C", dir, entry]).exitCode).toBe(0);
+ const hash = createHash("sha256").update(readFileSync(archive)).digest("hex");
+ const pins = join(dir, "pins.json");
+ writeFileSync(pins, JSON.stringify({ version, sha256: { [target]: hash } }));
+ const mock = join(dir, "mock.mjs");
+ // Intercept only the network boundary, leaving the actual CLI and tar intact.
+ writeFileSync(mock, `
+ import { readFileSync, writeFileSync } from "node:fs";
+ globalThis.fetch = async (url) => {
+ writeFileSync(${JSON.stringify(join(dir, "requested"))}, url);
+ if (process.env.FETCH_FAILURE) throw new Error("offline");
+ return new Response(readFileSync(${JSON.stringify(archive)}));
+ };
+ `);
+ const into = join(dir, "bin");
+ function run(args: string[] = [], offline = false) {
+ const result = Bun.spawnSync(["node", "--import", mock, fetchScript, "--into", into, "--pins", pins, ...args], {
+ env: { ...process.env, FETCH_FAILURE: offline ? "1" : "" },
+ });
+ return { code: result.exitCode, out: result.stdout.toString(), err: result.stderr.toString() };
+ }
+ return { dir, into, pins, content, run };
+}
+
+test("downloads, verifies, extracts, stamps, and reuses the pinned binary", () => {
+ const f = fixture();
+ const result = f.run(["--required"]);
+ expect(result.code, result.err).toBe(0);
+ expect(readFileSync(join(f.dir, "requested"), "utf8")).toBe(
+ `https://github.com/devdotfast/diffr/releases/download/${version}/diffr-${version}-${target}.tar.gz`,
+ );
+ expect(readFileSync(join(f.into, "diffr"), "utf8")).toBe(f.content);
+ expect(statSync(join(f.into, "diffr")).mode & 0o777).toBe(0o755);
+ expect(JSON.parse(readFileSync(join(f.into, "diffr.stamp.json"), "utf8"))).toEqual({ version, target });
+ expect(readdirSync(f.into).sort()).toEqual(["diffr", "diffr.stamp.json"]);
+ rmSync(join(f.dir, "requested"));
+ expect(f.run(["--check"], true).code).toBe(0);
+ expect(f.run([], true).code).toBe(0);
+ expect(existsSync(join(f.dir, "requested"))).toBe(false);
+});
+
+test("--check never downloads and rejects missing, stale, damaged, or nonexecutable installs", () => {
+ const f = fixture();
+ expect(f.run(["--check"]).code).toBe(1);
+ expect(existsSync(join(f.dir, "requested"))).toBe(false);
+ expect(f.run().code).toBe(0);
+ for (const stamp of [JSON.stringify({ version: "0.0.0", target }), JSON.stringify({ version, target: "wrong" }), "{"]) {
+ writeFileSync(join(f.into, "diffr.stamp.json"), stamp);
+ expect(f.run(["--check"]).code).toBe(1);
+ }
+ expect(f.run().code).toBe(0);
+ chmodSync(join(f.into, "diffr"), 0o644);
+ expect(f.run(["--check"]).code).toBe(1);
+});
+
+test("mismatched pin versions and invalid hashes fail before downloading", () => {
+ const f = fixture();
+ writeFileSync(f.pins, JSON.stringify({ version: "9.9.9", sha256: {} }));
+ expect(f.run().err).toContain("pins.json is for 9.9.9");
+ writeFileSync(f.pins, JSON.stringify({ version, sha256: {} }));
+ expect(f.run().err).toContain("no valid sha256");
+ expect(existsSync(join(f.dir, "requested"))).toBe(false);
+});
+
+test("network failure warns unless --required", () => {
+ const f = fixture();
+ const optional = f.run([], true);
+ expect(optional.code).toBe(0);
+ expect(optional.err).toContain("continuing without a bundled diffr");
+ expect(f.run(["--required"], true).code).toBe(1);
+ expect(existsSync(f.into)).toBe(false);
+});
+
+test("hash mismatch always fails and preserves the previous installation", () => {
+ const f = fixture();
+ expect(f.run().code).toBe(0);
+ const stamp = JSON.stringify({ version: "0.0.0", target });
+ writeFileSync(join(f.into, "diffr.stamp.json"), stamp);
+ writeFileSync(f.pins, JSON.stringify({ version, sha256: { [target!]: "0".repeat(64) } }));
+ const result = f.run();
+ expect(result.code).toBe(1);
+ expect(result.err).toContain("does not match pinned");
+ expect(readFileSync(join(f.into, "diffr"), "utf8")).toBe(f.content);
+ expect(readFileSync(join(f.into, "diffr.stamp.json"), "utf8")).toBe(stamp);
+});
+
+test("missing archive entry fails and cleans staging", () => {
+ const f = fixture("other");
+ const result = f.run();
+ expect(result.code).toBe(1);
+ expect(result.err).toContain("does not contain a regular diffr file");
+ expect(readdirSync(f.into)).toEqual([]);
+});
+
+test("missing option values fail clearly", () => {
+ const f = fixture();
+ expect(f.run(["--pins"]).code).toBe(1);
+ expect(f.run(["--into"]).code).toBe(1);
+});
diff --git a/diffr-ts/src/index.ts b/diffr-ts/src/index.ts
new file mode 100644
index 000000000..f9addffd1
--- /dev/null
+++ b/diffr-ts/src/index.ts
@@ -0,0 +1,2 @@
+export * from "./contract.js";
+export * from "./stream.js";
diff --git a/diffr-ts/src/stream.ts b/diffr-ts/src/stream.ts
new file mode 100644
index 000000000..68f41da14
--- /dev/null
+++ b/diffr-ts/src/stream.ts
@@ -0,0 +1,9 @@
+import { type StructuralDiffEvent, StructuralDiffEventSchema } from "./contract.js";
+
+export function decodeStructuralDiffEvent(line: string): StructuralDiffEvent {
+ try {
+ return StructuralDiffEventSchema.parse(JSON.parse(line));
+ } catch (cause) {
+ throw new Error("Malformed diffr protocol record.", { cause });
+ }
+}
diff --git a/diffr-ts/src/test-binary.ts b/diffr-ts/src/test-binary.ts
new file mode 100644
index 000000000..1416df7ec
--- /dev/null
+++ b/diffr-ts/src/test-binary.ts
@@ -0,0 +1,15 @@
+import { existsSync } from "node:fs";
+import { resolve } from "node:path";
+
+export function diffrBinary(): string {
+ const candidate =
+ process.env.DIFFR_BINARY ?? resolve(import.meta.dir, "../../target/debug/diffr");
+ if (!existsSync(candidate)) {
+ throw new Error(
+ `${candidate} is missing. Run \`cargo build --locked\` at the repository root or set DIFFR_BINARY.`,
+ );
+ }
+ return candidate;
+}
+
+export const repositoryRoot = resolve(import.meta.dir, "../..");
diff --git a/diffr-ts/src/version.test.ts b/diffr-ts/src/version.test.ts
new file mode 100644
index 000000000..d8d382864
--- /dev/null
+++ b/diffr-ts/src/version.test.ts
@@ -0,0 +1,24 @@
+import { expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { STRUCTURAL_DIFF_BASE_WIRE_VERSION } from "./contract.js";
+import { repositoryRoot } from "./test-binary.js";
+
+test("STRUCTURAL_DIFF_BASE_WIRE_VERSION matches src/protocol/mod.rs", () => {
+ const source = readFileSync(join(repositoryRoot, "src/protocol/mod.rs"), "utf8");
+ const match = /pub const VERSION: u32 = (\d+);/.exec(source);
+ expect(match).not.toBeNull();
+ expect(Number(match![1])).toBe(STRUCTURAL_DIFF_BASE_WIRE_VERSION);
+});
+
+test("release pins match the package version and cover both supported targets", () => {
+ const root = join(repositoryRoot, "diffr-ts");
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
+ const pins = JSON.parse(readFileSync(join(root, "pins.json"), "utf8"));
+ expect(pins.version).toBe(pkg.version);
+ expect(Object.keys(pins.sha256).sort()).toEqual([
+ "aarch64-apple-darwin", "x86_64-unknown-linux-gnu",
+ ]);
+ for (const hash of Object.values(pins.sha256)) expect(hash).toMatch(/^[a-f0-9]{64}$/);
+});
diff --git a/diffr-ts/tsconfig.build.json b/diffr-ts/tsconfig.build.json
new file mode 100644
index 000000000..1fd8ccd88
--- /dev/null
+++ b/diffr-ts/tsconfig.build.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "types": []
+ },
+ "exclude": [
+ "src/**/*.test.ts",
+ "src/test-binary.ts"
+ ]
+}
diff --git a/diffr-ts/tsconfig.json b/diffr-ts/tsconfig.json
new file mode 100644
index 000000000..66bd0b198
--- /dev/null
+++ b/diffr-ts/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "declaration": true,
+ "skipLibCheck": true,
+ "types": ["bun"],
+ "outDir": "dist",
+ "rootDir": "src"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/tui/packages/hunk/src/diffr/wire.ts b/tui/packages/hunk/src/diffr/wire.ts
index 2677f363c..867ac1b3a 100644
--- a/tui/packages/hunk/src/diffr/wire.ts
+++ b/tui/packages/hunk/src/diffr/wire.ts
@@ -1,3 +1,4 @@
+// Full v3/v4 contract: diffr-ts/src/contract.ts.
/** Parse diffr's wire v3 (src/protocol/mod.rs, docs/streaming.md): tagged enums, pairings by presence, defaults omitted. The shapes are checked; invariants between records are diffr's and trusted. */
import { z } from "zod";
const uint = z.number().int().nonnegative();
diff --git a/tui/test/fixtures/comparison.ndjson b/tui/test/fixtures/comparison.ndjson
index e0cf01c30..ba12caec9 100644
--- a/tui/test/fixtures/comparison.ndjson
+++ b/tui/test/fixtures/comparison.ndjson
@@ -1,4 +1,4 @@
-{"type":"start","version":3,"lhs":{"type":"revision","rev":"38049b0b067444916e9de65efceef73100b3e615"},"rhs":{"type":"revision","rev":"15afc787a8bfc95644bd00f97511015fb9a32aeb"},"files":[{"file":{"lhs":{"path":"src/greet.ts","oid":"8529d7a6dc7709c707ebd16b3689352f90f91993","mode":"100644"},"rhs":{"path":"src/greet.ts","oid":"f9895c906b6b46c0c562bc9e8c39b1702096cf4f","mode":"100644"}},"status":"modified"},{"file":{"lhs":{"path":"tests/greet.test.ts","oid":"0231e3a6a765dbd026034d9e12a988347d559f88","mode":"100644"},"rhs":{"path":"tests/greet.test.ts","oid":"ae1cd95b02cc02cff3387547fceff8bda367db5b","mode":"100644"}},"status":"modified","tags":["test"]}]}
-{"type":"file","file":{"lhs":{"path":"src/greet.ts","oid":"8529d7a6dc7709c707ebd16b3689352f90f91993","mode":"100644"},"rhs":{"path":"src/greet.ts","oid":"f9895c906b6b46c0c562bc9e8c39b1702096cf4f","mode":"100644"}},"diff":{"type":"text","lhs":{"text":"export function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":6,"capture":"keyword"},{"line":0,"start_column":7,"end_column":15,"capture":"keyword"},{"line":0,"start_column":16,"end_column":21,"capture":"variable"},{"line":0,"start_column":21,"end_column":22,"capture":"punctuation.bracket"},{"line":0,"start_column":22,"end_column":26,"capture":"variable"},{"line":0,"start_column":28,"end_column":34,"capture":"type.builtin"},{"line":0,"start_column":34,"end_column":35,"capture":"punctuation.bracket"},{"line":0,"start_column":36,"end_column":37,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":0,"fold_state_id":0,"start":{"line":0,"column":0},"end":{"line":2,"column":0},"tags":["deleted-bodies:function","removed-runs:function","summarize:function"],"visibility":{"label":"Function"},"kind":"fold","children":[{"id":1,"fold_state_id":1,"start":{"line":0,"column":0},"end":{"line":2,"column":0},"kind":"leaf","alignment_id":0}]},{"id":2,"fold_state_id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","alignment_id":1}]},"rhs":{"text":"export function greet(name: string, punctuation = \"!\") {\n return \"hello \" + name + punctuation;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":6,"capture":"keyword"},{"line":0,"start_column":7,"end_column":15,"capture":"keyword"},{"line":0,"start_column":16,"end_column":21,"capture":"variable"},{"line":0,"start_column":21,"end_column":22,"capture":"punctuation.bracket"},{"line":0,"start_column":22,"end_column":26,"capture":"variable"},{"line":0,"start_column":28,"end_column":34,"capture":"type.builtin"},{"line":0,"start_column":34,"end_column":35,"capture":"punctuation.delimiter"},{"line":0,"start_column":36,"end_column":47,"capture":"variable"},{"line":0,"start_column":48,"end_column":49,"capture":"operator"},{"line":0,"start_column":50,"end_column":53,"capture":"string"},{"line":0,"start_column":53,"end_column":54,"capture":"punctuation.bracket"},{"line":0,"start_column":55,"end_column":56,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":25,"end_column":26,"capture":"operator"},{"line":1,"start_column":27,"end_column":38,"capture":"variable"},{"line":1,"start_column":38,"end_column":39,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":3,"fold_state_id":0,"start":{"line":0,"column":0},"end":{"line":2,"column":0},"tags":["deleted-bodies:function","removed-runs:function","summarize:function"],"visibility":{"label":"Function"},"kind":"fold","children":[{"id":4,"fold_state_id":1,"start":{"line":0,"column":0},"end":{"line":2,"column":0},"kind":"leaf","alignment_id":0,"changed":[{"line":0,"start_column":34,"end_column":35},{"line":0,"start_column":36,"end_column":47},{"line":0,"start_column":48,"end_column":49},{"line":0,"start_column":50,"end_column":53},{"line":1,"start_column":25,"end_column":26},{"line":1,"start_column":27,"end_column":38}]}]},{"id":5,"fold_state_id":2,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","alignment_id":1}]},"stats":{"textual":{"added":2,"removed":2},"visible":{"added":2,"removed":0}}}}
-{"type":"file","file":{"lhs":{"path":"tests/greet.test.ts","oid":"0231e3a6a765dbd026034d9e12a988347d559f88","mode":"100644"},"rhs":{"path":"tests/greet.test.ts","oid":"ae1cd95b02cc02cff3387547fceff8bda367db5b","mode":"100644"}},"visibility":{"collapsed":true,"label":"Test file · hidden by default"},"diff":{"type":"text","lhs":{"text":"import { greet } from \"../src/greet\";\ntest(\"greets\", () => expect(greet(\"a\")).toBe(\"hello a\"));\n","syntax":[{"line":0,"start_column":0,"end_column":6,"capture":"keyword"},{"line":0,"start_column":7,"end_column":8,"capture":"punctuation.bracket"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":15,"end_column":16,"capture":"punctuation.bracket"},{"line":0,"start_column":17,"end_column":21,"capture":"keyword"},{"line":0,"start_column":22,"end_column":36,"capture":"string"},{"line":0,"start_column":36,"end_column":37,"capture":"punctuation.delimiter"},{"line":1,"start_column":0,"end_column":4,"capture":"variable"},{"line":1,"start_column":4,"end_column":5,"capture":"punctuation.bracket"},{"line":1,"start_column":5,"end_column":13,"capture":"string"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.delimiter"},{"line":1,"start_column":15,"end_column":17,"capture":"punctuation.bracket"},{"line":1,"start_column":18,"end_column":20,"capture":"operator"},{"line":1,"start_column":21,"end_column":27,"capture":"variable"},{"line":1,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":1,"start_column":28,"end_column":33,"capture":"variable"},{"line":1,"start_column":33,"end_column":34,"capture":"punctuation.bracket"},{"line":1,"start_column":34,"end_column":37,"capture":"string"},{"line":1,"start_column":37,"end_column":39,"capture":"punctuation.bracket"},{"line":1,"start_column":39,"end_column":40,"capture":"punctuation.delimiter"},{"line":1,"start_column":40,"end_column":44,"capture":"property"},{"line":1,"start_column":44,"end_column":45,"capture":"punctuation.bracket"},{"line":1,"start_column":45,"end_column":54,"capture":"string"},{"line":1,"start_column":54,"end_column":56,"capture":"punctuation.bracket"},{"line":1,"start_column":56,"end_column":57,"capture":"punctuation.delimiter"}],"regions":[{"id":0,"fold_state_id":0,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf","alignment_id":0},{"id":1,"fold_state_id":1,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","alignment_id":1}]},"rhs":{"text":"import { greet } from \"../src/greet\";\ntest(\"greets\", () => expect(greet(\"a\")).toBe(\"hello a!\"));\n","syntax":[{"line":0,"start_column":0,"end_column":6,"capture":"keyword"},{"line":0,"start_column":7,"end_column":8,"capture":"punctuation.bracket"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":15,"end_column":16,"capture":"punctuation.bracket"},{"line":0,"start_column":17,"end_column":21,"capture":"keyword"},{"line":0,"start_column":22,"end_column":36,"capture":"string"},{"line":0,"start_column":36,"end_column":37,"capture":"punctuation.delimiter"},{"line":1,"start_column":0,"end_column":4,"capture":"variable"},{"line":1,"start_column":4,"end_column":5,"capture":"punctuation.bracket"},{"line":1,"start_column":5,"end_column":13,"capture":"string"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.delimiter"},{"line":1,"start_column":15,"end_column":17,"capture":"punctuation.bracket"},{"line":1,"start_column":18,"end_column":20,"capture":"operator"},{"line":1,"start_column":21,"end_column":27,"capture":"variable"},{"line":1,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":1,"start_column":28,"end_column":33,"capture":"variable"},{"line":1,"start_column":33,"end_column":34,"capture":"punctuation.bracket"},{"line":1,"start_column":34,"end_column":37,"capture":"string"},{"line":1,"start_column":37,"end_column":39,"capture":"punctuation.bracket"},{"line":1,"start_column":39,"end_column":40,"capture":"punctuation.delimiter"},{"line":1,"start_column":40,"end_column":44,"capture":"property"},{"line":1,"start_column":44,"end_column":45,"capture":"punctuation.bracket"},{"line":1,"start_column":45,"end_column":55,"capture":"string"},{"line":1,"start_column":55,"end_column":57,"capture":"punctuation.bracket"},{"line":1,"start_column":57,"end_column":58,"capture":"punctuation.delimiter"}],"regions":[{"id":2,"fold_state_id":0,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf","alignment_id":0},{"id":3,"fold_state_id":1,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","alignment_id":1,"changed":[{"line":1,"start_column":53,"end_column":54}]}]},"stats":{"textual":{"added":1,"removed":1},"visible":{"added":1,"removed":0}}}}
+{"type":"start","version":3,"lhs":{"type":"revision","rev":"4895447da86c90df267f64320ac5cd23c58d65ea"},"rhs":{"type":"revision","rev":"476b98e96f6196f2e18e1d08d5d3c2b957e7e294"},"files":[{"file":{"lhs":{"path":"src/greet.ts","oid":"8529d7a6dc7709c707ebd16b3689352f90f91993","mode":"100644"},"rhs":{"path":"src/greet.ts","oid":"f9895c906b6b46c0c562bc9e8c39b1702096cf4f","mode":"100644"}},"status":"modified"},{"file":{"lhs":{"path":"tests/greet.test.ts","oid":"0231e3a6a765dbd026034d9e12a988347d559f88","mode":"100644"},"rhs":{"path":"tests/greet.test.ts","oid":"ae1cd95b02cc02cff3387547fceff8bda367db5b","mode":"100644"}},"status":"modified","tags":["test"]}]}
+{"type":"file","file":{"lhs":{"path":"src/greet.ts","oid":"8529d7a6dc7709c707ebd16b3689352f90f91993","mode":"100644"},"rhs":{"path":"src/greet.ts","oid":"f9895c906b6b46c0c562bc9e8c39b1702096cf4f","mode":"100644"}},"diff":{"type":"text","lhs":{"text":"export function greet(name: string) {\n return \"hello \" + name;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":6,"capture":"keyword"},{"line":0,"start_column":7,"end_column":15,"capture":"keyword"},{"line":0,"start_column":16,"end_column":21,"capture":"variable"},{"line":0,"start_column":21,"end_column":22,"capture":"punctuation.bracket"},{"line":0,"start_column":22,"end_column":26,"capture":"variable"},{"line":0,"start_column":28,"end_column":34,"capture":"type.builtin"},{"line":0,"start_column":34,"end_column":35,"capture":"punctuation.bracket"},{"line":0,"start_column":36,"end_column":37,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":24,"end_column":25,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":1,"fold_state_id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf","alignment_id":0},{"id":2,"fold_state_id":2,"start":{"line":1,"column":0},"end":{"line":3,"column":0},"tags":["context:scope"],"kind":"fold","children":[{"id":3,"fold_state_id":3,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","alignment_id":1},{"id":4,"fold_state_id":4,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","alignment_id":2}]}]},"rhs":{"text":"export function greet(name: string, punctuation = \"!\") {\n return \"hello \" + name + punctuation;\n}\n","syntax":[{"line":0,"start_column":0,"end_column":6,"capture":"keyword"},{"line":0,"start_column":7,"end_column":15,"capture":"keyword"},{"line":0,"start_column":16,"end_column":21,"capture":"variable"},{"line":0,"start_column":21,"end_column":22,"capture":"punctuation.bracket"},{"line":0,"start_column":22,"end_column":26,"capture":"variable"},{"line":0,"start_column":28,"end_column":34,"capture":"type.builtin"},{"line":0,"start_column":34,"end_column":35,"capture":"punctuation.delimiter"},{"line":0,"start_column":36,"end_column":47,"capture":"variable"},{"line":0,"start_column":48,"end_column":49,"capture":"operator"},{"line":0,"start_column":50,"end_column":53,"capture":"string"},{"line":0,"start_column":53,"end_column":54,"capture":"punctuation.bracket"},{"line":0,"start_column":55,"end_column":56,"capture":"punctuation.bracket"},{"line":1,"start_column":2,"end_column":8,"capture":"keyword"},{"line":1,"start_column":9,"end_column":17,"capture":"string"},{"line":1,"start_column":18,"end_column":19,"capture":"operator"},{"line":1,"start_column":20,"end_column":24,"capture":"variable"},{"line":1,"start_column":25,"end_column":26,"capture":"operator"},{"line":1,"start_column":27,"end_column":38,"capture":"variable"},{"line":1,"start_column":38,"end_column":39,"capture":"punctuation.delimiter"},{"line":2,"start_column":0,"end_column":1,"capture":"punctuation.bracket"}],"regions":[{"id":5,"fold_state_id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf","alignment_id":0,"changed":[{"line":0,"start_column":34,"end_column":35},{"line":0,"start_column":36,"end_column":47},{"line":0,"start_column":48,"end_column":49},{"line":0,"start_column":50,"end_column":53}]},{"id":6,"fold_state_id":2,"start":{"line":1,"column":0},"end":{"line":3,"column":0},"tags":["context:scope"],"kind":"fold","children":[{"id":7,"fold_state_id":3,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","alignment_id":1,"changed":[{"line":1,"start_column":25,"end_column":26},{"line":1,"start_column":27,"end_column":38}]},{"id":8,"fold_state_id":4,"start":{"line":2,"column":0},"end":{"line":3,"column":0},"kind":"leaf","alignment_id":2}]}]},"stats":{"textual":{"added":2,"removed":2},"visible":{"added":2,"removed":0}},"structural_changes":{"base":[],"head":[[0,2]]}}}
+{"type":"file","file":{"lhs":{"path":"tests/greet.test.ts","oid":"0231e3a6a765dbd026034d9e12a988347d559f88","mode":"100644"},"rhs":{"path":"tests/greet.test.ts","oid":"ae1cd95b02cc02cff3387547fceff8bda367db5b","mode":"100644"}},"visibility":{"collapsed":true,"label":"Test file · hidden by default"},"diff":{"type":"text","lhs":{"text":"import { greet } from \"../src/greet\";\ntest(\"greets\", () => expect(greet(\"a\")).toBe(\"hello a\"));\n","syntax":[{"line":0,"start_column":0,"end_column":6,"capture":"keyword"},{"line":0,"start_column":7,"end_column":8,"capture":"punctuation.bracket"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":15,"end_column":16,"capture":"punctuation.bracket"},{"line":0,"start_column":17,"end_column":21,"capture":"keyword"},{"line":0,"start_column":22,"end_column":36,"capture":"string"},{"line":0,"start_column":36,"end_column":37,"capture":"punctuation.delimiter"},{"line":1,"start_column":0,"end_column":4,"capture":"variable"},{"line":1,"start_column":4,"end_column":5,"capture":"punctuation.bracket"},{"line":1,"start_column":5,"end_column":13,"capture":"string"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.delimiter"},{"line":1,"start_column":15,"end_column":17,"capture":"punctuation.bracket"},{"line":1,"start_column":18,"end_column":20,"capture":"operator"},{"line":1,"start_column":21,"end_column":27,"capture":"variable"},{"line":1,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":1,"start_column":28,"end_column":33,"capture":"variable"},{"line":1,"start_column":33,"end_column":34,"capture":"punctuation.bracket"},{"line":1,"start_column":34,"end_column":37,"capture":"string"},{"line":1,"start_column":37,"end_column":39,"capture":"punctuation.bracket"},{"line":1,"start_column":39,"end_column":40,"capture":"punctuation.delimiter"},{"line":1,"start_column":40,"end_column":44,"capture":"property"},{"line":1,"start_column":44,"end_column":45,"capture":"punctuation.bracket"},{"line":1,"start_column":45,"end_column":54,"capture":"string"},{"line":1,"start_column":54,"end_column":56,"capture":"punctuation.bracket"},{"line":1,"start_column":56,"end_column":57,"capture":"punctuation.delimiter"}],"regions":[{"id":1,"fold_state_id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf","alignment_id":0},{"id":2,"fold_state_id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","alignment_id":1}]},"rhs":{"text":"import { greet } from \"../src/greet\";\ntest(\"greets\", () => expect(greet(\"a\")).toBe(\"hello a!\"));\n","syntax":[{"line":0,"start_column":0,"end_column":6,"capture":"keyword"},{"line":0,"start_column":7,"end_column":8,"capture":"punctuation.bracket"},{"line":0,"start_column":9,"end_column":14,"capture":"variable"},{"line":0,"start_column":15,"end_column":16,"capture":"punctuation.bracket"},{"line":0,"start_column":17,"end_column":21,"capture":"keyword"},{"line":0,"start_column":22,"end_column":36,"capture":"string"},{"line":0,"start_column":36,"end_column":37,"capture":"punctuation.delimiter"},{"line":1,"start_column":0,"end_column":4,"capture":"variable"},{"line":1,"start_column":4,"end_column":5,"capture":"punctuation.bracket"},{"line":1,"start_column":5,"end_column":13,"capture":"string"},{"line":1,"start_column":13,"end_column":14,"capture":"punctuation.delimiter"},{"line":1,"start_column":15,"end_column":17,"capture":"punctuation.bracket"},{"line":1,"start_column":18,"end_column":20,"capture":"operator"},{"line":1,"start_column":21,"end_column":27,"capture":"variable"},{"line":1,"start_column":27,"end_column":28,"capture":"punctuation.bracket"},{"line":1,"start_column":28,"end_column":33,"capture":"variable"},{"line":1,"start_column":33,"end_column":34,"capture":"punctuation.bracket"},{"line":1,"start_column":34,"end_column":37,"capture":"string"},{"line":1,"start_column":37,"end_column":39,"capture":"punctuation.bracket"},{"line":1,"start_column":39,"end_column":40,"capture":"punctuation.delimiter"},{"line":1,"start_column":40,"end_column":44,"capture":"property"},{"line":1,"start_column":44,"end_column":45,"capture":"punctuation.bracket"},{"line":1,"start_column":45,"end_column":55,"capture":"string"},{"line":1,"start_column":55,"end_column":57,"capture":"punctuation.bracket"},{"line":1,"start_column":57,"end_column":58,"capture":"punctuation.delimiter"}],"regions":[{"id":3,"fold_state_id":1,"start":{"line":0,"column":0},"end":{"line":1,"column":0},"kind":"leaf","alignment_id":0},{"id":4,"fold_state_id":2,"start":{"line":1,"column":0},"end":{"line":2,"column":0},"kind":"leaf","alignment_id":1,"changed":[{"line":1,"start_column":53,"end_column":54}]}]},"stats":{"textual":{"added":1,"removed":1},"visible":{"added":1,"removed":0}},"structural_changes":{"base":[],"head":[[1,2]]}}}
{"type":"complete","succeeded":2,"failed":0}