Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output

This commit is contained in:
Tianyi Cui
2026-07-22 02:00:57 +08:00
8 changed files with 161 additions and 112 deletions

View File

@@ -50,79 +50,127 @@ function enumerableStringKeys(value: object): string[] | undefined {
return keys as string[]
}
type SnapshotDestination =
| { kind: 'root' }
| { kind: 'array'; target: JsonValue[]; index: number }
| { kind: 'object'; target: { [key: string]: JsonValue }; key: string }
type JsonWalkTask =
| { kind: 'visit'; value: unknown; destination?: SnapshotDestination }
| { kind: 'array-item'; source: unknown[]; index: number; target?: JsonValue[] }
| { kind: 'object-property'; source: Record<string, unknown>; key: string; target?: { [key: string]: JsonValue } }
| { kind: 'leave'; source: object }
/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */
function walkJsonValue(value: unknown, detach: boolean): JsonValue | true | undefined {
const ancestors = new Set<object>()
let root: JsonValue | undefined
const assign = (destination: SnapshotDestination | undefined, item: JsonValue): void => {
if (destination === undefined) return
if (destination.kind === 'root') {
root = item
} else if (destination.kind === 'array') {
destination.target[destination.index] = item
} else {
Object.defineProperty(destination.target, destination.key, {
value: item,
enumerable: true,
configurable: true,
writable: true,
})
}
}
const tasks: JsonWalkTask[] = [{
kind: 'visit',
value,
...(detach ? { destination: { kind: 'root' } as const } : {}),
}]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (task.kind === 'leave') {
ancestors.delete(task.source)
continue
}
if (task.kind === 'array-item') {
if (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return undefined
tasks.push({
kind: 'visit',
value: task.source[task.index],
...(task.target === undefined ? {} : { destination: { kind: 'array', target: task.target, index: task.index } as const }),
})
continue
}
if (task.kind === 'object-property') {
tasks.push({
kind: 'visit',
value: task.source[task.key],
...(task.target === undefined ? {} : { destination: { kind: 'object', target: task.target, key: task.key } as const }),
})
continue
}
const current = task.value
if (current === null) {
assign(task.destination, null)
continue
}
if (typeof current === 'boolean' || typeof current === 'string') {
assign(task.destination, current)
continue
}
if (typeof current === 'number') {
if (!Number.isFinite(current) || Object.is(current, -0)) return undefined
assign(task.destination, current)
continue
}
if (typeof current !== 'object') return undefined
if (ancestors.has(current)) return undefined
if (Array.isArray(current)) {
if (!hasPlainArrayPrototype(current)) return undefined
const length = current.length
if (Reflect.ownKeys(current).length !== length + 1) return undefined
const target = detach ? [] as JsonValue[] : undefined
if (target !== undefined) assign(task.destination, target)
ancestors.add(current)
tasks.push({ kind: 'leave', source: current })
for (let index = length - 1; index >= 0; index--) {
tasks.push({ kind: 'array-item', source: current, index, ...(target === undefined ? {} : { target }) })
}
continue
}
if (!hasPlainObjectPrototype(current)) return undefined
const keys = enumerableStringKeys(current)
if (keys === undefined) return undefined
const target = detach ? {} as { [key: string]: JsonValue } : undefined
if (target !== undefined) assign(task.destination, target)
ancestors.add(current)
tasks.push({ kind: 'leave', source: current })
for (let index = keys.length - 1; index >= 0; index--) {
const key = keys[index]
/* v8 ignore next -- the loop is bounded by the captured key count. */
if (key === undefined) return undefined
tasks.push({ kind: 'object-property', source: current as Record<string, unknown>, key, ...(target === undefined ? {} : { target }) })
}
}
return detach ? root : true
}
/**
* Validate and detach lossless JSON in one read per property, so a stateful
* getter cannot change between validation and copying. Accepts ordinary arrays,
* plain or null-prototype objects, and JSON scalars; rejects sparse, cyclic,
* exotic, negative-zero, and non-finite values. Getter throws propagate.
* getter cannot change between validation and copying. Traversal is iterative,
* so valid nesting is bounded by available memory rather than the JavaScript
* call stack. Accepts ordinary arrays, plain or null-prototype objects, and JSON
* scalars; rejects sparse, cyclic, exotic, negative-zero, and non-finite values.
* Getter throws propagate.
*
* @param value - the candidate value to validate and detach.
* @returns the detached snapshot, or `undefined` when the value is not
* losslessly JSON-serializable.
*/
export function snapshotJsonValue<T>(value: T): T | undefined {
const ancestors = new Set<object>()
const visit = (current: unknown): JsonValue | undefined => {
if (current === null) return null
switch (typeof current) {
case 'boolean':
case 'string':
return current
case 'number':
return Number.isFinite(current) && !Object.is(current, -0) ? current : undefined
case 'bigint':
case 'function':
case 'symbol':
case 'undefined':
return undefined
case 'object':
break
}
if (ancestors.has(current)) return undefined
ancestors.add(current)
try {
if (Array.isArray(current)) {
if (!hasPlainArrayPrototype(current)) return undefined
const length = current.length
// Every ordinary array owns `length`; dense indexed elements account
// for the remaining keys. Anything else would be lost by JSON and by
// structured clone, including symbols and non-enumerable properties.
if (Reflect.ownKeys(current).length !== length + 1) return undefined
const snapshot: JsonValue[] = []
for (let index = 0; index < length; index++) {
if (!Object.prototype.hasOwnProperty.call(current, index)) return undefined
const item = visit(current[index])
if (item === undefined) return undefined
snapshot.push(item)
}
return snapshot
}
if (!hasPlainObjectPrototype(current)) return undefined
const keys = enumerableStringKeys(current)
if (keys === undefined) return undefined
const snapshot: { [key: string]: JsonValue } = {}
for (const key of keys) {
const item = visit((current as Record<string, unknown>)[key])
if (item === undefined) return undefined
// Define the key as data so a JSON field literally named "__proto__"
// cannot mutate the snapshot's prototype through ordinary assignment.
Object.defineProperty(snapshot, key, {
value: item,
enumerable: true,
configurable: true,
writable: true,
})
}
return snapshot
} finally {
ancestors.delete(current)
}
}
return visit(value) as T | undefined
return walkJsonValue(value, true) as T | undefined
}
/**
@@ -130,46 +178,8 @@ export function snapshotJsonValue<T>(value: T): T | undefined {
* detaching it. Only own enumerable string properties participate; `toJSON`
* is ignored and getters run, so persistence boundaries use the snapshotter.
* @param value - the candidate event data to test.
* @param seen - current recursion path; callers omit it.
* @returns whether `value` survives JSON round-trip losslessly.
*/
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
if (value === null) return true
switch (typeof value) {
case 'boolean':
case 'string':
return true
case 'number':
return Number.isFinite(value) && !Object.is(value, -0)
case 'bigint':
case 'function':
case 'symbol':
case 'undefined':
return false
case 'object':
break // handled below
}
// object
if (seen.has(value)) return false // circular
seen.add(value)
try {
if (Array.isArray(value)) {
if (!hasPlainArrayPrototype(value)) return false
if (Reflect.ownKeys(value).length !== value.length + 1) return false
// Reject sparse arrays: a hole is skipped by `every`/`forEach` but
// JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
// lossily. Require every index 0..length-1 to be an OWN property.
for (let i = 0; i < value.length; i++) {
if (!Object.prototype.hasOwnProperty.call(value, i)) return false
if (!isJsonValue(value[i], seen)) return false
}
return true
}
// Plain object only (reject Map/Set/Date/class instances).
if (!hasPlainObjectPrototype(value)) return false
const keys = enumerableStringKeys(value)
return keys !== undefined && keys.every(key => isJsonValue((value as Record<string, unknown>)[key], seen))
} finally {
seen.delete(value)
}
export function isJsonValue(value: unknown): boolean {
return walkJsonValue(value, false) === true
}