/** * Coerce a value into something that is genuinely lossless JSON. * * `undefined` is the trap. It is not a JSON value: `JSON.stringify` silently * drops it from an object, so a result carrying `{ ok: true, error: undefined }` * looks harmless on the way out and is not — the harness enforces the declared * output schema against the canonical value *before* materialising it, and an * `undefined` property is not valid lossless JSON. The tool call fails with a * serialisation error the caller cannot act on. * * Building the value through one chokepoint is what makes that impossible to * reintroduce: any future branch may write `foo: undefined` and it is dropped, * exactly as `JSON.stringify` would have dropped it. * * Non-finite numbers become `null` for the same reason (`JSON.stringify(NaN)` * is `"null"`), and a circular reference throws rather than recursing forever. * Handles are not JSON values at all, so they are converted to `""`. */ /** * @param {unknown} value * @param {Set} [seen] * @returns {unknown} a value that survives JSON round-tripping unchanged */ export function toJsonValue(value, seen = new Set()) { if (value === null) return null switch (typeof value) { case 'string': case 'boolean': return value case 'number': return Number.isFinite(value) ? value : null case 'bigint': return value.toString() case 'undefined': case 'function': case 'symbol': return undefined case 'object': break default: return undefined } if (seen.has(value)) throw new TypeError('cannot serialise a circular value') seen.add(value) try { if (Array.isArray(value)) { // An array slot cannot be dropped the way an object key can, so an // unserialisable element becomes null rather than shifting the indices. return value.map((item) => { const converted = toJsonValue(item, seen) return converted === undefined ? null : converted }) } const output = {} for (const [key, entry] of Object.entries(value)) { const converted = toJsonValue(entry, seen) if (converted !== undefined) output[key] = converted } return output } finally { seen.delete(value) } }