From db0ef80bb72e8f13e2a8e89b6fe3565d8a144ac7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 8 Jul 2026 23:41:17 +0100 Subject: [PATCH] Validate Eve plugin advertisement contracts --- docs/eve-migration-roadmap.md | 17 +- .../norn-graph.plugin-advertisement.json | 22 +++ .../sai-vn.plugin-advertisement.json | 25 +++ .../tex-math.plugin-advertisement.json | 19 ++ schemas/gamecult.eve.plugin.v1.schema.json | 177 ++++++++++++++++++ ...lt.eve.plugin_advertisement.v1.schema.json | 67 +++++++ tools/parity/parity-manifest.json | 31 ++- tools/parity/run-parity.mjs | 109 ++++++++++- 8 files changed, 453 insertions(+), 14 deletions(-) create mode 100644 plugins/incubating/norn-graph.plugin-advertisement.json create mode 100644 plugins/incubating/sai-vn.plugin-advertisement.json create mode 100644 plugins/incubating/tex-math.plugin-advertisement.json create mode 100644 schemas/gamecult.eve.plugin.v1.schema.json create mode 100644 schemas/gamecult.eve.plugin_advertisement.v1.schema.json diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index 5dc1b8c..1a44bad 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -182,7 +182,9 @@ Work: `plugins/incubating/norn-graph.plugin.json`, and `plugins/incubating/tex-math.plugin.json` as temporary manifests. - Define `gamecult.eve.plugin.v1` and - `gamecult.eve.plugin_advertisement.v1` as explicit schemas. + `gamecult.eve.plugin_advertisement.v1` as explicit schemas. First-cut JSON + schemas live under `schemas/` and the parity harness validates incubating + plugin manifests and advertisements against them. - Add plugin ABI fixtures for `describe`, `validate`, `project`, `apply`, `lower`, and `measure` where relevant. - Move `sai.vn` to Sai when VN/Ink story state, visual manifest, story commands, @@ -271,15 +273,14 @@ Exit criteria: ## Active Work Queue -1. Add JSON schemas for plugin manifest and plugin advertisement. -2. Add provider advertisement checks to the parity harness. -3. Add command descriptor checks to fixtures and runtime source-symbol +1. Add provider advertisement checks to the parity harness. +2. Add command descriptor checks to fixtures and runtime source-symbol expectations. -4. Add authority/staleness witness fixture to browser reference. -5. Add conformance-pack metadata files beside fixtures. -6. Add a Direct2D adapter spike or explicitly demote Direct2D until Fensalir owns +3. Add authority/staleness witness fixture to browser reference. +4. Add conformance-pack metadata files beside fixtures. +5. Add a Direct2D adapter spike or explicitly demote Direct2D until Fensalir owns the body. -7. Add split-readiness reports for EveFlutter and EveUnity. +6. Add split-readiness reports for EveFlutter and EveUnity. ## Stop Conditions diff --git a/plugins/incubating/norn-graph.plugin-advertisement.json b/plugins/incubating/norn-graph.plugin-advertisement.json new file mode 100644 index 0000000..029fa1f --- /dev/null +++ b/plugins/incubating/norn-graph.plugin-advertisement.json @@ -0,0 +1,22 @@ +{ + "schema": "gamecult.eve.plugin_advertisement.v1", + "pluginId": "norn.graph", + "ownerService": "norn", + "version": "0.1.0-incubating", + "manifestAddress": "file://plugins/incubating/norn-graph.plugin.json", + "schemas": [ + "norn.graph.document.v1", + "norn.graph.layout_request.v1", + "norn.graph.layout_result.v1" + ], + "componentKinds": [ + "embed.norn" + ], + "commands": [ + "graph.node.activate", + "graph.focus" + ], + "fixtures": [ + "sai-vn" + ] +} diff --git a/plugins/incubating/sai-vn.plugin-advertisement.json b/plugins/incubating/sai-vn.plugin-advertisement.json new file mode 100644 index 0000000..95e35df --- /dev/null +++ b/plugins/incubating/sai-vn.plugin-advertisement.json @@ -0,0 +1,25 @@ +{ + "schema": "gamecult.eve.plugin_advertisement.v1", + "pluginId": "sai.vn", + "ownerService": "sai", + "version": "0.1.0-incubating", + "manifestAddress": "file://plugins/incubating/sai-vn.plugin.json", + "schemas": [ + "sai.vn.story_session.v1", + "sai.vn.visual_manifest.v1" + ], + "componentKinds": [ + "vn.stage", + "panel.dialogue", + "text.dialogue", + "rail.actions" + ], + "commands": [ + "story.continue", + "story.choose", + "story.jump" + ], + "fixtures": [ + "sai-vn" + ] +} diff --git a/plugins/incubating/tex-math.plugin-advertisement.json b/plugins/incubating/tex-math.plugin-advertisement.json new file mode 100644 index 0000000..0c8ca4e --- /dev/null +++ b/plugins/incubating/tex-math.plugin-advertisement.json @@ -0,0 +1,19 @@ +{ + "schema": "gamecult.eve.plugin_advertisement.v1", + "pluginId": "tex.math", + "ownerService": "eveplugins", + "version": "0.1.0-incubating", + "manifestAddress": "file://plugins/incubating/tex-math.plugin.json", + "schemas": [ + "tex.math.source.v1", + "tex.math.render_request.v1", + "tex.math.render_result.v1" + ], + "componentKinds": [ + "embed.tex" + ], + "commands": [], + "fixtures": [ + "sai-vn" + ] +} diff --git a/schemas/gamecult.eve.plugin.v1.schema.json b/schemas/gamecult.eve.plugin.v1.schema.json new file mode 100644 index 0000000..645f679 --- /dev/null +++ b/schemas/gamecult.eve.plugin.v1.schema.json @@ -0,0 +1,177 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gamecult.eve.plugin.v1", + "title": "Eve Plugin Manifest", + "type": "object", + "required": [ + "schema", + "pluginId", + "title", + "owner", + "incubation", + "componentKinds", + "commands", + "stateSchemas", + "fixtures", + "capabilities" + ], + "properties": { + "schema": { + "const": "gamecult.eve.plugin.v1" + }, + "pluginId": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string", + "minLength": 1 + }, + "owner": { + "type": "string", + "minLength": 1 + }, + "incubation": { + "type": "object", + "required": [ + "ownerRepo", + "currentHostRepo", + "splitTarget", + "graduationTrigger" + ], + "properties": { + "ownerRepo": { + "type": "string", + "minLength": 1 + }, + "currentHostRepo": { + "type": "string", + "minLength": 1 + }, + "splitTarget": { + "type": "string", + "minLength": 1 + }, + "graduationTrigger": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + }, + "componentKinds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "commands": { + "type": "array", + "items": { + "type": "object", + "required": [ + "command", + "schema", + "effect" + ], + "properties": { + "command": { + "type": "string", + "minLength": 1 + }, + "schema": { + "type": "string", + "minLength": 1 + }, + "effect": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + } + }, + "stateSchemas": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "fixtures": { + "type": "array", + "items": { + "type": "object", + "required": [ + "fixtureId", + "schema", + "path" + ], + "properties": { + "fixtureId": { + "type": "string", + "minLength": 1 + }, + "schema": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + } + }, + "capabilities": { + "type": "object", + "required": [ + "requires" + ], + "properties": { + "requires": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "optionalCapabilities": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "optionalPlugins": { + "type": "array", + "items": { + "type": "object", + "required": [ + "pluginId", + "capabilities" + ], + "properties": { + "pluginId": { + "type": "string", + "minLength": 1 + }, + "capabilities": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": true + } + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/schemas/gamecult.eve.plugin_advertisement.v1.schema.json b/schemas/gamecult.eve.plugin_advertisement.v1.schema.json new file mode 100644 index 0000000..3a054d5 --- /dev/null +++ b/schemas/gamecult.eve.plugin_advertisement.v1.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "gamecult.eve.plugin_advertisement.v1", + "title": "Eve Plugin Advertisement", + "type": "object", + "required": [ + "schema", + "pluginId", + "ownerService", + "version", + "manifestAddress", + "schemas", + "componentKinds", + "commands", + "fixtures" + ], + "properties": { + "schema": { + "const": "gamecult.eve.plugin_advertisement.v1" + }, + "pluginId": { + "type": "string", + "minLength": 1 + }, + "ownerService": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "manifestAddress": { + "type": "string", + "minLength": 1 + }, + "schemas": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "componentKinds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "commands": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "fixtures": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": true +} diff --git a/tools/parity/parity-manifest.json b/tools/parity/parity-manifest.json index 70c9532..b053af5 100644 --- a/tools/parity/parity-manifest.json +++ b/tools/parity/parity-manifest.json @@ -5,6 +5,10 @@ "incubationPolicy": "Eve may host runtimes, plugins, and conformance packs only while their contract is still forming.", "graduationDocument": "docs/repo-strategy.md" }, + "schemas": { + "gamecult.eve.plugin.v1": "schemas/gamecult.eve.plugin.v1.schema.json", + "gamecult.eve.plugin_advertisement.v1": "schemas/gamecult.eve.plugin_advertisement.v1.schema.json" + }, "conformancePacks": [ { "id": "core", @@ -223,7 +227,14 @@ "capabilities": ["vn.stage", "story.choose", "story.continue", "story.jump"], "optionalPlugins": ["norn.graph", "tex.math"], "requiredFixtures": ["sai-vn"], - "expectedPaths": ["plugins/incubating/sai-vn.plugin.json"] + "schemaPath": "schemas/gamecult.eve.plugin.v1.schema.json", + "manifestPath": "plugins/incubating/sai-vn.plugin.json", + "advertisementSchemaPath": "schemas/gamecult.eve.plugin_advertisement.v1.schema.json", + "advertisementPath": "plugins/incubating/sai-vn.plugin-advertisement.json", + "expectedPaths": [ + "plugins/incubating/sai-vn.plugin.json", + "plugins/incubating/sai-vn.plugin-advertisement.json" + ] }, { "pluginId": "norn.graph", @@ -235,7 +246,14 @@ "graduationTrigger": "Split when embed.norn graph layout, interaction, fallback, and conformance fixtures are stable.", "capabilities": ["embed.norn", "graph.node.activate"], "requiredFixtures": ["sai-vn"], - "expectedPaths": ["plugins/incubating/norn-graph.plugin.json"] + "schemaPath": "schemas/gamecult.eve.plugin.v1.schema.json", + "manifestPath": "plugins/incubating/norn-graph.plugin.json", + "advertisementSchemaPath": "schemas/gamecult.eve.plugin_advertisement.v1.schema.json", + "advertisementPath": "plugins/incubating/norn-graph.plugin-advertisement.json", + "expectedPaths": [ + "plugins/incubating/norn-graph.plugin.json", + "plugins/incubating/norn-graph.plugin-advertisement.json" + ] }, { "pluginId": "tex.math", @@ -247,7 +265,14 @@ "graduationTrigger": "Split when TeX source, macro, baseline, cached render, and fallback fixtures are stable.", "capabilities": ["embed.tex", "tex.inline", "tex.block"], "requiredFixtures": ["sai-vn"], - "expectedPaths": ["plugins/incubating/tex-math.plugin.json"] + "schemaPath": "schemas/gamecult.eve.plugin.v1.schema.json", + "manifestPath": "plugins/incubating/tex-math.plugin.json", + "advertisementSchemaPath": "schemas/gamecult.eve.plugin_advertisement.v1.schema.json", + "advertisementPath": "plugins/incubating/tex-math.plugin-advertisement.json", + "expectedPaths": [ + "plugins/incubating/tex-math.plugin.json", + "plugins/incubating/tex-math.plugin-advertisement.json" + ] } ], "runtimes": [ diff --git a/tools/parity/run-parity.mjs b/tools/parity/run-parity.mjs index 97cdd57..b253dbb 100644 --- a/tools/parity/run-parity.mjs +++ b/tools/parity/run-parity.mjs @@ -230,15 +230,27 @@ async function evaluatePlugin(plugin, fixtureResults) { const requiredFixtures = plugin.requiredFixtures || []; const missingRequiredFixtures = requiredFixtures.filter(id => !fixtureResults.some(fixture => fixture.id === id && fixture.status === "pass")); const missingIncubationFields = requiredIncubationFields(plugin).filter(field => !plugin[field]); + const schemaErrors = await validateJsonDocument(plugin.schemaPath, plugin.manifestPath, { + schema: "gamecult.eve.plugin.v1", + pluginId: plugin.pluginId, + }); + const advertisementErrors = await validateJsonDocument(plugin.advertisementSchemaPath, plugin.advertisementPath, { + schema: "gamecult.eve.plugin_advertisement.v1", + pluginId: plugin.pluginId, + }); const status = missingPaths.length ? "missing-body" : missingRequiredFixtures.length ? "missing-required-fixture" : missingIncubationFields.length ? "missing-incubation-metadata" - : plugin.kind === "incubating" - ? "incubating" - : "external-owner-planned"; + : schemaErrors.length + ? "invalid-plugin-manifest" + : advertisementErrors.length + ? "invalid-plugin-advertisement" + : plugin.kind === "incubating" + ? "incubating" + : "external-owner-planned"; return { pluginId: plugin.pluginId, @@ -252,6 +264,12 @@ async function evaluatePlugin(plugin, fixtureResults) { optionalPlugins: plugin.optionalPlugins || [], requiredFixtures, missingRequiredFixtures, + schemaPath: plugin.schemaPath || "", + manifestPath: plugin.manifestPath || "", + advertisementSchemaPath: plugin.advertisementSchemaPath || "", + advertisementPath: plugin.advertisementPath || "", + schemaErrors, + advertisementErrors, expectedPaths, missingPaths, missingIncubationFields, @@ -259,6 +277,89 @@ async function evaluatePlugin(plugin, fixtureResults) { }; } +async function validateJsonDocument(schemaPath, documentPath, expected = {}) { + const errors = []; + if (!schemaPath) errors.push("schemaPath:missing"); + if (!documentPath) errors.push("documentPath:missing"); + if (!schemaPath || !documentPath) return errors; + + const absoluteSchemaPath = path.join(repoRoot, schemaPath); + const absoluteDocumentPath = path.join(repoRoot, documentPath); + if (!existsSync(absoluteSchemaPath)) errors.push(`${schemaPath}:missing`); + if (!existsSync(absoluteDocumentPath)) errors.push(`${documentPath}:missing`); + if (errors.length) return errors; + + try { + const schema = JSON.parse(await readFile(absoluteSchemaPath, "utf8")); + const document = JSON.parse(await readFile(absoluteDocumentPath, "utf8")); + errors.push(...validateSchemaSubset(schema, document)); + for (const [key, value] of Object.entries(expected)) { + if (document[key] !== value) errors.push(`${documentPath}:${key}:expected ${value} got ${document[key]}`); + } + } catch (error) { + errors.push(`${documentPath}:invalid-json:${error instanceof Error ? error.message : String(error)}`); + } + + return errors; +} + +function validateSchemaSubset(schema, value, pointer = "$") { + const errors = []; + if (!schema || typeof schema !== "object") return errors; + + if (schema.const !== undefined && value !== schema.const) { + errors.push(`${pointer}:const:${schema.const}`); + } + + if (schema.type && !matchesSchemaType(value, schema.type)) { + errors.push(`${pointer}:type:${schema.type}`); + return errors; + } + + if (schema.minLength !== undefined && typeof value === "string" && value.length < schema.minLength) { + errors.push(`${pointer}:minLength:${schema.minLength}`); + } + + if (schema.required && value && typeof value === "object" && !Array.isArray(value)) { + for (const key of schema.required) { + if (value[key] === undefined) errors.push(`${pointer}.${key}:required`); + } + } + + 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}`)); + } + } + } + + if (schema.items && Array.isArray(value)) { + value.forEach((item, index) => { + errors.push(...validateSchemaSubset(schema.items, item, `${pointer}[${index}]`)); + }); + } + + return errors; +} + +function matchesSchemaType(value, type) { + switch (type) { + case "array": + return Array.isArray(value); + case "object": + return value !== null && typeof value === "object" && !Array.isArray(value); + case "string": + return typeof value === "string"; + case "number": + return typeof value === "number"; + case "boolean": + return typeof value === "boolean"; + default: + return true; + } +} + function collectPluginCapabilityGaps(runtime, fixtureResults) { const supported = new Map((runtime.supportedPlugins || []).map(plugin => [plugin.pluginId, new Set(plugin.capabilities || [])])); const fixtureById = new Map((manifest.fixtures || []).map(fixture => [fixture.id, fixture])); @@ -389,6 +490,8 @@ function renderMarkdown(report) { ...plugin.missingPaths, ...plugin.missingRequiredFixtures.map(id => `fixture:${id}`), ...plugin.missingIncubationFields.map(id => `metadata:${id}`), + ...plugin.schemaErrors.map(id => `schema:${id}`), + ...plugin.advertisementErrors.map(id => `advertisement:${id}`), ].join(", "); lines.push(`| ${plugin.title || plugin.pluginId} | ${plugin.status} | ${plugin.ownerRepo} | ${plugin.capabilities.join(", ")} | ${missing} |`); }