Require plugin availability in projection schemas

This commit is contained in:
Meta Vens 2026-07-09 16:29:51 +01:00
commit 4bee5c8504
9 changed files with 381 additions and 123 deletions

View file

@ -974,6 +974,12 @@ Recently cut:
required for the VN surface, while `norn.graph` and `tex.math` are
`optional-nested` independent sidecar plugin projections. This keeps
runtime projection support from becoming an implied Sai dependency story.
- Runtime projection schemas now type that boundary. Electron, TUI, Unity
scene, and Unity UI Toolkit projection schemas require plugin projections to
declare `availability`, `semanticOwner`, ABI schema, command boundary, and
capability claims; the local schema subset validators now resolve `$ref` and
enforce enum values so missing availability cannot hide in recursive runtime
capture trees.
- Sai, Norn, and TeX now have plugin ABI fixtures for describe, validate,
project, lower, measure, and apply behavior. The parity harness validates
those fixtures against plugin manifests, so Eve proves the ABI shape without

View file

@ -1,10 +1,20 @@
export function validateSchemaSubset(schema, value, pointer = "$") {
export function validateSchemaSubset(schema, value, pointer = "$", rootSchema = schema) {
const errors = [];
if (!schema || typeof schema !== "object") return errors;
if (schema.$ref) {
const resolved = resolveSchemaReference(rootSchema, schema.$ref);
return resolved
? validateSchemaSubset(resolved, value, pointer, rootSchema)
: [`${pointer}:unresolved-ref:${schema.$ref}`];
}
if (schema.const !== undefined && value !== schema.const) {
errors.push(`${pointer}:const:${schema.const}`);
}
if (schema.enum && !schema.enum.includes(value)) {
errors.push(`${pointer}:enum:${schema.enum.join("|")}`);
}
if (schema.type && !matchesSchemaType(value, schema.type)) {
errors.push(`${pointer}:type:${schema.type}`);
return errors;
@ -23,18 +33,26 @@ export function validateSchemaSubset(schema, value, pointer = "$") {
if (schema.properties && value && typeof value === "object" && !Array.isArray(value)) {
for (const [key, childSchema] of Object.entries(schema.properties)) {
if (value[key] !== undefined) {
errors.push(...validateSchemaSubset(childSchema, value[key], `${pointer}.${key}`));
errors.push(...validateSchemaSubset(childSchema, value[key], `${pointer}.${key}`, rootSchema));
}
}
}
if (schema.items && Array.isArray(value)) {
value.forEach((item, index) => {
errors.push(...validateSchemaSubset(schema.items, item, `${pointer}[${index}]`));
errors.push(...validateSchemaSubset(schema.items, item, `${pointer}[${index}]`, rootSchema));
});
}
return errors;
}
function resolveSchemaReference(rootSchema, reference) {
if (!reference.startsWith("#/")) return null;
return reference.slice(2).split("/").reduce((current, segment) => {
if (!current || typeof current !== "object") return null;
return current[segment.replaceAll("~1", "/").replaceAll("~0", "~")];
}, rootSchema);
}
function matchesSchemaType(value, type) {
switch (type) {
case "array":

View file

@ -46,6 +46,84 @@
"minLength": 1
},
"root": {
"$ref": "#/$defs/shellNode"
}
},
"$defs": {
"embeddedDocument": {
"type": "object",
"required": [
"slotId",
"documentId"
],
"properties": {
"slotId": {
"type": "string",
"minLength": 1
},
"documentId": {
"type": "string",
"minLength": 1
},
"schemaId": {
"type": "string"
},
"presentationKind": {
"type": "string"
}
},
"additionalProperties": true
},
"pluginProjection": {
"type": "object",
"required": [
"pluginId",
"projectionKind",
"abiSchema",
"commandBoundary",
"capabilities",
"semanticOwner",
"availability"
],
"properties": {
"pluginId": {
"type": "string",
"minLength": 1
},
"projectionKind": {
"type": "string",
"minLength": 1
},
"abiSchema": {
"type": "string",
"minLength": 1
},
"commandBoundary": {
"type": "string",
"minLength": 1
},
"capabilities": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"documentId": {
"type": "string"
},
"semanticOwner": {
"type": "string",
"minLength": 1
},
"availability": {
"type": "string",
"enum": ["required", "optional", "optional-nested"]
}
},
"additionalProperties": true
},
"shellNode": {
"type": "object",
"required": [
"id",
@ -85,32 +163,17 @@
"embeddedDocuments": {
"type": "array",
"items": {
"type": "object",
"required": [
"slotId",
"documentId"
],
"properties": {
"slotId": {
"type": "string",
"minLength": 1
},
"documentId": {
"type": "string",
"minLength": 1
},
"schemaId": {
"type": "string"
},
"presentationKind": {
"type": "string"
}
},
"additionalProperties": true
"$ref": "#/$defs/embeddedDocument"
}
},
"pluginProjection": {
"$ref": "#/$defs/pluginProjection"
},
"children": {
"type": "array"
"type": "array",
"items": {
"$ref": "#/$defs/shellNode"
}
}
},
"additionalProperties": true

View file

@ -84,52 +84,7 @@
"pluginProjections": {
"type": "array",
"items": {
"type": "object",
"required": [
"ownerId",
"pluginId",
"projectionKind",
"semanticOwner",
"fallbackKind"
],
"properties": {
"ownerId": {
"type": "string",
"minLength": 1
},
"pluginId": {
"type": "string",
"minLength": 1
},
"projectionKind": {
"type": "string",
"minLength": 1
},
"abiSchema": {
"type": "string"
},
"commandBoundary": {
"type": "string"
},
"capabilities": {
"type": "array",
"items": {
"type": "string"
}
},
"documentId": {
"type": "string"
},
"semanticOwner": {
"type": "string",
"minLength": 1
},
"fallbackKind": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": true
"$ref": "#/$defs/pluginProjection"
}
},
"lossiness": {
@ -137,5 +92,66 @@
"minLength": 1
}
},
"$defs": {
"pluginProjection": {
"type": "object",
"required": [
"ownerId",
"pluginId",
"projectionKind",
"abiSchema",
"commandBoundary",
"capabilities",
"semanticOwner",
"availability",
"fallbackKind"
],
"properties": {
"ownerId": {
"type": "string",
"minLength": 1
},
"pluginId": {
"type": "string",
"minLength": 1
},
"projectionKind": {
"type": "string",
"minLength": 1
},
"abiSchema": {
"type": "string",
"minLength": 1
},
"commandBoundary": {
"type": "string",
"minLength": 1
},
"capabilities": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"documentId": {
"type": "string"
},
"semanticOwner": {
"type": "string",
"minLength": 1
},
"availability": {
"type": "string",
"enum": ["required", "optional", "optional-nested"]
},
"fallbackKind": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": true
}
},
"additionalProperties": true
}

View file

@ -50,6 +50,87 @@
"minLength": 1
},
"root": {
"$ref": "#/$defs/sceneNode"
}
},
"$defs": {
"embeddedDocument": {
"type": "object",
"required": [
"slotId",
"documentId"
],
"properties": {
"slotId": {
"type": "string",
"minLength": 1
},
"documentId": {
"type": "string",
"minLength": 1
},
"schemaId": {
"type": "string"
},
"presentationKind": {
"type": "string"
}
},
"additionalProperties": true
},
"pluginProjection": {
"type": "object",
"required": [
"pluginId",
"projectionKind",
"abiSchema",
"commandBoundary",
"capabilities",
"semanticOwner",
"availability"
],
"properties": {
"pluginId": {
"type": "string",
"minLength": 1
},
"projectionKind": {
"type": "string",
"minLength": 1
},
"abiSchema": {
"type": "string",
"minLength": 1
},
"commandBoundary": {
"type": "string",
"minLength": 1
},
"capabilities": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"command": {
"type": "string"
},
"documentId": {
"type": "string"
},
"semanticOwner": {
"type": "string",
"minLength": 1
},
"availability": {
"type": "string",
"enum": ["required", "optional", "optional-nested"]
}
},
"additionalProperties": true
},
"sceneNode": {
"type": "object",
"required": [
"id",
@ -89,35 +170,17 @@
"embeddedDocuments": {
"type": "array",
"items": {
"type": "object",
"required": [
"slotId",
"documentId"
],
"properties": {
"slotId": {
"type": "string",
"minLength": 1
},
"documentId": {
"type": "string",
"minLength": 1
},
"schemaId": {
"type": "string"
},
"presentationKind": {
"type": "string"
}
},
"additionalProperties": true
"$ref": "#/$defs/embeddedDocument"
}
},
"pluginProjection": {
"type": "object"
"$ref": "#/$defs/pluginProjection"
},
"children": {
"type": "array"
"type": "array",
"items": {
"$ref": "#/$defs/sceneNode"
}
}
},
"additionalProperties": true

View file

@ -50,6 +50,87 @@
"minLength": 1
},
"root": {
"$ref": "#/$defs/visualElementNode"
}
},
"$defs": {
"embeddedDocument": {
"type": "object",
"required": [
"slotId",
"documentId"
],
"properties": {
"slotId": {
"type": "string",
"minLength": 1
},
"documentId": {
"type": "string",
"minLength": 1
},
"schemaId": {
"type": "string"
},
"presentationKind": {
"type": "string"
}
},
"additionalProperties": true
},
"pluginProjection": {
"type": "object",
"required": [
"pluginId",
"projectionKind",
"abiSchema",
"commandBoundary",
"capabilities",
"semanticOwner",
"availability"
],
"properties": {
"pluginId": {
"type": "string",
"minLength": 1
},
"projectionKind": {
"type": "string",
"minLength": 1
},
"abiSchema": {
"type": "string",
"minLength": 1
},
"commandBoundary": {
"type": "string",
"minLength": 1
},
"capabilities": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}
},
"command": {
"type": "string"
},
"documentId": {
"type": "string"
},
"semanticOwner": {
"type": "string",
"minLength": 1
},
"availability": {
"type": "string",
"enum": ["required", "optional", "optional-nested"]
}
},
"additionalProperties": true
},
"visualElementNode": {
"type": "object",
"required": [
"id",
@ -96,35 +177,17 @@
"embeddedDocuments": {
"type": "array",
"items": {
"type": "object",
"required": [
"slotId",
"documentId"
],
"properties": {
"slotId": {
"type": "string",
"minLength": 1
},
"documentId": {
"type": "string",
"minLength": 1
},
"schemaId": {
"type": "string"
},
"presentationKind": {
"type": "string"
}
},
"additionalProperties": true
"$ref": "#/$defs/embeddedDocument"
}
},
"pluginProjection": {
"type": "object"
"$ref": "#/$defs/pluginProjection"
},
"children": {
"type": "array"
"type": "array",
"items": {
"$ref": "#/$defs/visualElementNode"
}
}
},
"additionalProperties": true

View file

@ -1,8 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { validateSchemaSubset } from "../../runtimes/incubating/test-support/schema-subset.mjs";
import { buildUnitySceneCaptureArtifact, buildUnitySceneProjection } from "./eveunity-scene-capture-artifact.mjs";
const unitySceneProjectionSchema = JSON.parse(readFileSync(new URL("../../schemas/gamecult.eve.unity_scene_projection.v1.schema.json", import.meta.url), "utf8"));
const aetheriaAdvertisement = JSON.parse(readFileSync(new URL("../../web/fixtures/aetheria.provider-advertisement.json", import.meta.url), "utf8"));
const capabilityManifest = JSON.parse(readFileSync(new URL("../../runtimes/incubating/eve-unity-scene/eve-runtime-capability.json", import.meta.url), "utf8"));
const aetheriaSurfaceDocument = JSON.parse(readFileSync(new URL("../../web/fixtures/aetheria-world-surface.json", import.meta.url), "utf8"));
@ -20,6 +22,7 @@ test("builds Unity scene projection capture artifact from provider advertisement
});
assert.equal(request.artifactPath, "artifacts/eveunity-scene-capture/smoke/unity-scene.png");
assert.deepEqual(validateSchemaSubset(unitySceneProjectionSchema, projection), []);
assert.equal(projection.schema, "gamecult.eve.unity_scene_projection.v1");
assert.equal(projection.runtimeId, "unity-scene");
assert.equal(projection.providerId, "aetheria");
@ -43,6 +46,7 @@ test("marks Sai required and Norn TeX optional sidecar plugin projections", () =
};
const projection = buildUnitySceneProjection(saiSurfaceDocument, advertisement, "sai.visual_novel.surface");
assert.deepEqual(validateSchemaSubset(unitySceneProjectionSchema, projection), []);
const graphNode = projection.root.children.find(child => child.id === "sai.graph");
const texLayer = projection.root.children.find(child => child.id === "sai.embeds");
const texNode = texLayer.children.find(child => child.id === "sai.tex.log-power");

View file

@ -1,8 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { validateSchemaSubset } from "../../runtimes/incubating/test-support/schema-subset.mjs";
import { buildUnityUiToolkitCaptureArtifact, buildUnityUiToolkitProjection } from "./eveunity-uitoolkit-capture-artifact.mjs";
const unityUiToolkitProjectionSchema = JSON.parse(readFileSync(new URL("../../schemas/gamecult.eve.unity_uitoolkit_projection.v1.schema.json", import.meta.url), "utf8"));
const aetheriaAdvertisement = JSON.parse(readFileSync(new URL("../../web/fixtures/aetheria.provider-advertisement.json", import.meta.url), "utf8"));
const capabilityManifest = JSON.parse(readFileSync(new URL("../../packages/org.gamecult.eve.unity-uitoolkit/eve-runtime-capability.json", import.meta.url), "utf8"));
const aetheriaSurfaceDocument = JSON.parse(readFileSync(new URL("../../web/fixtures/aetheria-world-surface.json", import.meta.url), "utf8"));
@ -20,6 +22,7 @@ test("builds Unity UI Toolkit projection capture artifact from provider advertis
});
assert.equal(request.artifactPath, "artifacts/eveunity-uitoolkit-capture/smoke/unity-uitoolkit.png");
assert.deepEqual(validateSchemaSubset(unityUiToolkitProjectionSchema, projection), []);
assert.equal(projection.schema, "gamecult.eve.unity_uitoolkit_projection.v1");
assert.equal(projection.runtimeId, "unity-uitoolkit");
assert.equal(projection.providerId, "aetheria");
@ -44,6 +47,7 @@ test("marks Sai required and Norn TeX optional sidecar plugin UI projections", (
};
const projection = buildUnityUiToolkitProjection(saiSurfaceDocument, advertisement, "sai.visual_novel.surface");
assert.deepEqual(validateSchemaSubset(unityUiToolkitProjectionSchema, projection), []);
const graphNode = projection.root.children.find(child => child.id === "sai.graph");
const texLayer = projection.root.children.find(child => child.id === "sai.embeds");
const texNode = texLayer.children.find(child => child.id === "sai.tex.log-power");

View file

@ -1242,13 +1242,23 @@ function extractProviderSchemaIds(schemas) {
}).filter(Boolean).sort();
}
function validateSchemaSubset(schema, value, pointer = "$") {
function validateSchemaSubset(schema, value, pointer = "$", rootSchema = schema) {
const errors = [];
if (!schema || typeof schema !== "object") return errors;
if (schema.$ref) {
const resolved = resolveSchemaReference(rootSchema, schema.$ref);
return resolved
? validateSchemaSubset(resolved, value, pointer, rootSchema)
: [`${pointer}:unresolved-ref:${schema.$ref}`];
}
if (schema.const !== undefined && value !== schema.const) {
errors.push(`${pointer}:const:${schema.const}`);
}
if (schema.enum && !schema.enum.includes(value)) {
errors.push(`${pointer}:enum:${schema.enum.join("|")}`);
}
if (schema.type && !matchesSchemaType(value, schema.type)) {
errors.push(`${pointer}:type:${schema.type}`);
@ -1258,6 +1268,9 @@ function validateSchemaSubset(schema, value, pointer = "$") {
if (schema.minLength !== undefined && typeof value === "string" && value.length < schema.minLength) {
errors.push(`${pointer}:minLength:${schema.minLength}`);
}
if (schema.minItems !== undefined && Array.isArray(value) && value.length < schema.minItems) {
errors.push(`${pointer}:minItems:${schema.minItems}`);
}
if (schema.required && value && typeof value === "object" && !Array.isArray(value)) {
for (const key of schema.required) {
@ -1268,20 +1281,28 @@ function validateSchemaSubset(schema, value, pointer = "$") {
if (schema.properties && value && typeof value === "object" && !Array.isArray(value)) {
for (const [key, childSchema] of Object.entries(schema.properties)) {
if (value[key] !== undefined) {
errors.push(...validateSchemaSubset(childSchema, value[key], `${pointer}.${key}`));
errors.push(...validateSchemaSubset(childSchema, value[key], `${pointer}.${key}`, rootSchema));
}
}
}
if (schema.items && Array.isArray(value)) {
value.forEach((item, index) => {
errors.push(...validateSchemaSubset(schema.items, item, `${pointer}[${index}]`));
errors.push(...validateSchemaSubset(schema.items, item, `${pointer}[${index}]`, rootSchema));
});
}
return errors;
}
function resolveSchemaReference(rootSchema, reference) {
if (!reference.startsWith("#/")) return null;
return reference.slice(2).split("/").reduce((current, segment) => {
if (!current || typeof current !== "object") return null;
return current[segment.replaceAll("~1", "/").replaceAll("~0", "~")];
}, rootSchema);
}
function matchesSchemaType(value, type) {
switch (type) {
case "array":