mirror of
https://github.com/mvschwarz/openrig-studio.git
synced 2026-11-07 05:56:21 -10:00
A surface can now say WHAT to keep across a code reload in its manifest row. Before this the field was contract in prose only: the schema did not declare it, so the runtime reported a documented field as unknown, and an author following change-signal.md would have been told their row was unrecognised. The schema and the runtime's ROW_OPTIONAL table are two places computing one property — which fields a row may carry, and of what type — and nothing had ever compared them. The drift is silent and points the wrong way in both directions: schema-only means the contract says yes while the runtime shrugs, runtime-only means a field works while being documented nowhere, which is how a field shipped unpromised here once already. test/surface-row-contract.test.mjs compares membership AND type, reading the runtime's table out of source because importing that module starts a server. Verified by planting all three drift shapes — schema-only, runtime-only, and a type disagreement that membership alone would miss — each caught with the reason it names, each planted by an edit that aborts rather than proceed if the replacement does not apply. The runtime-level control asserts a declared list is accepted and survives into the served row, paired with a control that an UNKNOWN field still warns — without which the clean read passes equally against a runtime that stopped warning at all, which is the forward-compat behaviour the contract promises. WHAT IS DELIBERATELY NOT CLAIMED: the helper does not yet read the row and drive capture and restore itself. A surface adopting this today reads its own row and passes the list. The declaration is carried, not consumed, and the conformance table says so — a plausible declaration that quietly does nothing is the defect class this seam has already removed four times, and it is not being reintroduced under a different field name. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
103 lines
4.8 KiB
JavaScript
103 lines
4.8 KiB
JavaScript
// The surface-row schema and the runtime's validation table must not drift.
|
|
//
|
|
// node --test 'test/*.test.mjs'
|
|
//
|
|
// They are two places computing ONE property — which fields a manifest row may
|
|
// carry, and of what type. contract/surface-row.schema.json is what an author
|
|
// validates against; ROW_OPTIONAL in app/serve-studio.mjs is what actually
|
|
// decides whether a row is accepted. Nothing had ever compared them.
|
|
//
|
|
// The drift is silent AND it points the wrong way: add a field to the schema
|
|
// alone and an author following the contract writes a row the runtime reports as
|
|
// an "unknown field" — the contract says yes while the runtime shrugs. Add it to
|
|
// the runtime alone and the field works while being documented nowhere, which is
|
|
// exactly how `manifest.consumer.dir` came to ship unpromised.
|
|
//
|
|
// This is the same class as the app-manifest doc/schema guard next door, one
|
|
// layer down: prose and schema there, schema and code here.
|
|
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const SCHEMA = JSON.parse(fs.readFileSync(path.join(REPO, "contract", "surface-row.schema.json"), "utf8"));
|
|
const RUNTIME = fs.readFileSync(path.join(REPO, "app", "serve-studio.mjs"), "utf8");
|
|
|
|
// The runtime's table, read from source. Parsed rather than imported because
|
|
// serve-studio.mjs starts a server on import — the same reason the declared-rows
|
|
// guard next door reads its block out of the file.
|
|
function runtimeTable() {
|
|
const block = RUNTIME.match(/const ROW_OPTIONAL = \{([\s\S]*?)\n?\};/);
|
|
assert.ok(block, "ROW_OPTIONAL is no longer a literal in app/serve-studio.mjs — this guard cannot read it");
|
|
const table = {};
|
|
for (const m of block[1].matchAll(/(\w+):\s*"(\w+)"/g)) table[m[1]] = m[2];
|
|
return table;
|
|
}
|
|
|
|
// Fields the runtime validates by hand, ahead of the optional table.
|
|
const HANDLED_SEPARATELY = new Set(["id", "name", "path"]);
|
|
const schemaOptional = () =>
|
|
Object.fromEntries(
|
|
Object.entries(SCHEMA.properties)
|
|
.filter(([name]) => !HANDLED_SEPARATELY.has(name))
|
|
.map(([name, prop]) => [name, prop.type])
|
|
);
|
|
|
|
test("positive control: both sides were actually read", () => {
|
|
// Without this a regex that silently matched nothing would make every
|
|
// comparison below pass by comparing two empty objects.
|
|
const table = runtimeTable();
|
|
assert.ok(Object.keys(table).length >= 5, `ROW_OPTIONAL parsed as ${JSON.stringify(table)}`);
|
|
assert.ok(Object.keys(schemaOptional()).length >= 5, "the schema parsed with almost no properties");
|
|
});
|
|
|
|
test("the schema and the runtime accept exactly the same optional fields", () => {
|
|
const table = runtimeTable();
|
|
const schema = schemaOptional();
|
|
|
|
const inSchemaOnly = Object.keys(schema).filter((k) => !(k in table));
|
|
const inRuntimeOnly = Object.keys(table).filter((k) => !(k in schema));
|
|
|
|
assert.deepEqual(inSchemaOnly, [],
|
|
`the schema declares field(s) the runtime treats as unknown: ${inSchemaOnly.join(", ")} — ` +
|
|
`an author following the contract would get a warning for a documented field`);
|
|
assert.deepEqual(inRuntimeOnly, [],
|
|
`the runtime accepts field(s) the schema does not declare: ${inRuntimeOnly.join(", ")} — ` +
|
|
`a field that works while being documented nowhere is how one shipped unpromised before`);
|
|
});
|
|
|
|
test("and they agree on each field's TYPE", () => {
|
|
// Membership alone would pass while the schema said array and the runtime
|
|
// demanded a string, which rejects every correctly-authored row.
|
|
const table = runtimeTable();
|
|
for (const [name, type] of Object.entries(schemaOptional())) {
|
|
assert.equal(table[name], type,
|
|
`${name}: schema says ${type}, the runtime validates it as ${table[name]}`);
|
|
}
|
|
});
|
|
|
|
test("preserve is declared, is an array, and is stable", () => {
|
|
// The declaration this slice adds. Asserted by name because the guards above
|
|
// would stay green if it were dropped from BOTH sides at once.
|
|
const p = SCHEMA.properties.preserve;
|
|
assert.ok(p, "the preserve declaration is gone from the surface-row schema");
|
|
assert.equal(p.type, "array");
|
|
assert.equal(p["x-stability"], "stable");
|
|
assert.equal(runtimeTable().preserve, "array", "the runtime does not accept a declared preserve list");
|
|
});
|
|
|
|
test("every schema property carries a valid stability mark", () => {
|
|
const CLASSES = new Set(["stable", "provisional", "runtime-internal"]);
|
|
for (const [name, prop] of Object.entries(SCHEMA.properties)) {
|
|
assert.ok(CLASSES.has(prop["x-stability"]),
|
|
`${name} has x-stability ${JSON.stringify(prop["x-stability"])}, which is not a class contract-meta defines`);
|
|
}
|
|
});
|
|
|
|
test("positive control: this suite is capable of failing", () => {
|
|
assert.throws(() => assert.equal(1, 2));
|
|
});
|