feat: publish coauthoring-sidecar JSON Schema + rung-neutral validator (F5 SLICE-1, PUC-5, #14)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <sidecar.json>
|
||||||
|
// 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 <sidecar.json>");
|
||||||
|
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);
|
||||||
@@ -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")}`);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user