The probe returned `error: undefined` on a dimension match. `undefined` is not a JSON value -- JSON.stringify silently drops it from an object -- so the shape looked harmless and was not: the harness enforces the declared output schema against the canonical value before materialising it, and an explicit undefined property fails that check. A perfectly healthy status call could not be returned at all. Fixed at the boundary rather than at each site. Every return now funnels through `toJsonValue`, which drops undefined properties exactly as JSON.stringify would, maps non-finite numbers to null (same reasoning: JSON.stringify(NaN) is "null"), turns an undefined array slot into null rather than shifting indices, and refuses a circular value instead of recursing forever. `update` was carrying `errorCode: undefined` and `ensureSlopo` a `detail: undefined` on the same path, so a per-site fix would have left two more of these waiting. The probe also now omits `error` rather than assigning undefined, since it is a plausible thing for another consumer to read. Four regression tests, and they were checked against a negative control: with the sanitiser reverted and the undefined restored, exactly the "probe SUCCEEDS" test fails with "does not survive a JSON round trip" and the others still pass, which is what makes it a test of this bug rather than of the JSON round trip in general.
66 lines
2.2 KiB
JavaScript
66 lines
2.2 KiB
JavaScript
/**
|
|
* 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 `"<handle>"`.
|
|
*/
|
|
|
|
/**
|
|
* @param {unknown} value
|
|
* @param {Set<object>} [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)
|
|
}
|
|
}
|