Enforce optional nested plugin requirements

This commit is contained in:
Meta Vens 2026-07-09 17:42:50 +01:00
commit b03ddc1083
5 changed files with 73 additions and 0 deletions

View file

@ -1099,6 +1099,12 @@ Recently cut:
`story.*`/`style.patch`; Aetheria proves the daemon command boundary; the web `story.*`/`style.patch`; Aetheria proves the daemon command boundary; the web
lowerer resolves authored `action.command` through the shared Eve command lowerer resolves authored `action.command` through the shared Eve command
intent path. intent path.
- Provider plugin requirements now distinguish required parent semantics from
optional nested sidecar affordances at the contract layer. A Sai surface may
require `sai.vn`, but nested `norn.graph` and `tex.math` entries must keep
`requiredCapabilities` empty and advertise their nested affordances through
`optionalCapabilities`, preserving Norn and TeX as independent sidecar
plugins.
- Provider advertisement validation now runs in the parity harness. - Provider advertisement validation now runs in the parity harness.
- Repixelizer and Aetheria provider advertisements are checked against - Repixelizer and Aetheria provider advertisements are checked against
`gamecult.eve.provider_advertisement.v1`. `gamecult.eve.provider_advertisement.v1`.

View file

@ -90,6 +90,9 @@ Required top-level meanings:
interaction. Optional nested entries, such as a VN scene reserving space for interaction. Optional nested entries, such as a VN scene reserving space for
independently-owned Norn or TeX surfaces, degrade when unavailable; they do independently-owned Norn or TeX surfaces, degrade when unavailable; they do
not make Sai or any other parent plugin own the nested plugin's semantics. not make Sai or any other parent plugin own the nested plugin's semantics.
Required entries put their gated capabilities in `requiredCapabilities`.
Optional nested entries leave `requiredCapabilities` empty and put their
nested affordance claims in `optionalCapabilities`.
Runtimes compare these entries with local projection support and report Runtimes compare these entries with local projection support and report
visible capability gaps; they must not import provider or plugin internals to visible capability gaps; they must not import provider or plugin internals to
recover missing semantics. recover missing semantics.

View file

@ -220,6 +220,45 @@
} }
} }
}, },
"allOf": [
{
"if": {
"properties": {
"availability": {
"const": "required"
}
}
},
"then": {
"properties": {
"requiredCapabilities": {
"minItems": 1
}
}
}
},
{
"if": {
"required": ["availability"],
"properties": {
"availability": {
"const": "optional-nested"
}
}
},
"then": {
"required": ["optionalCapabilities"],
"properties": {
"requiredCapabilities": {
"maxItems": 0
},
"optionalCapabilities": {
"minItems": 1
}
}
}
}
],
"additionalProperties": true "additionalProperties": true
} }
} }

View file

@ -971,6 +971,7 @@ function validateProviderPluginRequirements(provider, requirements, pluginResult
const errors = []; const errors = [];
const pluginById = new Map((pluginResults || []).map(plugin => [plugin.pluginId, plugin])); const pluginById = new Map((pluginResults || []).map(plugin => [plugin.pluginId, plugin]));
for (const requirement of requirements) { for (const requirement of requirements) {
errors.push(...validateProviderPluginRequirementShape(provider, requirement));
const plugin = pluginById.get(requirement.pluginId); const plugin = pluginById.get(requirement.pluginId);
const optional = requirement.availability && requirement.availability !== "required"; const optional = requirement.availability && requirement.availability !== "required";
if (!plugin) { if (!plugin) {
@ -991,6 +992,28 @@ function validateProviderPluginRequirements(provider, requirements, pluginResult
return errors; return errors;
} }
function validateProviderPluginRequirementShape(provider, requirement) {
const label = `${provider.advertisementPath}:${requirement.surfaceId}:plugin:${requirement.pluginId || "unknown"}`;
const errors = [];
const availability = requirement.availability || "required";
if (!requirement.pluginId) errors.push(`${label}:pluginId:missing`);
if (availability === "required" && !requirement.requiredCapabilities.length) {
errors.push(`${label}:requiredCapabilities:missing`);
}
if (availability === "optional-nested") {
if (requirement.requiredCapabilities.length) {
errors.push(`${label}:optional-nested:requiredCapabilities:must-be-empty`);
}
if (!requirement.optionalCapabilities.length) {
errors.push(`${label}:optional-nested:optionalCapabilities:missing`);
}
}
return errors;
}
async function readJsonDocument(documentPath) { async function readJsonDocument(documentPath) {
return JSON.parse(await readFile(path.join(repoRoot, documentPath), "utf8")); return JSON.parse(await readFile(path.join(repoRoot, documentPath), "utf8"));
} }

View file

@ -52,7 +52,9 @@ test("preserves Sai required and optional nested plugin requirements from advert
"story.jump", "story.jump",
]); ]);
assert.equal(byPlugin.get("norn.graph").availability, "optional-nested"); assert.equal(byPlugin.get("norn.graph").availability, "optional-nested");
assert.deepEqual(byPlugin.get("norn.graph").requiredCapabilities, []);
assert.deepEqual(byPlugin.get("norn.graph").optionalCapabilities, ["embed.norn"]); assert.deepEqual(byPlugin.get("norn.graph").optionalCapabilities, ["embed.norn"]);
assert.equal(byPlugin.get("tex.math").availability, "optional-nested"); assert.equal(byPlugin.get("tex.math").availability, "optional-nested");
assert.deepEqual(byPlugin.get("tex.math").requiredCapabilities, []);
assert.deepEqual(byPlugin.get("tex.math").optionalCapabilities, ["embed.tex"]); assert.deepEqual(byPlugin.get("tex.math").optionalCapabilities, ["embed.tex"]);
}); });