diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index 53204f8..fb69712 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -280,6 +280,13 @@ Exit criteria: Recently cut: +- Conformance export now carries first-class `capabilityGaps[]` records derived + from plugin errors, provider advertisement/scenario errors, runtime missing + evidence, declared unsupported plugin projection, capture gaps, and split + blockers. The generic consumer can assert named gaps with + `--expect-capability-gap`; the default smoke now proves Direct2D capture and + EveUnity release ownership remain visible blockers instead of hiding in + Markdown prose. - Conformance export now carries a first-class `gamecult.eve.capability_matrix.v1` projection. The matrix summarizes pack health, plugin ABI/capability claims, provider surfaces/commands/receipts, diff --git a/docs/parity-testing-harness.md b/docs/parity-testing-harness.md index 3e16c5e..aee0c02 100644 --- a/docs/parity-testing-harness.md +++ b/docs/parity-testing-harness.md @@ -96,6 +96,11 @@ surfaces/commands/receipts, runtime feature/capture/command coverage, split readiness, and handoff counts. This is derived evidence for consumers, not a new source of truth. +Capability gaps are exported as `capabilityGaps[]`. They are derived from +missing plugin/provider/runtime evidence, declared unsupported plugin +projection, capture gaps, and split blockers. Consumer smokes can assert named +gap substrings with `--expect-capability-gap`. + The conformance export includes runtime `capabilityManifestPath` and `capabilityManifestErrors` fields so external consumers can distinguish a generic projection runtime from a runtime that can project plugin-declared diff --git a/schemas/gamecult.eve.conformance_export.v1.schema.json b/schemas/gamecult.eve.conformance_export.v1.schema.json index 6882cf2..3271172 100644 --- a/schemas/gamecult.eve.conformance_export.v1.schema.json +++ b/schemas/gamecult.eve.conformance_export.v1.schema.json @@ -66,6 +66,35 @@ }, "additionalProperties": true }, + "capabilityGaps": { + "type": "array", + "items": { + "type": "object", + "required": ["kind", "subjectId", "gap", "severity"], + "properties": { + "kind": { + "type": "string", + "minLength": 1 + }, + "ownerRepo": { + "type": "string" + }, + "subjectId": { + "type": "string", + "minLength": 1 + }, + "gap": { + "type": "string", + "minLength": 1 + }, + "severity": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": true + } + }, "plugins": { "type": "array" }, diff --git a/scripts/run-conformance-consumer-smoke.ps1 b/scripts/run-conformance-consumer-smoke.ps1 index ed01356..667788f 100644 --- a/scripts/run-conformance-consumer-smoke.ps1 +++ b/scripts/run-conformance-consumer-smoke.ps1 @@ -30,7 +30,11 @@ Copy-Item -LiteralPath $sourceExport -Destination $consumerExport -Recurse Push-Location $projectRoot try { - node .\tools\conformance\consume-export.mjs $consumerExport --expect-capability-matrix --expect-conformance-handoff + node .\tools\conformance\consume-export.mjs $consumerExport ` + --expect-capability-matrix ` + --expect-capability-gap runtime:Fensalir:direct2d:capture:missing ` + --expect-capability-gap "split-target:EveUnity:EveUnity:proof:Tagged UPM release" ` + --expect-conformance-handoff if ($LASTEXITCODE -ne 0) { throw "Conformance consumer smoke failed with exit code $LASTEXITCODE" } diff --git a/tools/conformance/consume-export.mjs b/tools/conformance/consume-export.mjs index a3e8d70..a53724f 100644 --- a/tools/conformance/consume-export.mjs +++ b/tools/conformance/consume-export.mjs @@ -17,6 +17,7 @@ if (!exportDirectory) { " --expect-scenario ", " --expect-split-target ", " --expect-capability-matrix", + " --expect-capability-gap ", " --expect-conformance-handoff", " --expect-plugin-operation ", " --expect-plugin-capability ", @@ -107,6 +108,7 @@ function validateIndex(index, directory, expectations, errors) { const providers = Array.isArray(index.providers) ? index.providers : []; const runtimes = mergeRuntimeRecords(Array.isArray(index.runtimes) ? index.runtimes : [], exportedRuntimeTargets); const splitTargets = Array.isArray(index.splitTargets) ? index.splitTargets : []; + const capabilityGaps = Array.isArray(index.capabilityGaps) ? index.capabilityGaps : []; if (expectations.conformanceHandoff && !index.conformanceHandoffPath) { errors.push("conformanceHandoffPath:missing"); @@ -117,6 +119,12 @@ function validateIndex(index, directory, expectations, errors) { if (expectations.capabilityMatrix) { validateCapabilityMatrix(index.capabilityMatrix, packs, plugins, providers, runtimes, splitTargets, errors); } + validateCapabilityGaps(index.capabilityGaps, errors); + for (const expectedGap of expectations.capabilityGaps) { + if (!capabilityGaps.some(gap => capabilityGapText(gap).includes(expectedGap))) { + errors.push(`capabilityGaps:${expectedGap}:missing`); + } + } for (const expectedFixture of expectations.fixtures) { if (!fixtureIds.has(expectedFixture)) errors.push(`fixture:${expectedFixture}:missing`); @@ -368,6 +376,29 @@ function validateCapabilityMatrix(matrix, packs, plugins, providers, runtimes, s } } +function validateCapabilityGaps(gaps, errors) { + if (!Array.isArray(gaps)) { + errors.push("capabilityGaps:missing"); + return; + } + for (const [index, gap] of gaps.entries()) { + for (const field of ["kind", "subjectId", "gap", "severity"]) { + if (!gap?.[field]) errors.push(`capabilityGaps:${index}:${field}:missing`); + } + } +} + +function capabilityGapText(gap) { + return [ + gap.kind, + gap.ownerRepo, + gap.subjectId, + gap.gap, + gap.severity, + gap.detail, + ].filter(Boolean).join(":"); +} + function parseArguments(args) { const exportPath = args[0] ? path.resolve(args[0]) : ""; const expectations = { @@ -394,6 +425,7 @@ function parseArguments(args) { splitTargetBlockers: [], splitTargetProofs: [], capabilityMatrix: false, + capabilityGaps: [], conformanceHandoff: false, }; const optionTargets = new Map([ @@ -419,6 +451,7 @@ function parseArguments(args) { ["--expect-split-target-status", expectations.splitTargetStatuses], ["--expect-split-target-blocker", expectations.splitTargetBlockers], ["--expect-split-target-proof", expectations.splitTargetProofs], + ["--expect-capability-gap", expectations.capabilityGaps], ]); for (let index = 1; index < args.length; index += 1) { diff --git a/tools/parity/run-parity.mjs b/tools/parity/run-parity.mjs index d917bdb..1f356ae 100644 --- a/tools/parity/run-parity.mjs +++ b/tools/parity/run-parity.mjs @@ -1356,6 +1356,7 @@ function buildConformanceExport(report) { conformanceHandoffExportPath: makeHandoffExportPath("conformance", "EveConformance", report.repoStrategy.conformanceHandoffPath || ""), packs, capabilityMatrix: buildCapabilityMatrix(report), + capabilityGaps: collectCapabilityGaps(report), plugins: (report.plugins || []).map(plugin => ({ pluginId: plugin.pluginId, status: plugin.status, @@ -1462,6 +1463,7 @@ function buildCapabilityMatrix(report) { schema: "gamecult.eve.capability_matrix.v1", generatedAt: report.generatedAt, summary: report.summary || {}, + capabilityGapCount: collectCapabilityGaps(report).length, handoffs: { conformance: report.repoStrategy.conformanceHandoffPath ? 1 : 0, plugins: plugins.filter(plugin => plugin.handoffExportPath).length, @@ -1476,6 +1478,117 @@ function buildCapabilityMatrix(report) { }; } +function collectCapabilityGaps(report) { + const gaps = []; + const addGap = ({ kind, ownerRepo, subjectId, gap, severity = "gap", detail = "" }) => { + if (!kind || !subjectId || !gap) return; + gaps.push({ + kind, + ownerRepo: ownerRepo || "", + subjectId, + gap, + severity, + detail, + }); + }; + + for (const plugin of report.plugins || []) { + for (const error of [ + ...(plugin.missingPaths || []).map(id => `path:${id}`), + ...(plugin.missingRequiredFixtures || []).map(id => `fixture:${id}`), + ...(plugin.missingIncubationFields || []).map(id => `metadata:${id}`), + ...(plugin.schemaErrors || []).map(id => `schema:${id}`), + ...(plugin.advertisementErrors || []).map(id => `advertisement:${id}`), + ...(plugin.abiErrors || []).map(id => `abi:${id}`), + ]) { + addGap({ + kind: "plugin", + ownerRepo: plugin.ownerRepo, + subjectId: plugin.pluginId, + gap: error, + severity: "blocker", + }); + } + } + + for (const provider of report.providers || []) { + for (const error of [ + ...(provider.missingPaths || []).map(id => `path:${id}`), + ...(provider.missingRequiredFixtures || []).map(id => `fixture:${id}`), + ...(provider.missingIncubationFields || []).map(id => `metadata:${id}`), + ...(provider.advertisementErrors || []).map(id => `advertisement:${id}`), + ...(provider.scenarioErrors || []).map(id => `scenario:${id}`), + ...(provider.missingSchemas || []).map(id => `schema:${id}`), + ...(provider.missingSurfaces || []).map(id => `surface:${id}`), + ...(provider.missingCommands || []).map(id => `command:${id}`), + ...(provider.pluginRequirementErrors || []).map(id => `plugin:${id}`), + ]) { + addGap({ + kind: "provider", + ownerRepo: provider.ownerRepo, + subjectId: provider.providerId, + gap: error, + severity: "blocker", + }); + } + } + + for (const runtime of report.runtimes || []) { + if (runtime.capture?.status === "missing") { + addGap({ + kind: "runtime", + ownerRepo: runtime.ownerRepo, + subjectId: runtime.id, + gap: "capture:missing", + severity: runtime.kind === "active" ? "blocker" : "activation-blocker", + }); + } + for (const error of [ + ...(runtime.missingPaths || []).map(id => `path:${id}`), + ...(runtime.missingExternalPaths || []).map(id => `external:${id}`), + ...(runtime.missingSourceSymbols || []).map(id => `source:${id}`), + ...(runtime.missingExternalSourceSymbols || []).map(id => `external-source:${id}`), + ...(runtime.missingRequiredFixtures || []).map(id => `fixture:${id}`), + ...(runtime.missingRequiredFeatures || []).map(id => `feature:${id}`), + ...(runtime.commandTransportSmokeErrors || []).map(id => `command-smoke:${id}`), + ...(runtime.capabilityManifestErrors || []).map(id => `runtime-capability:${id}`), + ...(runtime.localProviderCatalogErrors || []).map(id => `local-provider-catalog:${id}`), + ...(runtime.pluginCapabilityGaps || []).map(id => `plugin-capability:${id}`), + ]) { + addGap({ + kind: "runtime", + ownerRepo: runtime.ownerRepo, + subjectId: runtime.id, + gap: error, + severity: "blocker", + }); + } + for (const note of runtime.unsupportedPluginNotes || []) { + addGap({ + kind: "runtime", + ownerRepo: runtime.ownerRepo, + subjectId: runtime.id, + gap: `unsupported-plugin:${note}`, + severity: "declared-gap", + }); + } + } + + for (const target of report.splitTargets || []) { + for (const blocker of target.blockers || []) { + addGap({ + kind: "split-target", + ownerRepo: target.ownerRepo, + subjectId: target.id, + gap: blocker, + severity: "blocker", + }); + } + } + + return gaps; +} + async function writeConformanceExport(conformanceExport, directory) { await mkdir(path.join(directory, "packs"), { recursive: true }); await mkdir(path.join(directory, "handoffs"), { recursive: true }); @@ -1536,6 +1649,7 @@ function renderConformanceExportMarkdown(conformanceExport) { `Boundary rule: ${conformanceExport.boundaryRule || "not declared"}`, `Conformance handoff: ${conformanceExport.conformanceHandoffExportPath || conformanceExport.conformanceHandoffPath || "not declared"}`, `Capability matrix: ${conformanceExport.capabilityMatrix?.schema || "not declared"}`, + `Capability gaps: ${(conformanceExport.capabilityGaps || []).length}`, "", "## Packs", "",