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}