From e562055bfee7855149e9be661a69763c56e49758 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 10 Jul 2026 14:50:47 +0100 Subject: [PATCH] Add independent semantic fields plugin --- package-lock.json | 12 +++ package.json | 3 +- plugins/eve-plugin-fields/advertisement.json | 28 +++++ .../fixtures/basic-field.json | 10 ++ plugins/eve-plugin-fields/package.json | 10 ++ .../eve-plugin-fields/plugin-abi-fixture.json | 16 +++ plugins/eve-plugin-fields/plugin.json | 40 +++++++ .../eve-plugin-fields/src/cultnet-sidecar.mjs | 62 +++++++++++ plugins/eve-plugin-fields/src/plugin.mjs | 102 ++++++++++++++++++ plugins/eve-plugin-fields/src/sidecar.mjs | 27 +++++ .../eve-plugin-fields/test/plugin.test.mjs | 41 +++++++ scripts/run-tex-witness.mjs | 38 +++++-- 12 files changed, 378 insertions(+), 11 deletions(-) create mode 100644 plugins/eve-plugin-fields/advertisement.json create mode 100644 plugins/eve-plugin-fields/fixtures/basic-field.json create mode 100644 plugins/eve-plugin-fields/package.json create mode 100644 plugins/eve-plugin-fields/plugin-abi-fixture.json create mode 100644 plugins/eve-plugin-fields/plugin.json create mode 100644 plugins/eve-plugin-fields/src/cultnet-sidecar.mjs create mode 100644 plugins/eve-plugin-fields/src/plugin.mjs create mode 100644 plugins/eve-plugin-fields/src/sidecar.mjs create mode 100644 plugins/eve-plugin-fields/test/plugin.test.mjs diff --git a/package-lock.json b/package-lock.json index 7293ae0..c60bcc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,10 @@ "zod": "^3.25.76" } }, + "node_modules/@gamecult/eve-plugin-fields": { + "resolved": "plugins/eve-plugin-fields", + "link": true + }, "node_modules/@gamecult/eve-plugin-tex": { "resolved": "plugins/eve-plugin-tex", "link": true @@ -73,6 +77,14 @@ "katex": "cli.js" } }, + "plugins/eve-plugin-fields": { + "name": "@gamecult/eve-plugin-fields", + "version": "0.1.0", + "bin": { + "eve-plugin-fields": "src/sidecar.mjs", + "eve-plugin-fields-cultnet": "src/cultnet-sidecar.mjs" + } + }, "plugins/eve-plugin-tex": { "name": "@gamecult/eve-plugin-tex", "version": "0.1.0", diff --git a/package.json b/package.json index e80530c..0e88144 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "workspaces": ["plugins/*"], "scripts": { "test": "C:\\Progra~1\\nodejs\\node.exe --test plugins/*/test/*.test.mjs", - "witness:tex": "C:\\Progra~1\\nodejs\\node.exe scripts/run-tex-witness.mjs" + "witness:tex": "C:\\Progra~1\\nodejs\\node.exe scripts/run-tex-witness.mjs tex", + "witness:fields": "C:\\Progra~1\\nodejs\\node.exe scripts/run-tex-witness.mjs fields" }, "dependencies": { "@msgpack/msgpack": "^3.1.3", diff --git a/plugins/eve-plugin-fields/advertisement.json b/plugins/eve-plugin-fields/advertisement.json new file mode 100644 index 0000000..b3c37bc --- /dev/null +++ b/plugins/eve-plugin-fields/advertisement.json @@ -0,0 +1,28 @@ +{ + "schema": "gamecult.eve.plugin_advertisement.v1", + "pluginId": "fields.surface", + "ownerService": "eveplugins", + "version": "0.1.0", + "manifestAddress": "file://plugins/eve-plugin-fields/plugin.json", + "runtime": { + "invocationModel": "executable-sidecar", + "contract": "gamecult.eve.plugin_abi.v1", + "transports": ["cultnet-rudp", "stdio"], + "authority": ["renderer-independent", "no-provider-state-mutation", "provider-accepts-or-denies-commands"], + "sidecar": { + "processKind": "long-running-daemon", + "protocol": "cultnet-operation-v0", + "debugProtocol": "stdio-ndjson", + "requestSchema": "gamecult.eve.plugin_abi.request.v1", + "responseSchema": "gamecult.eve.plugin_abi.response.v1", + "operations": ["describe", "validate", "project", "measure"], + "commandEnvelope": "gamecult.eve.command_invocation.v1", + "receiptSchema": "gamecult.eve.plugin_receipt.v1", + "stateAuthority": "projects-provider-owned-field-documents-without-mutating-them" + } + }, + "schemas": ["gamecult.fields.surface2d.v1", "gamecult.fields.scalar.v1", "gamecult.fields.vector.v1", "gamecult.fields.objects.v1"], + "componentKinds": ["field.surface2d", "gravity.surface"], + "commands": [], + "fixtures": ["fields-surface-basic"] +} diff --git a/plugins/eve-plugin-fields/fixtures/basic-field.json b/plugins/eve-plugin-fields/fixtures/basic-field.json new file mode 100644 index 0000000..8dbff7a --- /dev/null +++ b/plugins/eve-plugin-fields/fixtures/basic-field.json @@ -0,0 +1,10 @@ +{ + "schema": "gamecult.fields.surface2d.v1", + "fieldId": "fixture.field", + "bounds": { "minX": -10, "minY": -10, "maxX": 10, "maxY": 10 }, + "channels": [ + { "channelId": "height", "kind": "scalar", "documentId": "cultmesh://fixture/fields/height", "schemaId": "gamecult.fields.scalar.v1" }, + { "channelId": "flow", "kind": "vector", "documentId": "cultmesh://fixture/fields/flow", "schemaId": "gamecult.fields.vector.v1" } + ], + "objects": { "documentId": "cultmesh://fixture/fields/objects", "schemaId": "gamecult.fields.objects.v1" } +} diff --git a/plugins/eve-plugin-fields/package.json b/plugins/eve-plugin-fields/package.json new file mode 100644 index 0000000..599b154 --- /dev/null +++ b/plugins/eve-plugin-fields/package.json @@ -0,0 +1,10 @@ +{ + "name": "@gamecult/eve-plugin-fields", + "version": "0.1.0", + "type": "module", + "private": true, + "bin": { + "eve-plugin-fields": "src/sidecar.mjs", + "eve-plugin-fields-cultnet": "src/cultnet-sidecar.mjs" + } +} diff --git a/plugins/eve-plugin-fields/plugin-abi-fixture.json b/plugins/eve-plugin-fields/plugin-abi-fixture.json new file mode 100644 index 0000000..d75aba9 --- /dev/null +++ b/plugins/eve-plugin-fields/plugin-abi-fixture.json @@ -0,0 +1,16 @@ +{ + "schema": "gamecult.eve.plugin_abi_fixture.v1", + "fixtureId": "fields-surface-live-sidecar", + "pluginId": "fields.surface", + "ownerRepo": "EvePlugins", + "contract": "gamecult.eve.plugin_abi.v1", + "requestSchema": "gamecult.eve.plugin_abi.request.v1", + "responseSchema": "gamecult.eve.plugin_abi.response.v1", + "authority": "EvePlugins owns field semantics; providers own sampled field state and runtimes own native projection.", + "operations": [ + { "operation": "describe", "input": {}, "expect": { "pluginId": "fields.surface", "componentKinds": ["field.surface2d", "gravity.surface"], "commands": [] } }, + { "operation": "validate", "input": { "componentKinds": ["field.surface2d", "world.scene3d"], "document": { "schema": "gamecult.fields.surface2d.v1", "bounds": { "minX": -1, "minY": -1, "maxX": 1, "maxY": 1 } } }, "expect": { "acceptedComponentKinds": ["field.surface2d"], "rejectedComponentKinds": ["world.scene3d"], "validDocument": true } }, + { "operation": "project", "input": { "document": { "schema": "gamecult.fields.surface2d.v1", "fieldId": "fixture.field", "bounds": { "minX": -1, "minY": -1, "maxX": 1, "maxY": 1 }, "channels": [{ "channelId": "height", "kind": "scalar", "documentId": "cultmesh://fixture/height", "schemaId": "gamecult.fields.scalar.v1" }] } }, "expect": { "projectionKind": "semantic-field-surface", "ownedComponentKinds": ["field.surface2d", "gravity.surface"], "providerStateMutated": false } }, + { "operation": "measure", "input": { "document": { "schema": "gamecult.fields.surface2d.v1", "fieldId": "fixture.field", "bounds": { "minX": -1, "minY": -2, "maxX": 3, "maxY": 2 }, "channels": [] } }, "expect": { "measurementKind": "field-domain-metrics", "width": 4, "height": 4, "preservesProviderAuthority": true } } + ] +} diff --git a/plugins/eve-plugin-fields/plugin.json b/plugins/eve-plugin-fields/plugin.json new file mode 100644 index 0000000..46f8dec --- /dev/null +++ b/plugins/eve-plugin-fields/plugin.json @@ -0,0 +1,40 @@ +{ + "schema": "gamecult.eve.plugin.v1", + "pluginId": "fields.surface", + "title": "Semantic Fields", + "owner": "EvePlugins", + "incubation": { + "ownerRepo": "EvePlugins", + "currentHostRepo": "EvePlugins", + "splitTarget": "EvePlugins/eve-plugin-fields", + "graduationTrigger": "Split when field schemas, sidecar witness, and two runtime adapters are stable." + }, + "componentKinds": ["field.surface2d", "gravity.surface"], + "commands": [], + "runtime": { + "invocationModel": "executable-sidecar", + "contract": "gamecult.eve.plugin_abi.v1", + "transports": ["cultnet-rudp", "stdio"], + "authority": ["renderer-independent", "no-provider-state-mutation", "provider-accepts-or-denies-commands"], + "sidecar": { + "processKind": "long-running-daemon", + "protocol": "cultnet-operation-v0", + "debugProtocol": "stdio-ndjson", + "requestSchema": "gamecult.eve.plugin_abi.request.v1", + "responseSchema": "gamecult.eve.plugin_abi.response.v1", + "operations": ["describe", "validate", "project", "measure"], + "commandEnvelope": "gamecult.eve.command_invocation.v1", + "receiptSchema": "gamecult.eve.plugin_receipt.v1", + "stateAuthority": "projects-provider-owned-field-documents-without-mutating-them" + } + }, + "stateSchemas": ["gamecult.fields.surface2d.v1", "gamecult.fields.scalar.v1", "gamecult.fields.vector.v1", "gamecult.fields.objects.v1"], + "fixtures": [ + { "fixtureId": "fields-surface-basic", "schema": "gamecult.fields.surface2d.v1", "path": "plugins/eve-plugin-fields/fixtures/basic-field.json" } + ], + "abiFixtures": ["plugins/eve-plugin-fields/plugin-abi-fixture.json"], + "capabilities": { + "requires": ["field.surface2d"], + "optionalCapabilities": ["gravity.surface", "field.scalar", "field.vector", "field.objects"] + } +} diff --git a/plugins/eve-plugin-fields/src/cultnet-sidecar.mjs b/plugins/eve-plugin-fields/src/cultnet-sidecar.mjs new file mode 100644 index 0000000..dab6241 --- /dev/null +++ b/plugins/eve-plugin-fields/src/cultnet-sidecar.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node + +import { Buffer } from "node:buffer"; +import { decode, encode } from "@msgpack/msgpack"; +import { startCultNetOperationServer } from "cultnet-ts"; +import { dispatch, pluginId } from "./plugin.mjs"; + +const host = process.env.EVE_PLUGIN_HOST || "127.0.0.1"; +const port = Number(process.env.EVE_PLUGIN_PORT || 0); +const runtimeId = "eve-plugin-fields"; +const server = await startCultNetOperationServer({ + runtimeId, + host, + port, + handler: request => { + if (request.serviceId !== pluginId) throw new Error(`Expected service ${pluginId}.`); + if (request.payloadSchema !== "gamecult.eve.plugin_abi.request.v1") throw new Error("Unsupported plugin payload schema."); + const abiRequest = decode(Buffer.from(request.payload, "base64")); + let abiResponse; + try { + abiResponse = { + schema: "gamecult.eve.plugin_abi.response.v1", + pluginId, + operation: abiRequest.operation, + requestId: abiRequest.requestId, + status: "accepted", + output: dispatch(abiRequest), + }; + } catch (error) { + abiResponse = { + schema: "gamecult.eve.plugin_abi.response.v1", + pluginId, + operation: abiRequest?.operation || request.operation, + requestId: abiRequest?.requestId || request.messageId, + status: "rejected", + output: {}, + diagnostics: [{ message: error instanceof Error ? error.message : String(error) }], + }; + } + return { + schemaVersion: "cultnet.operation_response.v0", + messageId: request.messageId, + serviceId: pluginId, + operation: request.operation, + status: abiResponse.status, + payloadSchema: abiResponse.schema, + payloadEncoding: "messagepack-base64", + payload: Buffer.from(encode(abiResponse)).toString("base64"), + diagnostics: (abiResponse.diagnostics || []).map(item => item.message), + sourceRuntimeId: runtimeId, + }; + }, +}); + +process.stdout.write(`${JSON.stringify({ schema: "gamecult.eve.plugin_endpoint.v1", pluginId, endpoint: server.endpoint })}\n`); +const stop = async () => { + await server.close(); + process.exit(0); +}; +process.once("SIGINT", stop); +process.once("SIGTERM", stop); +await new Promise(() => {}); diff --git a/plugins/eve-plugin-fields/src/plugin.mjs b/plugins/eve-plugin-fields/src/plugin.mjs new file mode 100644 index 0000000..1659e7e --- /dev/null +++ b/plugins/eve-plugin-fields/src/plugin.mjs @@ -0,0 +1,102 @@ +export const pluginId = "fields.surface"; +export const operations = ["describe", "validate", "project", "measure"]; +export const componentKinds = ["field.surface2d", "gravity.surface"]; + +export function dispatch(request) { + if (request?.schema !== "gamecult.eve.plugin_abi.request.v1") throw new Error("Unsupported request schema."); + if (request.pluginId !== pluginId) throw new Error(`Expected plugin ${pluginId}.`); + if (!operations.includes(request.operation)) throw new Error(`Unsupported operation ${request.operation}.`); + switch (request.operation) { + case "describe": return describe(); + case "validate": return validate(request.input || {}); + case "project": return project(request.input || {}); + case "measure": return measure(request.input || {}); + } +} + +export function describe() { + return { + pluginId, + componentKinds, + commands: [], + capabilities: ["field.surface2d", "gravity.surface", "field.scalar", "field.vector", "field.objects"], + operations, + stateAuthority: "provider-owns-field-documents", + }; +} + +export function validate(input) { + const requested = Array.isArray(input.componentKinds) ? input.componentKinds : []; + const document = input.document || {}; + const diagnostics = []; + if (document.schema && document.schema !== "gamecult.fields.surface2d.v1") diagnostics.push(`Unsupported field schema ${document.schema}.`); + if (document.bounds && !validBounds(document.bounds)) diagnostics.push("Field bounds must have increasing finite axes."); + return { + acceptedComponentKinds: requested.filter(kind => componentKinds.includes(kind)), + rejectedComponentKinds: requested.filter(kind => !componentKinds.includes(kind)), + validDocument: diagnostics.length === 0, + diagnostics, + requiredCapabilities: ["field.surface2d"], + }; +} + +export function project(input) { + const document = requiredObject(input.document, "document"); + if (document.schema !== "gamecult.fields.surface2d.v1") throw new Error("document must use gamecult.fields.surface2d.v1."); + if (!validBounds(document.bounds)) throw new Error("document bounds are invalid."); + const channels = Array.isArray(document.channels) ? document.channels.map(normalizeChannel) : []; + return { + schema: "gamecult.fields.projection.v1", + projectionKind: "semantic-field-surface", + ownedComponentKinds: componentKinds, + fieldId: requiredString(document.fieldId, "fieldId"), + bounds: { ...document.bounds }, + channels, + objects: document.objects ? normalizeDocumentRef(document.objects, "objects") : null, + nativeProjectionAuthority: "runtime-adapter", + providerStateMutated: false, + }; +} + +export function measure(input) { + const projection = project(input); + return { + measurementKind: "field-domain-metrics", + width: projection.bounds.maxX - projection.bounds.minX, + height: projection.bounds.maxY - projection.bounds.minY, + channelCount: projection.channels.length, + preservesProviderAuthority: true, + }; +} + +function normalizeChannel(channel, index) { + const value = requiredObject(channel, `channels[${index}]`); + const kind = requiredString(value.kind, `channels[${index}].kind`); + if (!["scalar", "vector", "color", "mask"].includes(kind)) throw new Error(`Unsupported field channel kind ${kind}.`); + return { + channelId: requiredString(value.channelId, `channels[${index}].channelId`), + kind, + ...normalizeDocumentRef(value, `channels[${index}]`), + }; +} + +function normalizeDocumentRef(value, field) { + return { + documentId: requiredString(value.documentId, `${field}.documentId`), + schemaId: requiredString(value.schemaId, `${field}.schemaId`), + }; +} + +function validBounds(value) { + return value && [value.minX, value.minY, value.maxX, value.maxY].every(Number.isFinite) && value.minX < value.maxX && value.minY < value.maxY; +} + +function requiredObject(value, field) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${field} is required.`); + return value; +} + +function requiredString(value, field) { + if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required.`); + return value; +} diff --git a/plugins/eve-plugin-fields/src/sidecar.mjs b/plugins/eve-plugin-fields/src/sidecar.mjs new file mode 100644 index 0000000..5b4912b --- /dev/null +++ b/plugins/eve-plugin-fields/src/sidecar.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import readline from "node:readline"; +import { dispatch, pluginId } from "./plugin.mjs"; + +readline.createInterface({ input: process.stdin }).on("line", line => { + if (!line.trim()) return; + let request; + try { + request = JSON.parse(line.replace(/^\uFEFF/, "")); + respond(request, dispatch(request)); + } catch (error) { + respond(request, {}, "rejected", error instanceof Error ? error.message : String(error)); + } +}); + +function respond(request, output, status = "accepted", message) { + process.stdout.write(`${JSON.stringify({ + schema: "gamecult.eve.plugin_abi.response.v1", + pluginId, + operation: request?.operation || "unknown", + requestId: request?.requestId || "unknown", + status, + output, + ...(message ? { diagnostics: [{ message }] } : {}), + })}\n`); +} diff --git a/plugins/eve-plugin-fields/test/plugin.test.mjs b/plugins/eve-plugin-fields/test/plugin.test.mjs new file mode 100644 index 0000000..864c270 --- /dev/null +++ b/plugins/eve-plugin-fields/test/plugin.test.mjs @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { dispatch } from "../src/plugin.mjs"; + +function request(operation, input = {}) { + return dispatch({ schema: "gamecult.eve.plugin_abi.request.v1", pluginId: "fields.surface", operation, requestId: operation, input }); +} + +const document = { + schema: "gamecult.fields.surface2d.v1", + fieldId: "fixture.field", + bounds: { minX: -10, minY: -5, maxX: 10, maxY: 5 }, + channels: [ + { channelId: "height", kind: "scalar", documentId: "cultmesh://fixture/height", schemaId: "gamecult.fields.scalar.v1" }, + ], +}; + +test("projects provider-owned field documents without taking native projection authority", () => { + const result = request("project", { document }); + assert.equal(result.schema, "gamecult.fields.projection.v1"); + assert.equal(result.projectionKind, "semantic-field-surface"); + assert.equal(result.channels[0].documentId, "cultmesh://fixture/height"); + assert.equal(result.nativeProjectionAuthority, "runtime-adapter"); + assert.equal(result.providerStateMutated, false); +}); + +test("rejects components and schemas outside field ownership", () => { + const result = request("validate", { componentKinds: ["field.surface2d", "world.scene3d"], document }); + assert.deepEqual(result.acceptedComponentKinds, ["field.surface2d"]); + assert.deepEqual(result.rejectedComponentKinds, ["world.scene3d"]); + assert.equal(result.validDocument, true); + assert.equal(request("validate", { componentKinds: ["field.surface2d"], document: { ...document, schema: "provider.private.v1" } }).validDocument, false); +}); + +test("measures field domains without mutating provider state", () => { + const result = request("measure", { document }); + assert.equal(result.width, 20); + assert.equal(result.height, 10); + assert.equal(result.channelCount, 1); + assert.equal(result.preservesProviderAuthority, true); +}); diff --git a/scripts/run-tex-witness.mjs b/scripts/run-tex-witness.mjs index ebb9126..140d05b 100644 --- a/scripts/run-tex-witness.mjs +++ b/scripts/run-tex-witness.mjs @@ -8,8 +8,26 @@ import { decode, encode } from "@msgpack/msgpack"; import { invokeCultNetOperation } from "cultnet-ts"; const root = path.resolve(import.meta.dirname, ".."); -const fixture = JSON.parse(fs.readFileSync(path.join(root, "plugins/eve-plugin-tex/plugin-abi-fixture.json"), "utf8")); -const child = spawn(process.execPath, [path.join(root, "plugins/eve-plugin-tex/src/cultnet-sidecar.mjs")], { cwd: root, stdio: ["ignore", "pipe", "inherit"] }); +const target = process.argv[2] || "tex"; +const targets = { + tex: { + directory: "eve-plugin-tex", + artifactDirectory: "tex-math", + witnessId: "tex.math.owner-sidecar", + authority: "plugin-sidecar-owns-tex-semantics-provider-retains-source-and-command-acceptance", + }, + fields: { + directory: "eve-plugin-fields", + artifactDirectory: "fields-surface", + witnessId: "fields.surface.owner-sidecar", + authority: "plugin-sidecar-owns-field-semantics-provider-retains-field-state-and-runtime-retains-native-projection", + }, +}; +const config = targets[target]; +if (!config) throw new Error(`Unknown plugin witness target '${target}'.`); +const pluginRoot = path.join(root, "plugins", config.directory); +const fixture = JSON.parse(fs.readFileSync(path.join(pluginRoot, "plugin-abi-fixture.json"), "utf8")); +const child = spawn(process.execPath, [path.join(pluginRoot, "src/cultnet-sidecar.mjs")], { cwd: root, stdio: ["ignore", "pipe", "inherit"] }); const responses = readline.createInterface({ input: child.stdout })[Symbol.asyncIterator](); const endpointLine = await responses.next(); assert.equal(endpointLine.done, false); @@ -22,7 +40,7 @@ const operationWitnesses = []; try { for (let index = 0; index < fixture.operations.length; index += 1) { const operation = fixture.operations[index]; - const requestId = `tex-witness-${index}`; + const requestId = `${target}-witness-${index}`; const operationStartedAt = performance.now(); const abiRequest = { schema: fixture.requestSchema, pluginId: fixture.pluginId, operation: operation.operation, requestId, input: operation.input }; const envelope = await invokeCultNetOperation(endpoint, { @@ -54,23 +72,23 @@ try { child.kill(); } -const outputDir = path.join(root, "artifacts", "tex-math"); +const outputDir = path.join(root, "artifacts", config.artifactDirectory); fs.mkdirSync(outputDir, { recursive: true }); const witness = { schema: "gamecult.eve.plugin_witness.v1", - witnessId: "tex.math.owner-sidecar", - pluginId: "tex.math", + witnessId: config.witnessId, + pluginId: fixture.pluginId, ownerRepo: "EvePlugins", status: "pass", transport: "cultnet-operation-v0+rudp", generatedAtUtc: new Date().toISOString(), durationMs: Number((performance.now() - startedAt).toFixed(3)), operations: operationWitnesses, - advertisementPath: "plugins/eve-plugin-tex/advertisement.json", - fixturePath: "plugins/eve-plugin-tex/plugin-abi-fixture.json", - executable: { artifact: "plugins/eve-plugin-tex/src/cultnet-sidecar.mjs", command: "node" }, + advertisementPath: `plugins/${config.directory}/advertisement.json`, + fixturePath: `plugins/${config.directory}/plugin-abi-fixture.json`, + executable: { artifact: `plugins/${config.directory}/src/cultnet-sidecar.mjs`, command: "node" }, diagnostics: [], - authority: "plugin-sidecar-owns-tex-semantics-provider-retains-source-and-command-acceptance", + authority: config.authority, }; fs.writeFileSync(path.join(outputDir, "runtime-witness.json"), `${JSON.stringify(witness, null, 2)}\n`); console.log(path.join(outputDir, "runtime-witness.json"));