Export live plugin sidecar witnesses

This commit is contained in:
Meta Vens 2026-07-10 07:23:05 +01:00
commit 86dbb685f8
8 changed files with 312 additions and 2 deletions

View file

@ -287,8 +287,8 @@ Exit criteria:
1. Graduate the generic Electron host/preload contract into EveElectron so 1. Graduate the generic Electron host/preload contract into EveElectron so
Aetheria retains only daemon lifecycle and provider transport configuration. Aetheria retains only daemon lifecycle and provider transport configuration.
2. Replace incubating Sai/Norn projection declarations with one live sidecar 2. Build Sai's owner-side playback daemon and live sidecar witness without
deployment witness per plugin, preserving their independent ownership. absorbing Norn or TeX semantics into the Sai process.
Recently cut: Recently cut:
@ -353,6 +353,16 @@ Recently cut:
renderer bundle, while the live Electron witness still proves the resolved renderer bundle, while the live Electron witness still proves the resolved
field surface. Eve core documents this as the host/plugin decoding boundary, field surface. Eve core documents this as the host/plugin decoding boundary,
not as an Aetheria-specific exception. not as an Aetheria-specific exception.
- Norn now has live runtime-independent sidecar proof, not only projection
declarations. The owner-repo `norn-eve-plugin` stays alive across correlated
`describe`, `validate`, `project`, and solver-backed `measure` requests over
advertised `stdio-ndjson`; all four operations satisfy Norn's ABI fixture.
Eve's generic sidecar witness launcher validates advertisement/fixture
agreement and response correlation, emits `gamecult.eve.plugin_witness.v1`,
and the conformance attachment path exports that witness without Norn source
paths. The first live run also removed a UTF-8 BOM framing fault at Norn's
NDJSON boundary. This proves Norn independently; it does not count as Sai
playback proof.
- The clean EveUnity PlayMode client now consumes Aetheria's independently - The clean EveUnity PlayMode client now consumes Aetheria's independently
running daemon without importing Aetheria code. Aetheria builds its authored running daemon without importing Aetheria code. Aetheria builds its authored

View file

@ -66,6 +66,23 @@
"additionalProperties": true "additionalProperties": true
} }
}, },
"pluginWitnesses": {
"type": "array",
"items": {
"type": "object",
"required": ["witnessId", "pluginId", "ownerRepo", "status", "transport", "operationCount", "witnessExportPath"],
"properties": {
"witnessId": { "type": "string", "minLength": 1 },
"pluginId": { "type": "string", "minLength": 1 },
"ownerRepo": { "type": "string", "minLength": 1 },
"status": { "enum": ["pass", "fail"] },
"transport": { "type": "string", "minLength": 1 },
"operationCount": { "type": "integer", "minimum": 1 },
"witnessExportPath": { "type": "string", "minLength": 1 }
},
"additionalProperties": true
}
},
"packs": { "packs": {
"type": "array", "type": "array",
"items": { "items": {

View file

@ -0,0 +1,35 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "gamecult.eve.plugin_witness.v1",
"title": "Eve Plugin Sidecar Witness",
"type": "object",
"required": ["schema", "witnessId", "pluginId", "ownerRepo", "status", "transport", "generatedAtUtc", "durationMs", "operations", "authority"],
"properties": {
"schema": { "const": "gamecult.eve.plugin_witness.v1" },
"witnessId": { "type": "string", "minLength": 1 },
"pluginId": { "type": "string", "minLength": 1 },
"ownerRepo": { "type": "string", "minLength": 1 },
"status": { "enum": ["pass", "fail"] },
"transport": { "type": "string", "minLength": 1 },
"generatedAtUtc": { "type": "string", "minLength": 1 },
"durationMs": { "type": "number", "minimum": 0 },
"operations": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["operation", "requestId", "status", "durationMs", "expectationCount"],
"properties": {
"operation": { "type": "string", "minLength": 1 },
"requestId": { "type": "string", "minLength": 1 },
"status": { "type": "string", "minLength": 1 },
"durationMs": { "type": "number", "minimum": 0 },
"expectationCount": { "type": "integer", "minimum": 0 }
},
"additionalProperties": true
}
},
"authority": { "type": "string", "minLength": 1 }
},
"additionalProperties": true
}

View file

@ -0,0 +1,44 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { validateSchemaSubset } from "../../runtimes/incubating/test-support/schema-subset.mjs";
const [witnessArgument, exportArgument] = process.argv.slice(2);
if (!witnessArgument || !exportArgument) {
console.error("Usage: node tools/conformance/attach-plugin-witness.mjs <plugin-witness.json> <conformance-export-directory>");
process.exit(2);
}
const witnessPath = path.resolve(witnessArgument);
const exportDirectory = path.resolve(exportArgument);
const indexPath = path.join(exportDirectory, "index.json");
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const witness = parseJson(await readFile(witnessPath, "utf8"));
const schema = parseJson(await readFile(path.join(repoRoot, "schemas/gamecult.eve.plugin_witness.v1.schema.json"), "utf8"));
const schemaErrors = validateSchemaSubset(schema, witness);
if (schemaErrors.length) throw new Error(`Plugin witness schema errors: ${schemaErrors.join("; ")}`);
const attachmentRoot = path.join(exportDirectory, "attachments", "plugin", safeSegment(witness.witnessId));
await mkdir(attachmentRoot, { recursive: true });
const exportedWitnessPath = path.join(attachmentRoot, "witness.json");
await writeFile(exportedWitnessPath, `${JSON.stringify(witness, null, 2)}\n`);
const index = parseJson(await readFile(indexPath, "utf8"));
const record = {
witnessId: witness.witnessId,
pluginId: witness.pluginId,
ownerRepo: witness.ownerRepo,
status: witness.status,
transport: witness.transport,
operationCount: witness.operations.length,
witnessExportPath: slash(path.relative(exportDirectory, exportedWitnessPath)),
};
index.pluginWitnesses = [
...(Array.isArray(index.pluginWitnesses) ? index.pluginWitnesses : []).filter(candidate => candidate.witnessId !== record.witnessId),
record,
].sort((left, right) => left.witnessId.localeCompare(right.witnessId));
await writeFile(indexPath, `${JSON.stringify(index, null, 2)}\n`);
console.log(exportedWitnessPath);
function safeSegment(value) { return String(value).replace(/[^a-zA-Z0-9._-]+/g, "-"); }
function slash(value) { return value.replaceAll("\\", "/"); }
function parseJson(value) { return JSON.parse(value.replace(/^\uFEFF/, "")); }

View file

@ -40,6 +40,7 @@ if (!exportDirectory) {
" --expect-plugin-abi-field <pluginId:operation:field.path:value>", " --expect-plugin-abi-field <pluginId:operation:field.path:value>",
" --expect-plugin-abi-operation-coverage <pluginId:operation:status:ownerRepo>", " --expect-plugin-abi-operation-coverage <pluginId:operation:status:ownerRepo>",
" --expect-plugin-handoff <pluginId>", " --expect-plugin-handoff <pluginId>",
" --expect-plugin-witness <pluginId:transport:status>",
" --expect-plugin-handoff-move <pluginId:moveSetId:pathKind:status:path-substring>", " --expect-plugin-handoff-move <pluginId:moveSetId:pathKind:status:path-substring>",
" --expect-provider-handoff-move <providerId:moveSetId:pathKind:status:path-substring>", " --expect-provider-handoff-move <providerId:moveSetId:pathKind:status:path-substring>",
" --expect-provider-surface <providerId:surfaceId>", " --expect-provider-surface <providerId:surfaceId>",
@ -140,6 +141,7 @@ function validateIndex(index, directory, expectations, errors) {
} }
const plugins = Array.isArray(index.plugins) ? index.plugins : []; const plugins = Array.isArray(index.plugins) ? index.plugins : [];
const pluginWitnesses = Array.isArray(index.pluginWitnesses) ? index.pluginWitnesses : [];
const providers = Array.isArray(index.providers) ? index.providers : []; const providers = Array.isArray(index.providers) ? index.providers : [];
const runtimes = mergeRuntimeRecords(Array.isArray(index.runtimes) ? index.runtimes : [], exportedRuntimeTargets); const runtimes = mergeRuntimeRecords(Array.isArray(index.runtimes) ? index.runtimes : [], exportedRuntimeTargets);
const splitTargets = Array.isArray(index.splitTargets) ? index.splitTargets : []; const splitTargets = Array.isArray(index.splitTargets) ? index.splitTargets : [];
@ -163,6 +165,7 @@ function validateIndex(index, directory, expectations, errors) {
const worldSurfaceLoweringGaps = Array.isArray(index.worldSurfaceLoweringGaps) ? index.worldSurfaceLoweringGaps : []; const worldSurfaceLoweringGaps = Array.isArray(index.worldSurfaceLoweringGaps) ? index.worldSurfaceLoweringGaps : [];
validatePluginRecords(plugins, errors); validatePluginRecords(plugins, errors);
validatePluginWitnesses(pluginWitnesses, directory, errors);
validatePluginAbiOperationCoverage(index.pluginAbiOperationCoverage, errors); validatePluginAbiOperationCoverage(index.pluginAbiOperationCoverage, errors);
validateProviderRecords(providers, errors); validateProviderRecords(providers, errors);
validateProviderPluginRequirementCoverage(index.providerPluginRequirementCoverage, errors); validateProviderPluginRequirementCoverage(index.providerPluginRequirementCoverage, errors);
@ -228,6 +231,15 @@ function validateIndex(index, directory, expectations, errors) {
errors.push(`runtimeWitnesses:${witness.witnessId}:status:expected ${expectation.status} got ${witness.status || ""}`); errors.push(`runtimeWitnesses:${witness.witnessId}:status:expected ${expectation.status} got ${witness.status || ""}`);
} }
} }
for (const expectation of expectations.pluginWitnesses) {
const witness = pluginWitnesses.find(candidate =>
candidate.pluginId === expectation.pluginId && candidate.transport === expectation.transport);
if (!witness) {
errors.push(`pluginWitnesses:${expectation.pluginId}:${expectation.transport}:missing`);
continue;
}
if (witness.status !== expectation.status) errors.push(`pluginWitnesses:${expectation.pluginId}:status:expected ${expectation.status} got ${witness.status || ""}`);
}
for (const expectation of expectations.providerRuntimePluginProjectionCoverage) { for (const expectation of expectations.providerRuntimePluginProjectionCoverage) {
const record = providerRuntimePluginProjectionCoverage.find(candidate => const record = providerRuntimePluginProjectionCoverage.find(candidate =>
candidate.providerId === expectation.providerId && candidate.providerId === expectation.providerId &&
@ -1168,6 +1180,23 @@ function validatePluginRecords(plugins, errors) {
} }
} }
function validatePluginWitnesses(witnesses, directory, errors) {
if (!Array.isArray(witnesses)) return;
for (const [index, witness] of witnesses.entries()) {
for (const field of ["witnessId", "pluginId", "ownerRepo", "status", "transport", "operationCount", "witnessExportPath"]) {
if (witness?.[field] === undefined || witness?.[field] === "") errors.push(`pluginWitnesses:${index}:${field}:missing`);
}
const witnessPath = witness?.witnessExportPath ? path.join(directory, witness.witnessExportPath) : "";
if (!witnessPath || !existsSync(witnessPath)) {
errors.push(`pluginWitnesses:${index}:witnessExportPath:missing`);
continue;
}
const document = JSON.parse(readFileSyncUtf8(witnessPath));
if (document.schema !== "gamecult.eve.plugin_witness.v1") errors.push(`pluginWitnesses:${index}:schema:unexpected`);
if ((document.operations || []).length !== witness.operationCount) errors.push(`pluginWitnesses:${index}:operationCount:mismatch`);
}
}
function validatePluginAbiOperationCoverage(records, errors) { function validatePluginAbiOperationCoverage(records, errors) {
if (!Array.isArray(records)) { if (!Array.isArray(records)) {
errors.push("pluginAbiOperationCoverage:missing"); errors.push("pluginAbiOperationCoverage:missing");
@ -1343,6 +1372,7 @@ function parseArguments(args) {
pluginAbiFields: [], pluginAbiFields: [],
pluginAbiOperationCoverage: [], pluginAbiOperationCoverage: [],
pluginHandoffs: [], pluginHandoffs: [],
pluginWitnesses: [],
pluginHandoffMoves: [], pluginHandoffMoves: [],
providers: [], providers: [],
providerSurfaces: [], providerSurfaces: [],
@ -1408,6 +1438,7 @@ function parseArguments(args) {
["--expect-plugin-abi-field", expectations.pluginAbiFields], ["--expect-plugin-abi-field", expectations.pluginAbiFields],
["--expect-plugin-abi-operation-coverage", expectations.pluginAbiOperationCoverage], ["--expect-plugin-abi-operation-coverage", expectations.pluginAbiOperationCoverage],
["--expect-plugin-handoff", expectations.pluginHandoffs], ["--expect-plugin-handoff", expectations.pluginHandoffs],
["--expect-plugin-witness", expectations.pluginWitnesses],
["--expect-plugin-handoff-move", expectations.pluginHandoffMoves], ["--expect-plugin-handoff-move", expectations.pluginHandoffMoves],
["--expect-provider-surface", expectations.providerSurfaces], ["--expect-provider-surface", expectations.providerSurfaces],
["--expect-provider-surface-kind", expectations.providerSurfaceKinds], ["--expect-provider-surface-kind", expectations.providerSurfaceKinds],
@ -1488,6 +1519,8 @@ function parseArguments(args) {
target.push(parsePluginAbiOperationCoverageExpectation(value)); target.push(parsePluginAbiOperationCoverageExpectation(value));
} else if (option === "--expect-plugin-handoff") { } else if (option === "--expect-plugin-handoff") {
target.push(value); target.push(value);
} else if (option === "--expect-plugin-witness") {
target.push(parsePluginWitnessExpectation(value));
} else if (option === "--expect-plugin-handoff-move") { } else if (option === "--expect-plugin-handoff-move") {
target.push(parsePluginHandoffMoveExpectation(value)); target.push(parsePluginHandoffMoveExpectation(value));
} else if (option === "--expect-provider-surface") { } else if (option === "--expect-provider-surface") {
@ -1928,6 +1961,15 @@ function parseProviderExpectation(value, field) {
}; };
} }
function parsePluginWitnessExpectation(value) {
const parts = value.split(":");
if (parts.length !== 3 || parts.some(part => !part)) {
console.error(`Expected plugin witness in <pluginId:transport:status> form, got: ${value}`);
process.exit(2);
}
return { pluginId: parts[0], transport: parts[1], status: parts[2] };
}
function parseProviderSurfaceKindExpectation(value) { function parseProviderSurfaceKindExpectation(value) {
const parts = value.split(":"); const parts = value.split(":");
if (parts.length !== 3 || parts.some(part => !part)) { if (parts.length !== 3 || parts.some(part => !part)) {

View file

@ -39,6 +39,7 @@
"schemas/gamecult.eve.plugin_receipt.v1.schema.json", "schemas/gamecult.eve.plugin_receipt.v1.schema.json",
"schemas/gamecult.eve.plugin_advertisement.v1.schema.json", "schemas/gamecult.eve.plugin_advertisement.v1.schema.json",
"schemas/gamecult.eve.plugin_handoff.v1.schema.json", "schemas/gamecult.eve.plugin_handoff.v1.schema.json",
"schemas/gamecult.eve.plugin_witness.v1.schema.json",
"schemas/gamecult.eve.plugin_abi_fixture.v1.schema.json", "schemas/gamecult.eve.plugin_abi_fixture.v1.schema.json",
"schemas/gamecult.eve.plugin_abi.request.v1.schema.json", "schemas/gamecult.eve.plugin_abi.request.v1.schema.json",
"schemas/gamecult.eve.plugin_abi.response.v1.schema.json", "schemas/gamecult.eve.plugin_abi.response.v1.schema.json",
@ -88,6 +89,7 @@
"gamecult.eve.plugin_receipt.v1", "gamecult.eve.plugin_receipt.v1",
"gamecult.eve.plugin_advertisement.v1", "gamecult.eve.plugin_advertisement.v1",
"gamecult.eve.plugin_handoff.v1", "gamecult.eve.plugin_handoff.v1",
"gamecult.eve.plugin_witness.v1",
"gamecult.eve.plugin_abi_fixture.v1", "gamecult.eve.plugin_abi_fixture.v1",
"gamecult.eve.plugin_abi.request.v1", "gamecult.eve.plugin_abi.request.v1",
"gamecult.eve.plugin_abi.response.v1", "gamecult.eve.plugin_abi.response.v1",

View file

@ -20,6 +20,7 @@
"gamecult.eve.plugin_abi_fixture.v1": "schemas/gamecult.eve.plugin_abi_fixture.v1.schema.json", "gamecult.eve.plugin_abi_fixture.v1": "schemas/gamecult.eve.plugin_abi_fixture.v1.schema.json",
"gamecult.eve.plugin_advertisement.v1": "schemas/gamecult.eve.plugin_advertisement.v1.schema.json", "gamecult.eve.plugin_advertisement.v1": "schemas/gamecult.eve.plugin_advertisement.v1.schema.json",
"gamecult.eve.plugin_handoff.v1": "schemas/gamecult.eve.plugin_handoff.v1.schema.json", "gamecult.eve.plugin_handoff.v1": "schemas/gamecult.eve.plugin_handoff.v1.schema.json",
"gamecult.eve.plugin_witness.v1": "schemas/gamecult.eve.plugin_witness.v1.schema.json",
"gamecult.eve.runtime_capability.v1": "schemas/gamecult.eve.runtime_capability.v1.schema.json", "gamecult.eve.runtime_capability.v1": "schemas/gamecult.eve.runtime_capability.v1.schema.json",
"gamecult.eve.runtime_lifecycle.v1": "schemas/gamecult.eve.runtime_lifecycle.v1.schema.json", "gamecult.eve.runtime_lifecycle.v1": "schemas/gamecult.eve.runtime_lifecycle.v1.schema.json",
"gamecult.eve.runtime_split_handoff.v1": "schemas/gamecult.eve.runtime_split_handoff.v1.schema.json", "gamecult.eve.runtime_split_handoff.v1": "schemas/gamecult.eve.runtime_split_handoff.v1.schema.json",

View file

@ -0,0 +1,159 @@
import { spawn } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import { performance } from "node:perf_hooks";
const options = parseArguments(process.argv.slice(2));
const advertisement = parseJson(await readFile(options.advertisement, "utf8"));
const fixture = parseJson(await readFile(options.fixture, "utf8"));
validateInputs(advertisement, fixture, options);
const started = performance.now();
const child = spawn(options.command, options.commandArgs, {
cwd: options.cwd,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
});
const lines = readline.createInterface({ input: child.stdout });
const responses = [];
const waiters = [];
lines.on("line", line => {
const waiter = waiters.shift();
if (!waiter) return;
try { waiter.resolve(parseJson(line)); } catch (error) { waiter.reject(error); }
});
let stderr = "";
child.stderr.on("data", chunk => { stderr += chunk.toString(); });
try {
for (const [index, operation] of fixture.operations.entries()) {
const requestId = `${fixture.fixtureId}:${operation.operation}:${index}`;
const request = {
schema: fixture.requestSchema,
pluginId: fixture.pluginId,
operation: operation.operation,
requestId,
input: operation.input || {},
context: { witnessId: options.witnessId },
targetRuntime: "eve-plugin-witness",
};
const operationStarted = performance.now();
const responsePromise = nextResponse(options.timeoutMs);
child.stdin.write(`${JSON.stringify(request)}\n`);
const response = await responsePromise;
validateResponse(response, request, operation.expect || {});
responses.push({
operation: operation.operation,
requestId,
status: response.status,
durationMs: round(performance.now() - operationStarted),
expectationCount: Object.keys(operation.expect || {}).length,
});
}
} finally {
child.stdin.end();
await Promise.race([new Promise(resolve => child.once("exit", resolve)), delay(1000)]);
if (child.exitCode === null) child.kill();
lines.close();
}
const witness = {
schema: "gamecult.eve.plugin_witness.v1",
witnessId: options.witnessId,
pluginId: fixture.pluginId,
ownerRepo: fixture.ownerRepo,
status: "pass",
transport: advertisement.runtime?.sidecar?.protocol || advertisement.runtime?.transports?.[0] || "",
generatedAtUtc: new Date().toISOString(),
durationMs: round(performance.now() - started),
operations: responses,
advertisementPath: slash(path.relative(options.cwd, options.advertisement)),
fixturePath: slash(path.relative(options.cwd, options.fixture)),
executable: {
artifact: advertisement.runtime?.sidecar?.launch?.artifact || "",
command: advertisement.runtime?.sidecar?.launch?.command || path.basename(options.command),
},
diagnostics: stderr.trim() ? [stderr.trim()] : [],
authority: "plugin-sidecar-owns-semantic-projection-and-proposes-state-provider-retains-acceptance",
};
await mkdir(path.dirname(options.output), { recursive: true });
await writeFile(options.output, `${JSON.stringify(witness, null, 2)}\n`);
console.log(options.output);
function nextResponse(timeoutMs) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Plugin sidecar response timed out after ${timeoutMs}ms.`)), timeoutMs);
waiters.push({
resolve: value => { clearTimeout(timer); resolve(value); },
reject: error => { clearTimeout(timer); reject(error); },
});
});
}
function validateInputs(advertisement, fixture, options) {
if (advertisement.schema !== "gamecult.eve.plugin_advertisement.v1") throw new Error("Plugin advertisement schema is invalid.");
if (fixture.schema !== "gamecult.eve.plugin_abi_fixture.v1") throw new Error("Plugin ABI fixture schema is invalid.");
if (advertisement.pluginId !== fixture.pluginId) throw new Error("Plugin advertisement and fixture ids differ.");
if (!advertisement.runtime?.sidecar?.operations?.length) throw new Error("Plugin advertisement has no sidecar operations.");
for (const operation of fixture.operations || []) {
if (!advertisement.runtime.sidecar.operations.includes(operation.operation)) throw new Error(`Operation ${operation.operation} is not advertised.`);
}
if (!options.command) throw new Error("Plugin sidecar command is required.");
}
function validateResponse(response, request, expect) {
for (const [field, value] of Object.entries({
schema: "gamecult.eve.plugin_abi.response.v1",
pluginId: request.pluginId,
operation: request.operation,
requestId: request.requestId,
status: "accepted",
})) {
if (response?.[field] !== value) throw new Error(`${request.operation} response ${field}: expected ${value}, got ${response?.[field] || ""}`);
}
assertSubset(response.output, expect, request.operation);
}
function assertSubset(actual, expected, label) {
for (const [key, expectedValue] of Object.entries(expected)) {
if (key === "measurementOutputs") {
for (const outputKey of expectedValue) if (!(outputKey in (actual || {}))) throw new Error(`${label} output missing ${outputKey}.`);
continue;
}
const actualValue = actual?.[key];
if (Array.isArray(expectedValue)) {
if (!Array.isArray(actualValue) || expectedValue.some(value => !actualValue.includes(value))) throw new Error(`${label} output ${key} does not include expected values.`);
} else if (expectedValue && typeof expectedValue === "object") {
assertSubset(actualValue, expectedValue, `${label}.${key}`);
} else if (actualValue !== expectedValue) {
throw new Error(`${label} output ${key}: expected ${expectedValue}, got ${actualValue}.`);
}
}
}
function parseArguments(args) {
const values = { commandArgs: [], timeoutMs: 5000 };
for (let index = 0; index < args.length; index += 1) {
const key = args[index];
if (key === "--") { values.commandArgs = args.slice(index + 1); break; }
const value = args[++index];
if (!value) throw new Error(`Missing value for ${key}.`);
if (key === "--advertisement") values.advertisement = path.resolve(value);
else if (key === "--fixture") values.fixture = path.resolve(value);
else if (key === "--output") values.output = path.resolve(value);
else if (key === "--witness-id") values.witnessId = value;
else if (key === "--cwd") values.cwd = path.resolve(value);
else if (key === "--command") values.command = value;
else if (key === "--timeout-ms") values.timeoutMs = Number(value);
else throw new Error(`Unknown option ${key}.`);
}
values.cwd ||= process.cwd();
for (const field of ["advertisement", "fixture", "output", "witnessId", "command"]) if (!values[field]) throw new Error(`Missing --${field.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)}.`);
return values;
}
function parseJson(value) { return JSON.parse(value.replace(/^\uFEFF/, "")); }
function round(value) { return Math.round(value * 1000) / 1000; }
function slash(value) { return value.replaceAll("\\", "/"); }
function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }