diff --git a/scripts/crossrung-reply.mjs b/scripts/crossrung-reply.mjs new file mode 100644 index 0000000..508052b --- /dev/null +++ b/scripts/crossrung-reply.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// crossrung-reply — the Gitea-rung STAND-IN writer (F5 spec §6.2, fork e): +// exactly what a forge-side surface does to the record, as a local process. +// Conforming-writer behavior per the contract (vscode-cowriting-plugin-content/ +// specs/coauthoring-sidecar-contract.md §5): validate → refuse newer majors → +// append a Message (fresh uuid, ISO-8601 UTC, identity w/ email) → +// stable-serialize (known-key order, unknowns after sorted, 2-space, trailing +// newline) → validate again → write. +// +// This deliberately RE-IMPLEMENTS the contract's serialization instead of +// importing the extension's serializer: it is executable documentation of how +// a second rung behaves. test/crossrungReply.test.ts pins byte-fidelity +// against the reference serializer — the contract-drift tripwire. +// +// usage: +// node scripts/crossrung-reply.mjs \ +// [--author-id id] [--author-email email] +import { readFileSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import Ajv from "ajv/dist/2020.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const schema = JSON.parse(readFileSync(path.join(here, "..", "schemas", "coauthoring-sidecar.schema.json"), "utf8")); +const ajv = new Ajv({ allErrors: true }); +const checks = ajv.compile(schema); + +function fail(msg) { + console.error(`crossrung-reply: ${msg}`); + process.exit(1); +} +function validateOrFail(data, when) { + if (!checks(data)) { + fail( + `${when} validation failed:\n` + + (checks.errors ?? []).map((e) => ` ${e.instancePath || "/"} ${e.message}`).join("\n"), + ); + } +} + +// --- args --- +const [file, threadId, body] = process.argv.slice(2, 5); +if (!file || !threadId || body === undefined) { + console.error("usage: crossrung-reply.mjs [--author-id id] [--author-email email]"); + process.exit(2); +} +const flags = process.argv.slice(5); +const flagVal = (name) => { + const i = flags.indexOf(name); + return i >= 0 ? flags[i + 1] : undefined; +}; +const authorId = flagVal("--author-id") ?? "crossrung"; +const authorEmail = flagVal("--author-email"); + +// --- contract serialization (independent implementation: contract §2) --- +const ORDER = { + root: ["schemaVersion", "document", "anchors", "threads", "attributions", "proposals"], + document: ["path"], + anchor: ["fingerprint"], + fingerprint: ["text", "before", "after", "lineHint"], + thread: ["id", "anchorId", "status", "messages"], + message: ["id", "author", "body", "createdAt"], + provenance: ["kind", "id", "email", "agent"], + agent: ["sdk", "model", "sessionId", "onBehalfOf"], + onBehalfOf: ["id", "email"], + attribution: ["id", "anchorId", "author", "createdAt", "updatedAt", "turnId"], + proposal: ["id", "anchorId", "replacement", "author", "createdAt", "turnId", "instruction"], +}; +// known keys first (contract order, present ones only), unknown keys after, sorted +function ordered(obj, kind, mapKnown = {}) { + const out = {}; + const known = ORDER[kind] ?? []; + for (const k of known) if (k in obj) out[k] = k in mapKnown ? mapKnown[k](obj[k]) : obj[k]; + for (const k of Object.keys(obj).filter((k) => !known.includes(k)).sort()) out[k] = obj[k]; + return out; +} +const provenance = (p) => + ordered(p, "provenance", { + agent: (ag) => ordered(ag, "agent", { onBehalfOf: (o) => ordered(o, "onBehalfOf") }), + }); +function canonicalize(a) { + return ordered(a, "root", { + document: (d) => ordered(d, "document"), + anchors: (anchors) => + Object.fromEntries( + Object.keys(anchors) + .sort() + .map((k) => [k, ordered(anchors[k], "anchor", { fingerprint: (fp) => ordered(fp, "fingerprint") })]), + ), + threads: (ts) => + ts.map((t) => ordered(t, "thread", { messages: (ms) => ms.map((m) => ordered(m, "message", { author: provenance })) })), + attributions: (ats) => ats.map((at) => ordered(at, "attribution", { author: provenance })), + proposals: (ps) => ps.map((p) => ordered(p, "proposal", { author: provenance })), + }); +} +const serialize = (a) => JSON.stringify(canonicalize(a), null, 2) + "\n"; + +// --- the write (contract §5 checklist) --- +const data = JSON.parse(readFileSync(file, "utf8")); +validateOrFail(data, "pre-write"); +if (data.schemaVersion > 1) { + fail(`schemaVersion ${data.schemaVersion} is newer than this writer understands (1) — refusing to write (INV-16)`); +} +const thread = data.threads.find((t) => t.id === threadId); +if (!thread) fail(`no thread ${threadId} in ${file}`); +thread.messages.push({ + id: `m_${randomUUID()}`, + author: { kind: "human", id: authorId, ...(authorEmail ? { email: authorEmail } : {}) }, + body, + createdAt: new Date().toISOString(), +}); +const out = serialize(data); +validateOrFail(JSON.parse(out), "post-write"); +writeFileSync(file, out, "utf8"); +console.log(`appended reply to ${threadId} in ${file}`); diff --git a/test/crossrungReply.test.ts b/test/crossrungReply.test.ts new file mode 100644 index 0000000..bacb6fc --- /dev/null +++ b/test/crossrungReply.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; +import { emptyArtifact, serializeArtifact, type Artifact } from "../src/model"; +import { validateSidecar } from "./helpers/validateSidecar"; + +const SCRIPT = path.resolve(__dirname, "../scripts/crossrung-reply.mjs"); +let dir: string; +let sidecar: string; + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-crossrung-")); + sidecar = path.join(dir, "sample.md.json"); + const a = emptyArtifact("docs/sample.md"); + a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: "", lineHint: 0 } }; + a.threads.push({ + id: "t_1", + anchorId: "a_1", + status: "open", + messages: [{ id: "m_1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }], + }); + // INV-15 must hold through a FOREIGN writer too + (a as unknown as Record).futureSection = [{ id: "f_1" }]; + fs.writeFileSync(sidecar, serializeArtifact(a), "utf8"); +}); +afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + +function run(...args: string[]): string { + return execFileSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8" }); +} + +describe("crossrung-reply stand-in (F5 SLICE-4, PUC-2)", () => { + it("appends a conforming reply, preserves unknowns, byte-identical to the reference serializer", () => { + run(sidecar, "t_1", "Reply from the forge", "--author-id", "gitea", "--author-email", "forge@example.org"); + const raw = fs.readFileSync(sidecar, "utf8"); + const parsed = JSON.parse(raw) as Artifact; + expect(parsed.threads[0].messages).toHaveLength(2); + const reply = parsed.threads[0].messages[1]; + expect(reply.body).toBe("Reply from the forge"); + expect(reply.author).toEqual({ kind: "human", id: "gitea", email: "forge@example.org" }); + expect(reply.id).toMatch(/^m_/); + expect((parsed as unknown as Record).futureSection).toEqual([{ id: "f_1" }]); + expect(validateSidecar(parsed).valid).toBe(true); + // the stand-in's independent serialization is byte-identical to the + // editor's (INV-2 across writers) — the contract-drift tripwire. + expect(serializeArtifact(parsed)).toBe(raw); + }); + + it("refuses an unknown thread id and leaves the file untouched", () => { + const before = fs.readFileSync(sidecar, "utf8"); + expect(() => run(sidecar, "t_nope", "x")).toThrow(); + expect(fs.readFileSync(sidecar, "utf8")).toBe(before); + }); + + it("refuses an invalid sidecar (validates BEFORE writing)", () => { + fs.writeFileSync(sidecar, '{"schemaVersion":1}\n', "utf8"); + expect(() => run(sidecar, "t_1", "x")).toThrow(); + }); + + it("refuses a newer-major sidecar (INV-16 holds for foreign writers too)", () => { + const v2 = JSON.parse(fs.readFileSync(sidecar, "utf8")); + v2.schemaVersion = 2; + fs.writeFileSync(sidecar, JSON.stringify(v2, null, 2) + "\n", "utf8"); + const before = fs.readFileSync(sidecar, "utf8"); + expect(() => run(sidecar, "t_1", "x")).toThrow(); + expect(fs.readFileSync(sidecar, "utf8")).toBe(before); + }); +});