Validate Eve plugin advertisement contracts

This commit is contained in:
Meta Vens 2026-07-08 23:41:17 +01:00
commit db0ef80bb7
8 changed files with 453 additions and 14 deletions

View file

@ -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

View file

@ -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"
]
}

View file

@ -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"
]
}

View file

@ -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"
]
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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": [

View file

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