Files
openrig-studio/test/surface-row-contract.test.mjs
mvschwarzandClaude Opus 5 f1a4c6f39b Make preserve a declaration the contract and the runtime both recognise
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]>
2026-08-04 13:31:30 -07:00

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));
});