From 4bee5c850465e80a12f04582079fd6f5a8ab9371 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 9 Jul 2026 16:29:51 +0100 Subject: [PATCH] Require plugin availability in projection schemas --- docs/eve-migration-roadmap.md | 6 + .../incubating/test-support/schema-subset.mjs | 24 +++- ...e.electron_shell_projection.v1.schema.json | 109 +++++++++++++---- schemas/gamecult.eve.tui_grid.v1.schema.json | 108 +++++++++-------- ....eve.unity_scene_projection.v1.schema.json | 111 ++++++++++++++---- ....unity_uitoolkit_projection.v1.schema.json | 111 ++++++++++++++---- .../eveunity-scene-capture-artifact.test.mjs | 4 + ...eunity-uitoolkit-capture-artifact.test.mjs | 4 + tools/parity/run-parity.mjs | 27 ++++- 9 files changed, 381 insertions(+), 123 deletions(-) diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index adad79d..3b98c8a 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -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 diff --git a/runtimes/incubating/test-support/schema-subset.mjs b/runtimes/incubating/test-support/schema-subset.mjs index e2484f8..5437df8 100644 --- a/runtimes/incubating/test-support/schema-subset.mjs +++ b/runtimes/incubating/test-support/schema-subset.mjs @@ -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": diff --git a/schemas/gamecult.eve.electron_shell_projection.v1.schema.json b/schemas/gamecult.eve.electron_shell_projection.v1.schema.json index d6d066a..e56c032 100644 --- a/schemas/gamecult.eve.electron_shell_projection.v1.schema.json +++ b/schemas/gamecult.eve.electron_shell_projection.v1.schema.json @@ -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 diff --git a/schemas/gamecult.eve.tui_grid.v1.schema.json b/schemas/gamecult.eve.tui_grid.v1.schema.json index cc0bdb4..1d2e0c8 100644 --- a/schemas/gamecult.eve.tui_grid.v1.schema.json +++ b/schemas/gamecult.eve.tui_grid.v1.schema.json @@ -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 } diff --git a/schemas/gamecult.eve.unity_scene_projection.v1.schema.json b/schemas/gamecult.eve.unity_scene_projection.v1.schema.json index 4556fba..bfac367 100644 --- a/schemas/gamecult.eve.unity_scene_projection.v1.schema.json +++ b/schemas/gamecult.eve.unity_scene_projection.v1.schema.json @@ -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 diff --git a/schemas/gamecult.eve.unity_uitoolkit_projection.v1.schema.json b/schemas/gamecult.eve.unity_uitoolkit_projection.v1.schema.json index 397c46a..e1950c2 100644 --- a/schemas/gamecult.eve.unity_uitoolkit_projection.v1.schema.json +++ b/schemas/gamecult.eve.unity_uitoolkit_projection.v1.schema.json @@ -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 diff --git a/tools/eveunity/eveunity-scene-capture-artifact.test.mjs b/tools/eveunity/eveunity-scene-capture-artifact.test.mjs index f561a1e..449190e 100644 --- a/tools/eveunity/eveunity-scene-capture-artifact.test.mjs +++ b/tools/eveunity/eveunity-scene-capture-artifact.test.mjs @@ -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"); diff --git a/tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs b/tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs index c59bbb0..090e6d7 100644 --- a/tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs +++ b/tools/eveunity/eveunity-uitoolkit-capture-artifact.test.mjs @@ -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"); diff --git a/tools/parity/run-parity.mjs b/tools/parity/run-parity.mjs index b14ed7d..47d10c8 100644 --- a/tools/parity/run-parity.mjs +++ b/tools/parity/run-parity.mjs @@ -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":