Validate provider plugin requirements
This commit is contained in:
parent
85d270e0ab
commit
5839ae2acb
7 changed files with 222 additions and 5 deletions
|
|
@ -280,6 +280,13 @@ Exit criteria:
|
|||
|
||||
Recently cut:
|
||||
|
||||
- Provider advertisements can now name sidecar plugin requirements per surface
|
||||
with `surfaces[].requiresPlugins[]`. The parity harness validates those
|
||||
requirements against known plugin manifests, exports them in the conformance
|
||||
provider list, and reports missing plugins or capabilities as provider
|
||||
capability gaps. The Sai VN fixture now has an advertisement-shaped provider
|
||||
entry for `gamecult.home.vn`, so Sai/Norn/TeX requirements sit at the
|
||||
provider/plugin boundary instead of hiding only in fixture metadata.
|
||||
- The conformance runtime pack now exports `runtimeTargets` in
|
||||
`artifacts/conformance/latest/packs/runtime.json`. Runtime repos can consume
|
||||
runtime status, feature/plugin claims, command schema, lifecycle, capture
|
||||
|
|
|
|||
|
|
@ -307,6 +307,12 @@ Recommended discovery path:
|
|||
6. Unsupported required plugin capability becomes a visible capability gap.
|
||||
7. Optional unsupported capability becomes a visible degraded lowering.
|
||||
|
||||
Eve's parity harness validates this boundary directly: provider-advertised
|
||||
`surfaces[].requiresPlugins[]` entries must name known plugin manifests, and
|
||||
each `requiredCapabilities` item must be claimed by that plugin manifest. This
|
||||
keeps plugin requirements in the provider advertisement instead of hiding them
|
||||
in runtime lowerers or fixture-only metadata.
|
||||
|
||||
Provider advertisement sketch:
|
||||
|
||||
```json
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ Required top-level meanings:
|
|||
- `witnesses`: CultCache `.cc` files, database exports, or document keys that
|
||||
witness durable state.
|
||||
- `surfaces`: Eve surface documents or endpoints the provider publishes.
|
||||
- `surfaces[].requiresPlugins`: sidecar plugin capabilities required to render
|
||||
or interact with that surface. Runtimes compare these requirements with their
|
||||
local projection support and report visible capability gaps; they must not
|
||||
import provider or plugin internals to recover missing semantics.
|
||||
- `commands`: command boundaries the provider accepts.
|
||||
- `routes`: transport, store, or compatibility routes used to reach the
|
||||
semantic address. WebSocket and HTTP routes live here when they still exist.
|
||||
|
|
|
|||
|
|
@ -135,6 +135,37 @@
|
|||
"status": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"requiresPlugins": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["pluginId", "requiredCapabilities"],
|
||||
"properties": {
|
||||
"pluginId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"versionRange": {
|
||||
"type": "string"
|
||||
},
|
||||
"requiredCapabilities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"optionalCapabilities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
|
|
|
|||
|
|
@ -296,6 +296,20 @@
|
|||
"expectedSurfaces": ["repixelizer.operator.surface"],
|
||||
"expectedCommands": ["repixelizer.job.submit", "repixelizer.job.cancel_own"]
|
||||
},
|
||||
{
|
||||
"providerId": "gamecult.home.vn",
|
||||
"title": "GameCult Compound VN",
|
||||
"kind": "plugin.fixture.provider",
|
||||
"ownerRepo": "Sai",
|
||||
"repoRole": "provider-fixture",
|
||||
"graduationTrigger": "Move this advertisement-shaped VN fixture to Sai when sai-vn-plugin owns VN/Ink semantics and conformance packs.",
|
||||
"schemaPath": "schemas/gamecult.eve.provider_advertisement.v1.schema.json",
|
||||
"advertisementPath": "web/fixtures/sai-vn.provider-advertisement.json",
|
||||
"requiredFixtures": ["sai-vn"],
|
||||
"expectedSchemas": ["gamecult.eve.surface.v1", "gamecult.eve.command.v1", "sai.vn.story_session.v1"],
|
||||
"expectedSurfaces": ["sai.visual_novel.surface"],
|
||||
"expectedCommands": ["story.continue", "story.choose", "story.jump", "style.patch"]
|
||||
},
|
||||
{
|
||||
"providerId": "aetheria",
|
||||
"title": "Aetheria",
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ for (const fixture of manifest.fixtures) {
|
|||
}
|
||||
|
||||
const pluginResults = await Promise.all((manifest.pluginManifests || []).map(plugin => evaluatePlugin(plugin, fixtureResults)));
|
||||
const providerResults = await Promise.all((manifest.providerAdvertisements || []).map(provider => evaluateProvider(provider, fixtureResults)));
|
||||
const providerResults = await Promise.all((manifest.providerAdvertisements || []).map(provider => evaluateProvider(provider, fixtureResults, pluginResults)));
|
||||
const runtimeResults = await Promise.all(manifest.runtimes.map(runtime => evaluateRuntime(runtime, fixtureResults)));
|
||||
const splitTargetResults = evaluateSplitTargets(manifest.splitTargets || [], runtimeResults);
|
||||
const report = {
|
||||
|
|
@ -462,7 +462,7 @@ async function evaluateFixtureMetadata(fixture) {
|
|||
return { metadata, errors };
|
||||
}
|
||||
|
||||
async function evaluateProvider(provider, fixtureResults) {
|
||||
async function evaluateProvider(provider, fixtureResults, pluginResults) {
|
||||
const expectedPaths = provider.expectedPaths || [];
|
||||
const missingPaths = expectedPaths.filter(candidate => !existsSync(path.join(repoRoot, candidate)));
|
||||
const requiredFixtures = provider.requiredFixtures || [];
|
||||
|
|
@ -482,6 +482,8 @@ async function evaluateProvider(provider, fixtureResults) {
|
|||
const surfaceIds = advertisement ? (advertisement.surfaces || []).map(surface => surface.surfaceId).filter(Boolean).sort() : [];
|
||||
const commandIds = advertisement ? (advertisement.commands || []).map(command => command.command).filter(Boolean).sort() : [];
|
||||
const witnessKinds = advertisement ? (advertisement.witnesses || []).map(witness => witness.kind).filter(Boolean).sort() : [];
|
||||
const pluginRequirements = advertisement ? collectProviderPluginRequirements(advertisement) : [];
|
||||
const pluginRequirementErrors = validateProviderPluginRequirements(provider, pluginRequirements, pluginResults);
|
||||
const missingSchemas = (provider.expectedSchemas || []).filter(schema => !advertisedSchemaIds.includes(schema));
|
||||
const missingSurfaces = (provider.expectedSurfaces || []).filter(surface => !surfaceIds.includes(surface));
|
||||
const missingCommands = (provider.expectedCommands || []).filter(command => !commandIds.includes(command));
|
||||
|
|
@ -495,7 +497,7 @@ async function evaluateProvider(provider, fixtureResults) {
|
|||
? "missing-ownership-metadata"
|
||||
: advertisementErrors.length
|
||||
? "invalid-provider-advertisement"
|
||||
: missingSchemas.length || missingSurfaces.length || missingCommands.length
|
||||
: missingSchemas.length || missingSurfaces.length || missingCommands.length || pluginRequirementErrors.length
|
||||
? "capability-gap"
|
||||
: scenarioErrors.length
|
||||
? "invalid-provider-scenario"
|
||||
|
|
@ -526,6 +528,8 @@ async function evaluateProvider(provider, fixtureResults) {
|
|||
surfaceIds,
|
||||
commandIds,
|
||||
witnessKinds,
|
||||
pluginRequirements,
|
||||
pluginRequirementErrors,
|
||||
missingSchemas,
|
||||
missingSurfaces,
|
||||
missingCommands,
|
||||
|
|
@ -535,6 +539,45 @@ async function evaluateProvider(provider, fixtureResults) {
|
|||
};
|
||||
}
|
||||
|
||||
function collectProviderPluginRequirements(advertisement) {
|
||||
const requirements = [];
|
||||
for (const surface of advertisement.surfaces || []) {
|
||||
for (const requirement of surface.requiresPlugins || []) {
|
||||
requirements.push({
|
||||
surfaceId: surface.surfaceId || "",
|
||||
pluginId: requirement.pluginId || "",
|
||||
versionRange: requirement.versionRange || "",
|
||||
requiredCapabilities: requirement.requiredCapabilities || [],
|
||||
optionalCapabilities: requirement.optionalCapabilities || [],
|
||||
});
|
||||
}
|
||||
}
|
||||
return requirements;
|
||||
}
|
||||
|
||||
function validateProviderPluginRequirements(provider, requirements, pluginResults) {
|
||||
const errors = [];
|
||||
const pluginById = new Map((pluginResults || []).map(plugin => [plugin.pluginId, plugin]));
|
||||
for (const requirement of requirements) {
|
||||
const plugin = pluginById.get(requirement.pluginId);
|
||||
if (!plugin) {
|
||||
errors.push(`${provider.advertisementPath}:${requirement.surfaceId}:plugin:${requirement.pluginId}:missing`);
|
||||
continue;
|
||||
}
|
||||
if (plugin.status !== "incubating" && plugin.status !== "external-owner-planned") {
|
||||
errors.push(`${provider.advertisementPath}:${requirement.surfaceId}:plugin:${requirement.pluginId}:status:${plugin.status}`);
|
||||
continue;
|
||||
}
|
||||
const capabilities = new Set(plugin.capabilities || []);
|
||||
for (const capability of requirement.requiredCapabilities || []) {
|
||||
if (!capabilities.has(capability)) {
|
||||
errors.push(`${provider.advertisementPath}:${requirement.surfaceId}:plugin:${requirement.pluginId}:${capability}:missing`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function readJsonDocument(documentPath) {
|
||||
return JSON.parse(await readFile(path.join(repoRoot, documentPath), "utf8"));
|
||||
}
|
||||
|
|
@ -1244,6 +1287,7 @@ function buildConformanceExport(report) {
|
|||
scenarioId: provider.scenarioId,
|
||||
surfaces: provider.surfaceIds,
|
||||
commands: provider.commandIds,
|
||||
pluginRequirements: provider.pluginRequirements,
|
||||
})),
|
||||
runtimes: (report.runtimes || []).map(runtime => ({
|
||||
runtimeId: runtime.id,
|
||||
|
|
@ -1393,7 +1437,7 @@ function renderMarkdown(report) {
|
|||
lines.push(`| ${plugin.title || plugin.pluginId} | ${plugin.status} | ${plugin.ownerRepo} | ${(plugin.abiOperations || []).join(", ")} | ${plugin.abiFixturePath || ""} | ${plugin.capabilities.join(", ")} | ${missing} |`);
|
||||
}
|
||||
|
||||
lines.push("", "## Providers", "", "| Provider | Status | Owner | Scenario | Receipt States | Surfaces | Commands | Witnesses | Missing |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- |");
|
||||
lines.push("", "## Providers", "", "| Provider | Status | Owner | Scenario | Receipt States | Surfaces | Commands | Plugin Requirements | Witnesses | Missing |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |");
|
||||
for (const provider of report.providers || []) {
|
||||
const missing = [
|
||||
...provider.missingPaths,
|
||||
|
|
@ -1404,8 +1448,9 @@ function renderMarkdown(report) {
|
|||
...provider.missingSchemas.map(id => `schema:${id}`),
|
||||
...provider.missingSurfaces.map(id => `surface:${id}`),
|
||||
...provider.missingCommands.map(id => `command:${id}`),
|
||||
...provider.pluginRequirementErrors.map(id => `plugin:${id}`),
|
||||
].join(", ");
|
||||
lines.push(`| ${provider.title || provider.providerId} | ${provider.status} | ${provider.ownerRepo} | ${provider.scenarioId || ""} | ${provider.scenarioReceiptStates.join(", ")} | ${provider.surfaceIds.join(", ")} | ${provider.commandIds.join(", ")} | ${provider.witnessKinds.join(", ")} | ${missing} |`);
|
||||
lines.push(`| ${provider.title || provider.providerId} | ${provider.status} | ${provider.ownerRepo} | ${provider.scenarioId || ""} | ${provider.scenarioReceiptStates.join(", ")} | ${provider.surfaceIds.join(", ")} | ${provider.commandIds.join(", ")} | ${summarizeProviderPluginRequirements(provider.pluginRequirements)} | ${provider.witnessKinds.join(", ")} | ${missing} |`);
|
||||
}
|
||||
|
||||
lines.push("", "## Runtimes", "", "| Runtime | Status | Owner | Lifecycle | Capture | Command Smoke | Required Fixtures | Plugin Fixtures | Features | Plugin Gaps | Unsupported Plugins | Missing |", "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |");
|
||||
|
|
@ -1443,6 +1488,12 @@ function renderMarkdown(report) {
|
|||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function summarizeProviderPluginRequirements(requirements = []) {
|
||||
return requirements
|
||||
.map(requirement => `${requirement.surfaceId}:${requirement.pluginId}(${(requirement.requiredCapabilities || []).join(", ")})`)
|
||||
.join("<br>");
|
||||
}
|
||||
|
||||
function summarizeLifecycle(lifecycle) {
|
||||
if (!lifecycle) return "";
|
||||
return ["release", "test", "capture"]
|
||||
|
|
|
|||
104
web/fixtures/sai-vn.provider-advertisement.json
Normal file
104
web/fixtures/sai-vn.provider-advertisement.json
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
{
|
||||
"schema": "gamecult.eve.provider_advertisement.v1",
|
||||
"providerId": "gamecult.home.vn",
|
||||
"serviceId": "gamecult.home.vn.service",
|
||||
"verseId": "gamecult.home.local",
|
||||
"rootVerse": "asgard",
|
||||
"canonicalService": "gamecult.home.vn",
|
||||
"locatedService": "gamecult.home.local.vn",
|
||||
"cultMeshAddress": "cultmesh://gamecult.home.local/eve/providers/gamecult.home.vn",
|
||||
"title": "GameCult Compound VN",
|
||||
"kind": "plugin.fixture.provider",
|
||||
"updatedAtUtc": "2026-07-08T00:00:00Z",
|
||||
"freshness": {
|
||||
"state": "fixture",
|
||||
"lastSeenAtUtc": "2026-07-08T00:00:00Z",
|
||||
"maxAgeMs": 15000
|
||||
},
|
||||
"schemas": [
|
||||
"gamecult.eve.surface.v1",
|
||||
"gamecult.eve.command.v1",
|
||||
"sai.vn.story_session.v1",
|
||||
"sai.vn.visual_manifest.v1",
|
||||
"norn.graph.document.v1",
|
||||
"tex.math.source.v1"
|
||||
],
|
||||
"witnesses": [
|
||||
{
|
||||
"kind": "plugin-fixture",
|
||||
"ref": "web/fixtures/sai-vn-surface.json",
|
||||
"summary": "Advertisement-shaped provider entry for the Sai/Norn/TeX plugin conformance fixture."
|
||||
},
|
||||
{
|
||||
"kind": "owner-repo",
|
||||
"ref": "Sai",
|
||||
"summary": "Sai owns VN/Ink semantics once the plugin ABI graduates; Eve only hosts this fixture while the contract is forming."
|
||||
}
|
||||
],
|
||||
"surfaces": [
|
||||
{
|
||||
"schema": "gamecult.eve.surface.v1",
|
||||
"surfaceId": "sai.visual_novel.surface",
|
||||
"key": "web/fixtures/sai-vn-surface.json",
|
||||
"transport": "local-json",
|
||||
"status": "available",
|
||||
"audience": "player",
|
||||
"mode": "interactive",
|
||||
"summary": "Visual novel surface requiring sidecar plugin capabilities for VN stage, Norn graph embedding, and TeX embedding.",
|
||||
"requiresPlugins": [
|
||||
{
|
||||
"pluginId": "sai.vn",
|
||||
"versionRange": "^0.1.0",
|
||||
"requiredCapabilities": [
|
||||
"vn.stage",
|
||||
"story.choose",
|
||||
"story.continue",
|
||||
"story.jump"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pluginId": "norn.graph",
|
||||
"versionRange": "^0.1.0",
|
||||
"requiredCapabilities": [
|
||||
"embed.norn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"pluginId": "tex.math",
|
||||
"versionRange": "^0.1.0",
|
||||
"requiredCapabilities": [
|
||||
"embed.tex"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"command": "story.continue",
|
||||
"transport": "plugin-abi",
|
||||
"summary": "Advance story through Sai's sidecar plugin ABI."
|
||||
},
|
||||
{
|
||||
"command": "story.choose",
|
||||
"transport": "plugin-abi",
|
||||
"summary": "Choose a story branch through Sai's sidecar plugin ABI."
|
||||
},
|
||||
{
|
||||
"command": "story.jump",
|
||||
"transport": "plugin-abi",
|
||||
"summary": "Jump to a story path through Sai's sidecar plugin ABI."
|
||||
},
|
||||
{
|
||||
"command": "style.patch",
|
||||
"transport": "eve-command",
|
||||
"summary": "Patch local style tokens without moving story truth into Eve."
|
||||
}
|
||||
],
|
||||
"contacts": [
|
||||
{
|
||||
"kind": "repo",
|
||||
"path": "E:\\Projects\\Sai"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue