From 7c4d3ed79f4f75017c903c5228f52ad69f22ef99 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 22:54:39 -0700 Subject: [PATCH] feat: publish coauthoring-sidecar JSON Schema + rung-neutral validator (F5 SLICE-1, PUC-5, #14) Co-Authored-By: Claude Opus 4.8 --- schemas/coauthoring-sidecar.schema.json | 112 ++++++++++++++++++++++++ scripts/validate-sidecar.mjs | 35 ++++++++ test/helpers/validateSidecar.ts | 24 +++++ test/schema.test.ts | 56 ++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 schemas/coauthoring-sidecar.schema.json create mode 100644 scripts/validate-sidecar.mjs create mode 100644 test/helpers/validateSidecar.ts create mode 100644 test/schema.test.ts diff --git a/schemas/coauthoring-sidecar.schema.json b/schemas/coauthoring-sidecar.schema.json new file mode 100644 index 0000000..0f2a23d --- /dev/null +++ b/schemas/coauthoring-sidecar.schema.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.benstull.org/benstull/vscode-cowriting-plugin/schemas/coauthoring-sidecar.schema.json", + "title": "Coauthoring sidecar artifact — cross-rung contract, schemaVersion 1", + "description": "Machine-checkable half of the cross-rung contract (vscode-cowriting-plugin-content/specs/coauthoring-sidecar-contract.md — the prose contract is normative, INV-14). Unknown fields are deliberately allowed at every level: minor versions are additive and conforming writers preserve fields they do not recognize (INV-15/INV-16).", + "type": "object", + "required": ["schemaVersion", "document", "anchors", "threads", "attributions", "proposals"], + "properties": { + "schemaVersion": { "type": "integer", "minimum": 1 }, + "document": { + "type": "object", + "required": ["path"], + "properties": { "path": { "type": "string", "minLength": 1 } } + }, + "anchors": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/anchor" } + }, + "threads": { "type": "array", "items": { "$ref": "#/$defs/thread" } }, + "attributions": { "type": "array", "items": { "$ref": "#/$defs/attribution" } }, + "proposals": { "type": "array", "items": { "$ref": "#/$defs/proposal" } } + }, + "$defs": { + "fingerprint": { + "type": "object", + "required": ["text", "before", "after", "lineHint"], + "properties": { + "text": { "type": "string" }, + "before": { "type": "string" }, + "after": { "type": "string" }, + "lineHint": { "type": "integer", "minimum": 0 } + } + }, + "anchor": { + "type": "object", + "required": ["fingerprint"], + "properties": { "fingerprint": { "$ref": "#/$defs/fingerprint" } } + }, + "onBehalfOf": { + "type": "object", + "required": ["id"], + "properties": { "id": { "type": "string" }, "email": { "type": "string" } } + }, + "agentPayload": { + "type": "object", + "required": ["sdk", "model", "sessionId"], + "properties": { + "sdk": { "type": "string" }, + "model": { "type": "string" }, + "sessionId": { "type": "string" }, + "onBehalfOf": { "$ref": "#/$defs/onBehalfOf" } + } + }, + "provenance": { + "type": "object", + "required": ["kind", "id"], + "properties": { + "kind": { "enum": ["human", "agent"] }, + "id": { "type": "string" }, + "email": { "type": "string" }, + "agent": { "$ref": "#/$defs/agentPayload" } + }, + "if": { "properties": { "kind": { "const": "agent" } } }, + "then": { "required": ["kind", "id", "agent"] } + }, + "message": { + "type": "object", + "required": ["id", "author", "body", "createdAt"], + "properties": { + "id": { "type": "string" }, + "author": { "$ref": "#/$defs/provenance" }, + "body": { "type": "string" }, + "createdAt": { "type": "string" } + } + }, + "thread": { + "type": "object", + "required": ["id", "anchorId", "status", "messages"], + "properties": { + "id": { "type": "string" }, + "anchorId": { "type": "string" }, + "status": { "enum": ["open", "resolved"] }, + "messages": { "type": "array", "items": { "$ref": "#/$defs/message" } } + } + }, + "attribution": { + "type": "object", + "required": ["id", "anchorId", "author", "createdAt", "updatedAt"], + "properties": { + "id": { "type": "string" }, + "anchorId": { "type": "string" }, + "author": { "$ref": "#/$defs/provenance" }, + "createdAt": { "type": "string" }, + "updatedAt": { "type": "string" }, + "turnId": { "type": "string" } + } + }, + "proposal": { + "type": "object", + "required": ["id", "anchorId", "replacement", "author", "createdAt"], + "properties": { + "id": { "type": "string" }, + "anchorId": { "type": "string" }, + "replacement": { "type": "string" }, + "author": { "$ref": "#/$defs/provenance" }, + "createdAt": { "type": "string" }, + "turnId": { "type": "string" }, + "instruction": { "type": "string" } + } + } + } +} diff --git a/scripts/validate-sidecar.mjs b/scripts/validate-sidecar.mjs new file mode 100644 index 0000000..8ca56f1 --- /dev/null +++ b/scripts/validate-sidecar.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +// validate-sidecar — rung-neutral conformance check (spec §6.4, PUC-5). +// usage: +// node scripts/validate-sidecar.mjs +// exit 0 = valid, 1 = invalid (per-error JSON paths on stderr), 2 = usage/IO. +import { readFileSync } from "node:fs"; +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 file = process.argv[2]; +if (!file) { + console.error("usage: validate-sidecar.mjs "); + process.exit(2); +} + +let data; +try { + data = JSON.parse(readFileSync(file, "utf8")); +} catch (err) { + console.error(`cannot read/parse ${file}: ${err.message}`); + process.exit(2); +} + +const ajv = new Ajv({ allErrors: true }); +const validate = ajv.compile(schema); +if (validate(data)) { + console.log(`OK ${file} (schemaVersion ${data.schemaVersion})`); + process.exit(0); +} +for (const e of validate.errors ?? []) console.error(`${e.instancePath || "/"} ${e.message}`); +process.exit(1); diff --git a/test/helpers/validateSidecar.ts b/test/helpers/validateSidecar.ts new file mode 100644 index 0000000..24b826f --- /dev/null +++ b/test/helpers/validateSidecar.ts @@ -0,0 +1,24 @@ +/** + * Schema-conformance helper (F5 SLICE-1, PUC-5): every serialized artifact in + * the unit suite must validate against the published cross-rung schema — + * contract drift fails CI (spec §7.4). + */ +import { readFileSync } from "node:fs"; +import Ajv from "ajv/dist/2020"; +import { serializeArtifact, type Artifact } from "../../src/model"; + +const schemaUrl = new URL("../../schemas/coauthoring-sidecar.schema.json", import.meta.url); +const schema = JSON.parse(readFileSync(schemaUrl, "utf8")); +const ajv = new Ajv({ allErrors: true }); +const validate = ajv.compile(schema); + +export function validateSidecar(data: unknown): { valid: boolean; errors: string[] } { + const valid = validate(data) as boolean; + const errors = (validate.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message}`); + return { valid, errors }; +} + +export function expectValidSidecar(artifact: Artifact): void { + const { valid, errors } = validateSidecar(JSON.parse(serializeArtifact(artifact))); + if (!valid) throw new Error(`sidecar failed schema validation:\n${errors.join("\n")}`); +} diff --git a/test/schema.test.ts b/test/schema.test.ts new file mode 100644 index 0000000..173cbf8 --- /dev/null +++ b/test/schema.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { emptyArtifact, serializeArtifact, type Artifact } from "../src/model"; +import { expectValidSidecar, validateSidecar } from "./helpers/validateSidecar"; + +function fullArtifact(): Artifact { + const a = emptyArtifact("docs/x.md"); + a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: " beta", 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" }], + }); + a.attributions.push({ + id: "at_1", + anchorId: "a_1", + author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" } }, + createdAt: "2026-06-10T00:00:00.000Z", + updatedAt: "2026-06-10T00:00:00.000Z", + turnId: "turn-1", + }); + a.proposals.push({ + id: "p_1", + anchorId: "a_1", + replacement: "ALPHA", + author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" } }, + createdAt: "2026-06-10T00:00:00.000Z", + instruction: "shout", + }); + return a; +} + +describe("coauthoring-sidecar JSON Schema (F5 SLICE-1, PUC-5)", () => { + it("validates the empty artifact and a full artifact", () => { + expectValidSidecar(emptyArtifact("docs/x.md")); + expectValidSidecar(fullArtifact()); + }); + + it("rejects a structurally broken sidecar with error paths", () => { + const broken = JSON.parse(serializeArtifact(fullArtifact())); + broken.threads[0].status = "closed"; // not in the enum + delete broken.schemaVersion; + const { valid, errors } = validateSidecar(broken); + expect(valid).toBe(false); + expect(errors.some((e) => e.includes("schemaVersion"))).toBe(true); + }); + + it("accepts unknown fields everywhere (forward compat, INV-16)", () => { + const data = JSON.parse(serializeArtifact(fullArtifact())); + data.futureSection = [{ id: "f_1" }]; + data.threads[0].futureField = true; + data.threads[0].messages[0].reactions = ["+1"]; + const { valid } = validateSidecar(data); + expect(valid).toBe(true); + }); +});