feat: crossrung-reply stand-in writer — validate, append, stable-serialize (F5 SLICE-4, #14)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <sidecar.json> <threadId> <body> \
|
||||||
|
// [--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 <sidecar.json> <threadId> <body> [--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}`);
|
||||||
@@ -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<string, unknown>).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<string, unknown>).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user