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 required for the VN surface, while `norn.graph` and `tex.math` are
`optional-nested` independent sidecar plugin projections. This keeps `optional-nested` independent sidecar plugin projections. This keeps
runtime projection support from becoming an implied Sai dependency story. 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, - Sai, Norn, and TeX now have plugin ABI fixtures for describe, validate,
project, lower, measure, and apply behavior. The parity harness validates project, lower, measure, and apply behavior. The parity harness validates
those fixtures against plugin manifests, so Eve proves the ABI shape without 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 = []; const errors = [];
if (!schema || typeof schema !== "object") return 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) { if (schema.const !== undefined && value !== schema.const) {
errors.push(`${pointer}:const:${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)) { if (schema.type && !matchesSchemaType(value, schema.type)) {
errors.push(`${pointer}:type:${schema.type}`); errors.push(`${pointer}:type:${schema.type}`);
return errors; return errors;
@ -23,18 +33,26 @@ export function validateSchemaSubset(schema, value, pointer = "$") {
if (schema.properties && value && typeof value === "object" && !Array.isArray(value)) { if (schema.properties && value && typeof value === "object" && !Array.isArray(value)) {
for (const [key, childSchema] of Object.entries(schema.properties)) { for (const [key, childSchema] of Object.entries(schema.properties)) {
if (value[key] !== undefined) { 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)) { if (schema.items && Array.isArray(value)) {
value.forEach((item, index) => { value.forEach((item, index) => {
errors.push(...validateSchemaSubset(schema.items, item, `${pointer}[${index}]`)); errors.push(...validateSchemaSubset(schema.items, item, `${pointer}[${index}]`, rootSchema));
}); });
} }
return errors; 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) { function matchesSchemaType(value, type) {
switch (type) { switch (type) {
case "array": case "array":

View file

@ -46,6 +46,84 @@
"minLength": 1 "minLength": 1
}, },
"root": { "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", "type": "object",
"required": [ "required": [
"id", "id",
@ -85,32 +163,17 @@
"embeddedDocuments": { "embeddedDocuments": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "$ref": "#/$defs/embeddedDocument"
"required": [
"slotId",
"documentId"
],
"properties": {
"slotId": {
"type": "string",
"minLength": 1
},
"documentId": {
"type": "string",
"minLength": 1
},
"schemaId": {
"type": "string"
},
"presentationKind": {
"type": "string"
}
},
"additionalProperties": true
} }
}, },
"pluginProjection": {
"$ref": "#/$defs/pluginProjection"
},
"children": { "children": {
"type": "array" "type": "array",
"items": {
"$ref": "#/$defs/shellNode"
}
} }
}, },
"additionalProperties": true "additionalProperties": true

View file

@ -84,52 +84,7 @@
"pluginProjections": { "pluginProjections": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "$ref": "#/$defs/pluginProjection"
"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
} }
}, },
"lossiness": { "lossiness": {
@ -137,5 +92,66 @@
"minLength": 1 "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 "additionalProperties": true
} }

View file

@ -50,6 +50,87 @@
"minLength": 1 "minLength": 1
}, },
"root": { "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", "type": "object",
"required": [ "required": [
"id", "id",
@ -89,35 +170,17 @@
"embeddedDocuments": { "embeddedDocuments": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "$ref": "#/$defs/embeddedDocument"
"required": [
"slotId",
"documentId"
],
"properties": {
"slotId": {
"type": "string",
"minLength": 1
},
"documentId": {
"type": "string",
"minLength": 1
},
"schemaId": {
"type": "string"
},
"presentationKind": {
"type": "string"
}
},
"additionalProperties": true
} }
}, },
"pluginProjection": { "pluginProjection": {
"type": "object" "$ref": "#/$defs/pluginProjection"
}, },
"children": { "children": {
"type": "array" "type": "array",
"items": {
"$ref": "#/$defs/sceneNode"
}
} }
}, },
"additionalProperties": true "additionalProperties": true

View file

@ -50,6 +50,87 @@
"minLength": 1 "minLength": 1
}, },
"root": { "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", "type": "object",
"required": [ "required": [
"id", "id",
@ -96,35 +177,17 @@
"embeddedDocuments": { "embeddedDocuments": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "$ref": "#/$defs/embeddedDocument"
"required": [
"slotId",
"documentId"
],
"properties": {
"slotId": {
"type": "string",
"minLength": 1
},
"documentId": {
"type": "string",
"minLength": 1
},
"schemaId": {
"type": "string"
},
"presentationKind": {
"type": "string"
}
},
"additionalProperties": true
} }
}, },
"pluginProjection": { "pluginProjection": {
"type": "object" "$ref": "#/$defs/pluginProjection"
}, },
"children": { "children": {
"type": "array" "type": "array",
"items": {
"$ref": "#/$defs/visualElementNode"
}
} }
}, },
"additionalProperties": true "additionalProperties": true

View file

@ -1,8 +1,10 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import test from "node:test"; import test from "node:test";
import { validateSchemaSubset } from "../../runtimes/incubating/test-support/schema-subset.mjs";
import { buildUnitySceneCaptureArtifact, buildUnitySceneProjection } from "./eveunity-scene-capture-artifact.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 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 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")); 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.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.schema, "gamecult.eve.unity_scene_projection.v1");
assert.equal(projection.runtimeId, "unity-scene"); assert.equal(projection.runtimeId, "unity-scene");
assert.equal(projection.providerId, "aetheria"); 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"); 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 graphNode = projection.root.children.find(child => child.id === "sai.graph");
const texLayer = projection.root.children.find(child => child.id === "sai.embeds"); const texLayer = projection.root.children.find(child => child.id === "sai.embeds");
const texNode = texLayer.children.find(child => child.id === "sai.tex.log-power"); 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 assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import test from "node:test"; import test from "node:test";
import { validateSchemaSubset } from "../../runtimes/incubating/test-support/schema-subset.mjs";
import { buildUnityUiToolkitCaptureArtifact, buildUnityUiToolkitProjection } from "./eveunity-uitoolkit-capture-artifact.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 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 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")); 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.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.schema, "gamecult.eve.unity_uitoolkit_projection.v1");
assert.equal(projection.runtimeId, "unity-uitoolkit"); assert.equal(projection.runtimeId, "unity-uitoolkit");
assert.equal(projection.providerId, "aetheria"); 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"); 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 graphNode = projection.root.children.find(child => child.id === "sai.graph");
const texLayer = projection.root.children.find(child => child.id === "sai.embeds"); const texLayer = projection.root.children.find(child => child.id === "sai.embeds");
const texNode = texLayer.children.find(child => child.id === "sai.tex.log-power"); const texNode = texLayer.children.find(child => child.id === "sai.tex.log-power");

View file

@ -1242,13 +1242,23 @@ function extractProviderSchemaIds(schemas) {
}).filter(Boolean).sort(); }).filter(Boolean).sort();
} }
function validateSchemaSubset(schema, value, pointer = "$") { function validateSchemaSubset(schema, value, pointer = "$", rootSchema = schema) {
const errors = []; const errors = [];
if (!schema || typeof schema !== "object") return 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) { if (schema.const !== undefined && value !== schema.const) {
errors.push(`${pointer}:const:${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)) { if (schema.type && !matchesSchemaType(value, schema.type)) {
errors.push(`${pointer}:type:${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) { if (schema.minLength !== undefined && typeof value === "string" && value.length < schema.minLength) {
errors.push(`${pointer}:minLength:${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)) { if (schema.required && value && typeof value === "object" && !Array.isArray(value)) {
for (const key of schema.required) { 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)) { if (schema.properties && value && typeof value === "object" && !Array.isArray(value)) {
for (const [key, childSchema] of Object.entries(schema.properties)) { for (const [key, childSchema] of Object.entries(schema.properties)) {
if (value[key] !== undefined) { 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)) { if (schema.items && Array.isArray(value)) {
value.forEach((item, index) => { value.forEach((item, index) => {
errors.push(...validateSchemaSubset(schema.items, item, `${pointer}[${index}]`)); errors.push(...validateSchemaSubset(schema.items, item, `${pointer}[${index}]`, rootSchema));
}); });
} }
return errors; 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) { function matchesSchemaType(value, type) {
switch (type) { switch (type) {
case "array": case "array":