Files
dsh-slopo agent 46124b11aa Fix: status could not be serialised when the probe SUCCEEDED
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.
2026-09-22 13:08:22 +01:00

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)
}
}