fix(tools): make schema traversal stack-safe

This commit is contained in:
Tianyi Cui
2026-07-22 20:51:13 +08:00
parent c05b684d52
commit 34194f0cd5
15 changed files with 1143 additions and 344 deletions

View File

@@ -12,7 +12,7 @@ Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-cata
## Trust stance
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
The sandbox isolates globals but is not a security boundary. Node globals are absent or redirect to Cordis services such as `ctx.fs`, `ctx.web`, and `ctx.bash`, and writes to `globalThis` stay local, but host-realm helpers make escape possible. Mounted plugins receive a façade without framework internals, yet its allowed services affect the live runtime. Dynamic tool schemas and annotations cross the realm through iterative JSON cloning and schema normalization, so valid deep declarations are memory-bounded rather than call-stack-bounded. Treat this toolset like bash access; see the [design and trust stance](../../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md).
## Config

View File

@@ -57,45 +57,105 @@ function hasPlainArrayPrototype(value: unknown[]): boolean {
}
/* jscpd:ignore-end */
/** Where one cloned JSON value is installed. */
type CloneDestination =
| { kind: 'root' }
| { kind: 'array'; target: unknown[]; index: number }
| { kind: 'object'; target: Record<string, unknown>; key: string }
/** Deferred work for stack-safe cross-realm JSON cloning. */
type CloneTask =
| { kind: 'visit'; value: unknown; path: string; destination: CloneDestination }
| { kind: 'array-item'; source: unknown[]; index: number; path: string; target: unknown[] }
| { kind: 'leave'; source: object }
/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */
function cloneJson(value: unknown, path: string, seen = new Set<object>()): unknown {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
if (typeof value === 'number') {
if (Number.isFinite(value) && !Object.is(value, -0)) return value
throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
}
if (typeof value !== 'object') throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
if (seen.has(value)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
seen.add(value)
try {
if (Array.isArray(value)) {
if (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) {
throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
}
const output: unknown[] = []
for (let index = 0; index < value.length; index++) {
if (!Object.hasOwn(value, index)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
output.push(cloneJson(value[index], `${path}[${index}]`, seen))
}
return output
function cloneJson(value: unknown, path: string): unknown {
const ancestors = new Set<object>()
let root: unknown
const assign = (destination: CloneDestination, item: unknown): void => {
if (destination.kind === 'root') {
root = item
return
}
if (!isPlainRecord(value)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
if (Reflect.ownKeys(value).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) {
throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
if (destination.kind === 'array') {
destination.target[destination.index] = item
return
}
Object.defineProperty(destination.target, destination.key, {
value: item,
enumerable: true,
configurable: true,
writable: true,
})
}
const reject = (at: string): never => {
throw new Error(`harness.defineTool ${at} must be lossless JSON data`)
}
const tasks: CloneTask[] = [{ kind: 'visit', value, path, destination: { kind: 'root' } }]
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.hasOwn(task.source, task.index)) reject(task.path)
tasks.push({
kind: 'visit',
value: task.source[task.index],
path: `${task.path}[${task.index}]`,
destination: { kind: 'array', target: task.target, index: task.index },
})
continue
}
const current = task.value
if (current === null || typeof current === 'string' || typeof current === 'boolean') {
assign(task.destination, current)
continue
}
if (typeof current === 'number') {
if (!Number.isFinite(current) || Object.is(current, -0)) reject(task.path)
assign(task.destination, current)
continue
}
if (typeof current !== 'object' || ancestors.has(current)) reject(task.path)
if (Array.isArray(current)) {
if (!hasPlainArrayPrototype(current) || Reflect.ownKeys(current).length !== current.length + 1) reject(task.path)
const output: unknown[] = []
assign(task.destination, output)
ancestors.add(current)
tasks.push({ kind: 'leave', source: current })
for (let index = current.length - 1; index >= 0; index--) {
tasks.push({ kind: 'array-item', source: current, index, path: task.path, target: output })
}
continue
}
if (!isPlainRecord(current)) reject(task.path)
const record = current as Record<string, unknown>
if (Reflect.ownKeys(record).some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(record, key))) {
reject(task.path)
}
const output: Record<string, unknown> = {}
for (const [key, entry] of Object.entries(value)) {
Object.defineProperty(output, key, {
value: cloneJson(entry, `${path}.${key}`, seen),
enumerable: true,
configurable: true,
writable: true,
assign(task.destination, output)
ancestors.add(record)
tasks.push({ kind: 'leave', source: record })
const entries = Object.entries(record)
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index]
/* v8 ignore next -- the loop is bounded by the captured entry count. */
if (entry === undefined) continue
tasks.push({
kind: 'visit',
value: entry[1],
path: `${task.path}.${entry[0]}`,
destination: { kind: 'object', target: output, key: entry[0] },
})
}
return output
} finally {
seen.delete(value)
}
return root
}
/** Copy and realm-materialize the shared annotation vocabulary. */
@@ -160,111 +220,229 @@ function normalizeRequiredNames(value: unknown, properties: Record<string, unkno
return names
}
/** Normalize one implicit property map. */
/** Mutable holder used only while one normalized property-map root is unresolved. */
interface NormalizeRoot {
value?: Record<string, unknown>
}
/** Where a normalized value node is installed. */
type NormalizeValueDestination =
| { kind: 'property'; target: Record<string, unknown>; key: string }
| { kind: 'item'; target: Record<string, unknown> }
| { kind: 'one-of'; target: Record<string, unknown>[]; index: number }
/** Where a normalized property map is installed. */
type NormalizeMapDestination =
| { kind: 'root'; holder: NormalizeRoot }
| { kind: 'properties'; target: Record<string, unknown> }
/** Deferred work for stack-safe sandbox schema normalization. */
type NormalizeTask =
| {
kind: 'map'
entries: Record<string, unknown>
path: string
requiredNames: ReadonlySet<string>
raw: boolean
destination: NormalizeMapDestination
}
| {
kind: 'value'
value: unknown
path: string
forceRequired: boolean
raw: boolean
parameterProperty: boolean
destination: NormalizeValueDestination
}
| { kind: 'leave'; value: object }
/** Install one normalized node without `__proto__` assignment semantics. */
function assignNormalizedValue(destination: NormalizeValueDestination, value: Record<string, unknown>): void {
if (destination.kind === 'property') {
Object.defineProperty(destination.target, destination.key, {
value,
enumerable: true,
configurable: true,
writable: true,
})
} else if (destination.kind === 'item') {
destination.target.items = value
} else {
destination.target[destination.index] = value
}
}
/** Install one normalized property map at its root or containing object. */
function assignNormalizedMap(destination: NormalizeMapDestination, value: Record<string, unknown>): void {
if (destination.kind === 'root') destination.holder.value = value
else destination.target.properties = value
}
/** Normalize one implicit property map and all descendants with explicit work frames. */
function normalizePropertyMap(
entries: Record<string, unknown>,
path: string,
requiredNames: ReadonlySet<string>,
raw: boolean,
): Record<string, unknown> {
const spec: Record<string, unknown> = {}
for (const [key, prop] of Object.entries(entries)) {
Object.defineProperty(spec, key, {
value: normalizeValueSchema(prop, `${path}.${key}`, requiredNames.has(key), raw, true),
enumerable: true,
configurable: true,
writable: true,
})
}
return spec
}
/** Normalize one property or nested value schema into the host realm. */
function normalizeValueSchema(
value: unknown,
path: string,
forceRequired = false,
raw = false,
parameterProperty = false,
): Record<string, unknown> {
if (!isPlainRecord(value)) {
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`)
}
const requiredKey = parameterProperty && !raw ? ['required'] : []
if (parameterProperty && raw && Object.hasOwn(value, 'required') && value.type !== 'object') {
throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`)
}
if (parameterProperty && !raw && Object.hasOwn(value, 'required') && value.required !== true) {
throw new Error(`harness.defineTool ${path}.required must be true when present`)
}
const prop: Record<string, unknown> = {}
if (forceRequired || value.required === true) prop.required = true
copyAnnotations(value, prop, path)
if (Object.hasOwn(value, 'oneOf')) {
assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS])
if (!Array.isArray(value.oneOf)) throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`)
prop.oneOf = value.oneOf.map((branch, index) => normalizeValueSchema(branch, `${path}.oneOf[${index}]`, false, raw))
return prop
}
if (raw && !Object.hasOwn(value, 'type')) {
assertSchemaKeys(value, path, ANNOTATION_KEYS)
prop.type = 'json'
return prop
}
if (!SCHEMA_TYPES.has(value.type) || raw && value.type === 'json') {
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
}
const type = value.type
prop.type = type
switch (type) {
case 'object': {
assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(raw ? ['required'] : []), ...ANNOTATION_KEYS])
if (!raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) {
throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`)
}
if (raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') {
throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`)
}
if (raw && Object.hasOwn(value, 'required') && value.required === undefined) {
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
}
prop.additionalProperties = raw ? value.additionalProperties ?? true : value.additionalProperties
if (Object.hasOwn(value, 'properties')) {
if (!isPlainRecord(value.properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
const nestedRequired = raw ? normalizeRequiredNames(value.required, value.properties, `${path}.required`) : new Set<string>()
prop.properties = normalizePropertyMap(value.properties, `${path}.properties`, nestedRequired, raw)
} else if (raw && value.required !== undefined) {
normalizeRequiredNames(value.required, {}, `${path}.required`)
}
return prop
const holder: NormalizeRoot = {}
const ancestors = new Set<object>()
const tasks: NormalizeTask[] = [{
kind: 'map',
entries,
path,
requiredNames,
raw,
destination: { kind: 'root', holder },
}]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (task.kind === 'leave') {
ancestors.delete(task.value)
continue
}
case 'array':
assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'items')) prop.items = normalizeValueSchema(value.items, `${path}.items`, false, raw)
return prop
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null':
assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'enum')) {
prop.enum = Array.isArray(value.enum)
? value.enum.map((entry, index) => cloneJson(entry, `${path}.enum[${index}]`))
: value.enum
if (task.kind === 'map') {
if (ancestors.has(task.entries)) throw new Error(`harness.defineTool ${task.path} is circular`)
ancestors.add(task.entries)
const spec: Record<string, unknown> = {}
assignNormalizedMap(task.destination, spec)
tasks.push({ kind: 'leave', value: task.entries })
const mapEntries = Object.entries(task.entries)
for (let index = mapEntries.length - 1; index >= 0; index--) {
const entry = mapEntries[index]
/* v8 ignore next -- the loop is bounded by the captured entry count. */
if (entry === undefined) continue
tasks.push({
kind: 'value',
value: entry[1],
path: `${task.path}.${entry[0]}`,
forceRequired: task.requiredNames.has(entry[0]),
raw: task.raw,
parameterProperty: true,
destination: { kind: 'property', target: spec, key: entry[0] },
})
}
if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`)
return prop
case 'json':
assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS])
return prop
/* v8 ignore next 2 -- SCHEMA_TYPES narrows this closed switch before dispatch. */
default:
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES}`)
continue
}
const { value, path } = task
if (!isPlainRecord(value)) {
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`)
}
if (ancestors.has(value)) throw new Error(`harness.defineTool ${path} is circular`)
ancestors.add(value)
const requiredKey = task.parameterProperty && !task.raw ? ['required'] : []
if (task.parameterProperty && task.raw && Object.hasOwn(value, 'required') && value.type !== 'object') {
throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`)
}
if (task.parameterProperty && !task.raw && Object.hasOwn(value, 'required') && value.required !== true) {
throw new Error(`harness.defineTool ${path}.required must be true when present`)
}
const prop: Record<string, unknown> = {}
assignNormalizedValue(task.destination, prop)
tasks.push({ kind: 'leave', value })
if (task.forceRequired || value.required === true) prop.required = true
copyAnnotations(value, prop, path)
if (Object.hasOwn(value, 'oneOf')) {
assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS])
if (!Array.isArray(value.oneOf)) throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`)
const oneOf: Record<string, unknown>[] = []
prop.oneOf = oneOf
for (let index = value.oneOf.length - 1; index >= 0; index--) {
tasks.push({
kind: 'value',
value: value.oneOf[index],
path: `${path}.oneOf[${index}]`,
forceRequired: false,
raw: task.raw,
parameterProperty: false,
destination: { kind: 'one-of', target: oneOf, index },
})
}
continue
}
if (task.raw && !Object.hasOwn(value, 'type')) {
assertSchemaKeys(value, path, ANNOTATION_KEYS)
prop.type = 'json'
continue
}
if (!SCHEMA_TYPES.has(value.type) || task.raw && value.type === 'json') {
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
}
const type = value.type
prop.type = type
switch (type) {
case 'object': {
assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(task.raw ? ['required'] : []), ...ANNOTATION_KEYS])
if (!task.raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) {
throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`)
}
if (task.raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') {
throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`)
}
if (task.raw && Object.hasOwn(value, 'required') && value.required === undefined) {
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
}
prop.additionalProperties = task.raw ? value.additionalProperties ?? true : value.additionalProperties
if (Object.hasOwn(value, 'properties')) {
const properties = value.properties
if (!isPlainRecord(properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
const nestedRequired = task.raw
? normalizeRequiredNames(value.required, properties, `${path}.required`)
: new Set<string>()
tasks.push({
kind: 'map',
entries: properties,
path: `${path}.properties`,
requiredNames: nestedRequired,
raw: task.raw,
destination: { kind: 'properties', target: prop },
})
} else if (task.raw && value.required !== undefined) {
normalizeRequiredNames(value.required, {}, `${path}.required`)
}
break
}
case 'array':
assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'items')) {
tasks.push({
kind: 'value',
value: value.items,
path: `${path}.items`,
forceRequired: false,
raw: task.raw,
parameterProperty: false,
destination: { kind: 'item', target: prop },
})
}
break
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null':
assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'enum')) {
prop.enum = Array.isArray(value.enum)
? Array.from(value.enum, (entry, index) => cloneJson(entry, `${path}.enum[${index}]`))
: value.enum
}
if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`)
break
case 'json':
assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS])
break
/* v8 ignore next 2 -- SCHEMA_TYPES narrows this closed switch before dispatch. */
default:
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES}`)
}
}
/* v8 ignore next -- the root map task assigns before scheduling descendants. */
return holder.value ?? {}
}
function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {

View File

@@ -274,6 +274,59 @@ describe('cordis_mount', () => {
})
})
it('normalizes and snapshots deeply nested sandbox schemas and annotations stack-safely', async () => {
const ctx = await setup()
const depth = 5_000
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'deep-unified-schema',
inject: ['tools'],
apply(ctx) {
let choice = { type: 'string' }
let example = 'leaf'
for (let index = 0; index < ${depth}; index++) {
choice = { oneOf: [choice, { type: 'null' }] }
example = [example]
}
harness.registerTool(ctx, harness.defineTool({
name: 'deep_unified_schema_tool',
description: 'deep unified nodes',
parameters: {
choice: { ...choice, required: true },
any: { type: 'json', default: example },
},
async execute() { return [] },
}))
},
}
`,
})
expect(result.isError).toBe(false)
const parameters = ctx.tools.schemas().find(s => s.name === 'deep_unified_schema_tool')!.parameters as {
properties: Record<string, Record<string, unknown>>
}
let choice = parameters.properties.choice!
let choiceDepth = 0
while (Array.isArray(choice.oneOf)) {
choice = choice.oneOf[0] as Record<string, unknown>
choiceDepth++
}
let example: unknown = parameters.properties.any!.default
let exampleDepth = 0
while (Array.isArray(example)) {
example = example[0]
exampleDepth++
}
expect({ choiceDepth, choice, exampleDepth, example }).toEqual({
choiceDepth: depth,
choice: { type: 'string' },
exampleDepth: depth,
example: 'leaf',
})
})
it('normalizes unconstrained and closed nested nodes from a raw JSON Schema wrapper', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
@@ -372,6 +425,47 @@ describe('cordis_mount', () => {
expect(text(result)).toContain(message)
})
it.each([
[
`
const parameters = {}
const item = { type: 'array' }
item.items = item
parameters.item = item
`,
'parameters.item.items is circular',
],
[
`
const parameters = {}
const item = { type: 'object', additionalProperties: true, properties: parameters }
parameters.item = item
`,
'parameters.item.properties is circular',
],
])('rejects circular sandbox schemas without exhausting the call stack', async (declaration, message) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'circular-schema',
inject: ['tools'],
apply(ctx) {
${declaration}
harness.registerTool(ctx, harness.defineTool({
name: 'circular_schema_tool',
description: 'circular',
parameters,
async execute() { return [] },
}))
},
}
`,
})
expect(result.isError).toBe(true)
expect(text(result)).toContain(message)
})
it('preserves literal __proto__ keys in sandbox schemas and annotations', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {

View File

@@ -84,7 +84,7 @@ ctx.tools.register(defineTool({
}))
```
The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default.
The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default. Compilation, validation, registry detachment, and schema-to-TypeScript rendering use explicit work stacks, so valid deep schemas are memory-bounded rather than call-stack-bounded.
A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. The implicit parameter root is open; an explicit object accepts extra keys only with `additionalProperties: true`, and a closed object with no declared properties accepts only `{}`. Raw JSON Schema objects remain open unless they explicitly set `additionalProperties: false`. Defaults are not applied; open objects without `properties` and arrays without `items` receive only a container type check. Raw-registered tools own their validation.

View File

@@ -773,10 +773,14 @@ export class ToolRegistry extends Service {
/** Project one definition onto the model-facing schema fields. */
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
const { name, description, parameters } = definition
const detached = detachParameters ? snapshotJsonValue(parameters) : parameters
if (detached === undefined) {
throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`)
}
return {
name,
description,
parameters: detachParameters ? structuredClone(parameters) : parameters,
parameters: detached,
}
}

View File

@@ -116,18 +116,70 @@ function scalarMatches(type: JsonSchemaScalarType, value: unknown): value is Jso
}
}
/** Collect every violation for one raw schema node. */
function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set<object>): void {
if (!isPlainJsonRecord(node)) {
violations.push(`${path} must be a schema object`)
return
/** Deferred work for the stack-safe raw-schema walk. */
type SchemaWalkTask =
| { kind: 'enter'; node: unknown; path: string }
| { kind: 'leave'; node: object }
| { kind: 'one-of-tail'; node: Record<string, unknown>; path: string }
| { kind: 'object-tail'; node: Record<string, unknown>; path: string; properties: unknown }
/** Keywords that are invalid beside `oneOf`. */
const ONE_OF_SIBLING_KEYWORDS = ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const'] as const
/** Validate object-only fields after its property schemas have been visited. */
function checkObjectSchemaTail(
node: Record<string, unknown>,
path: string,
properties: unknown,
violations: string[],
): void {
const required = node.required
if (Object.hasOwn(node, 'required')) {
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
violations.push(`${path}.required must be an array of strings`)
} else {
const declared = isPlainJsonRecord(properties) ? properties : {}
for (const key of required as string[]) {
if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
}
}
}
if (seen.has(node)) {
violations.push(`${path} is circular`)
return
if (Object.hasOwn(node, 'additionalProperties') && typeof node.additionalProperties !== 'boolean') {
violations.push(`${path}.additionalProperties must be a boolean`)
}
seen.add(node)
try {
}
/** Collect every violation for one raw schema tree without using the JavaScript call stack. */
function checkSchemaNode(root: unknown, rootPath: string, violations: string[], seen: Set<object>): void {
const tasks: SchemaWalkTask[] = [{ kind: 'enter', node: root, path: rootPath }]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (task.kind === 'leave') {
seen.delete(task.node)
continue
}
if (task.kind === 'one-of-tail') {
for (const key of ONE_OF_SIBLING_KEYWORDS) {
if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`)
}
continue
}
if (task.kind === 'object-tail') {
checkObjectSchemaTail(task.node, task.path, task.properties, violations)
continue
}
const { node, path } = task
if (!isPlainJsonRecord(node)) {
violations.push(`${path} must be a schema object`)
continue
}
if (seen.has(node)) {
violations.push(`${path} is circular`)
continue
}
seen.add(node)
tasks.push({ kind: 'leave', node })
for (const key of Object.keys(node)) {
if (CONSTRAINT_KEYWORDS.has(key)) continue
if (ANNOTATION_KEYWORDS.has(key)) {
@@ -151,28 +203,26 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
const hasOneOf = Object.hasOwn(node, 'oneOf')
if (hasType && hasOneOf) {
violations.push(`${path} cannot declare both type and oneOf`)
return
continue
}
if (!hasType && !hasOneOf) {
for (const key of ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) {
for (const key of ONE_OF_SIBLING_KEYWORDS) {
if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`)
}
return
continue
}
if (hasOneOf) {
const oneOf = node.oneOf
tasks.push({ kind: 'one-of-tail', node, path })
if (!Array.isArray(oneOf) || oneOf.length < 2) {
violations.push(`${path}.oneOf must be an array of at least two schemas`)
} else {
for (let index = 0; index < oneOf.length; index++) {
checkSchemaNode(oneOf[index], `${path}.oneOf[${index}]`, violations, seen)
for (let index = oneOf.length - 1; index >= 0; index--) {
tasks.push({ kind: 'enter', node: oneOf[index], path: `${path}.oneOf[${index}]` })
}
}
for (const key of ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) {
if (Object.hasOwn(node, key)) violations.push(`${path}.${key} is not supported beside oneOf`)
}
return
continue
}
const type = node.type
@@ -180,7 +230,7 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
violations.push(Array.isArray(type)
? `${path}.type must be a single type string (type arrays are not supported)`
: `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
return
continue
}
const schemaType = type as JsonSchemaType
const allowedFor: Record<string, JsonSchemaType[]> = {
@@ -200,33 +250,24 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
switch (schemaType) {
case 'object': {
const properties = node.properties
tasks.push({ kind: 'object-tail', node, path, properties })
if (Object.hasOwn(node, 'properties')) {
if (!isPlainJsonRecord(properties)) {
violations.push(`${path}.properties must be an object of schemas`)
} else {
for (const [key, child] of Object.entries(properties)) {
checkSchemaNode(child, `${path}.properties.${key}`, violations, seen)
const entries = Object.entries(properties)
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index]
/* v8 ignore next -- the loop is bounded by the captured entry count. */
if (entry === undefined) continue
tasks.push({ kind: 'enter', node: entry[1], path: `${path}.properties.${entry[0]}` })
}
}
}
const required = node.required
if (Object.hasOwn(node, 'required')) {
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
violations.push(`${path}.required must be an array of strings`)
} else {
const declared = isPlainJsonRecord(properties) ? properties : {}
for (const key of required as string[]) {
if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
}
}
}
if (Object.hasOwn(node, 'additionalProperties') && typeof node.additionalProperties !== 'boolean') {
violations.push(`${path}.additionalProperties must be a boolean`)
}
break
}
case 'array': {
if (Object.hasOwn(node, 'items')) checkSchemaNode(node.items, `${path}.items`, violations, seen)
if (Object.hasOwn(node, 'items')) tasks.push({ kind: 'enter', node: node.items, path: `${path}.items` })
break
}
case 'string':
@@ -238,10 +279,8 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
const enumValid = Array.isArray(allowed)
&& allowed.length > 0
&& allowed.every(entry => scalarMatches(schemaType, entry))
if (Object.hasOwn(node, 'enum')) {
if (!enumValid) {
violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
}
if (Object.hasOwn(node, 'enum') && !enumValid) {
violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
}
const constValid = scalarMatches(schemaType, node.const)
if (Object.hasOwn(node, 'const')) {
@@ -256,8 +295,6 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */
default: assertNever(schemaType, 'JsonSchemaType')
}
} finally {
seen.delete(node)
}
}
@@ -308,81 +345,57 @@ function propertyPath(path: string, key: string): string {
return path === '' ? key : `${path}.${key}`
}
/** Contain hostile getters/proxies so validation remains total for arbitrary values. */
function checkValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
if (node.type !== undefined && !(SCHEMA_TYPES as readonly unknown[]).includes(node.type)) {
return checkValueUnchecked(node, value, path)
}
try {
return checkValueUnchecked(node, value, path)
} catch {
return [`"${diagnosticPath(path)}" must be a lossless JSON value`]
/** One child evaluation deferred by a container or exact-one union frame. */
interface ValueChild {
readonly node: JsonSchemaNode
readonly value: unknown
readonly path: string
}
/** Explicit call frame for stack-safe schema-value validation. */
interface ValueFrame {
readonly node: JsonSchemaNode
readonly value: unknown
readonly path: string
catches: boolean
phase: 'start' | 'children'
kind?: 'oneOf' | 'object' | 'array'
children: ValueChild[]
childIndex: number
violations: string[]
tailViolations: string[]
matches: number
}
/** The generic exception-containment diagnostic owned by one valid schema node. */
function losslessValueViolation(path: string): string[] {
return [`"${diagnosticPath(path)}" must be a lossless JSON value`]
}
/** Append diagnostics without spreading a potentially wide child result as call arguments. */
function appendViolations(target: string[], source: readonly string[]): void {
for (const violation of source) target.push(violation)
}
/** Initialize one validation frame with empty aggregation state. */
function valueFrame(node: JsonSchemaNode, value: unknown, path: string): ValueFrame {
return {
node,
value,
path,
catches: false,
phase: 'start',
children: [],
childIndex: 0,
violations: [],
tailViolations: [],
matches: 0,
}
}
/** Collect value violations for one trusted schema node after the exception boundary. */
function checkValueUnchecked(node: JsonSchemaNode, value: unknown, path: string): string[] {
if (node.oneOf !== undefined) {
const matches = node.oneOf.filter(branch => checkValue(branch, value, path).length === 0).length
return matches === 1 ? [] : [`"${diagnosticPath(path)}" must match exactly one oneOf branch (matched ${matches})`]
}
if (node.type === undefined) {
return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a lossless JSON value`]
}
switch (node.type) {
case 'object': {
if (!isPlainJsonRecord(value)) return [`"${diagnosticPath(path)}" must be an object`]
const violations: string[] = []
const properties = node.properties ?? {}
for (const key of node.required ?? []) {
if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${propertyPath(path, key)}"`)
}
for (const [key, child] of Object.entries(properties)) {
if (!Object.hasOwn(value, key) || value[key] === undefined) continue
violations.push(...checkValue(child, value[key], propertyPath(path, key)))
}
if (node.additionalProperties === false) {
for (const key of Object.keys(value)) {
if (!Object.hasOwn(properties, key)) violations.push(`"${propertyPath(path, key)}" is not a declared property (additionalProperties: false)`)
}
}
if (violations.length > 0) return violations
return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a lossless JSON object`]
}
case 'array': {
if (!Array.isArray(value)) return [`"${diagnosticPath(path)}" must be an array`]
const items = node.items
const violations = items === undefined
? []
: value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`))
if (violations.length > 0) return violations
return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a dense lossless JSON array`]
}
case 'string': {
if (typeof value !== 'string') return [`"${diagnosticPath(path)}" must be a string`]
break
}
case 'number': {
if (typeof value !== 'number') return [`"${diagnosticPath(path)}" must be a number`]
if (!isJsonNumber(value)) return [`"${diagnosticPath(path)}" must be a finite JSON number`]
break
}
case 'integer': {
if (!isJsonNumber(value) || !Number.isInteger(value)) return [`"${diagnosticPath(path)}" must be an integer`]
break
}
case 'boolean': {
if (typeof value !== 'boolean') return [`"${diagnosticPath(path)}" must be a boolean`]
break
}
case 'null': {
if (value !== null) return [`"${diagnosticPath(path)}" must be null`]
break
}
default: return assertNever(node.type, 'JsonSchemaType')
}
if (node.enum !== undefined && !node.enum.includes(value)) {
/** Validate one scalar node after its primitive type check. */
function checkScalarValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
if (node.enum !== undefined && !node.enum.includes(value as JsonSchemaScalar)) {
return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(node.enum)}`]
}
if (Object.hasOwn(node, 'const') && value !== node.const) {
@@ -391,6 +404,165 @@ function checkValueUnchecked(node: JsonSchemaNode, value: unknown, path: string)
return []
}
/** Validate one trusted schema/value pair with explicit frames rather than recursive calls. */
function checkValue(schema: JsonSchemaNode, value: unknown, path: string): string[] {
const frames: ValueFrame[] = [valueFrame(schema, value, path)]
let rootResult: string[] | undefined
const receive = (result: string[]): void => {
const parent = frames.at(-1)
if (parent === undefined) {
rootResult = result
return
}
if (parent.kind === 'oneOf') {
if (result.length === 0) parent.matches++
} else {
appendViolations(parent.violations, result)
}
}
const finish = (result: string[]): void => {
frames.pop()
receive(result)
}
while (frames.length > 0) {
const frame = frames.at(-1)
/* v8 ignore next -- the loop condition guarantees a current frame. */
if (frame === undefined) break
try {
if (frame.phase === 'children') {
if (frame.childIndex < frame.children.length) {
const child = frame.children[frame.childIndex]
/* v8 ignore next -- childIndex is bounded by children.length. */
if (child === undefined) throw new Error('missing schema-value child frame')
frame.childIndex++
frames.push(valueFrame(child.node, child.value, child.path))
continue
}
if (frame.kind === 'oneOf') {
finish(frame.matches === 1 ? [] : [`"${diagnosticPath(frame.path)}" must match exactly one oneOf branch (matched ${frame.matches})`])
continue
}
appendViolations(frame.violations, frame.tailViolations)
if (frame.violations.length > 0) {
finish(frame.violations)
} else if (frame.kind === 'object') {
finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a lossless JSON object`])
} else {
finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a dense lossless JSON array`])
}
continue
}
const nodeType = frame.node.type
frame.catches = !(nodeType !== undefined && !(SCHEMA_TYPES as readonly unknown[]).includes(nodeType))
const oneOf = frame.node.oneOf
if (oneOf !== undefined) {
frame.kind = 'oneOf'
frame.children = Array.from(oneOf, branch => ({ node: branch, value: frame.value, path: frame.path }))
frame.childIndex = 0
frame.matches = 0
frame.phase = 'children'
continue
}
if (nodeType === undefined) {
finish(safelyIsJsonValue(frame.value) ? [] : losslessValueViolation(frame.path))
continue
}
switch (nodeType) {
case 'object': {
if (!isPlainJsonRecord(frame.value)) {
finish([`"${diagnosticPath(frame.path)}" must be an object`])
break
}
const properties = frame.node.properties ?? {}
const violations: string[] = []
for (const key of frame.node.required ?? []) {
if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) {
violations.push(`missing required property "${propertyPath(frame.path, key)}"`)
}
}
const children: ValueChild[] = []
for (const [key, child] of Object.entries(properties)) {
if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) continue
children.push({ node: child, value: frame.value[key], path: propertyPath(frame.path, key) })
}
const tailViolations: string[] = []
if (frame.node.additionalProperties === false) {
for (const key of Object.keys(frame.value)) {
if (!Object.hasOwn(properties, key)) {
tailViolations.push(`"${propertyPath(frame.path, key)}" is not a declared property (additionalProperties: false)`)
}
}
}
frame.kind = 'object'
frame.children = children
frame.childIndex = 0
frame.violations = violations
frame.tailViolations = tailViolations
frame.phase = 'children'
break
}
case 'array': {
if (!Array.isArray(frame.value)) {
finish([`"${diagnosticPath(frame.path)}" must be an array`])
break
}
const items = frame.node.items
const children = items === undefined
? []
: frame.value.flatMap((entry, index): ValueChild[] => [{ node: items, value: entry, path: `${frame.path}[${index}]` }])
frame.kind = 'array'
frame.children = children
frame.childIndex = 0
frame.violations = []
frame.phase = 'children'
break
}
case 'string':
finish(typeof frame.value === 'string'
? checkScalarValue(frame.node, frame.value, frame.path)
: [`"${diagnosticPath(frame.path)}" must be a string`])
break
case 'number':
finish(typeof frame.value !== 'number'
? [`"${diagnosticPath(frame.path)}" must be a number`]
: !isJsonNumber(frame.value)
? [`"${diagnosticPath(frame.path)}" must be a finite JSON number`]
: checkScalarValue(frame.node, frame.value, frame.path))
break
case 'integer':
finish(!isJsonNumber(frame.value) || !Number.isInteger(frame.value)
? [`"${diagnosticPath(frame.path)}" must be an integer`]
: checkScalarValue(frame.node, frame.value, frame.path))
break
case 'boolean':
finish(typeof frame.value === 'boolean'
? checkScalarValue(frame.node, frame.value, frame.path)
: [`"${diagnosticPath(frame.path)}" must be a boolean`])
break
case 'null':
finish(frame.value === null
? checkScalarValue(frame.node, frame.value, frame.path)
: [`"${diagnosticPath(frame.path)}" must be null`])
break
default:
finish(assertNever(nodeType, 'JsonSchemaType'))
}
} catch (error) {
let failed = frames.pop()
while (failed !== undefined && !failed.catches) failed = frames.pop()
if (failed === undefined) throw error
receive(losslessValueViolation(failed.path))
}
}
/* v8 ignore next -- every root frame finishes or throws. */
return rootResult ?? losslessValueViolation(path)
}
/**
* Validate a candidate value against an asserted raw schema. The function is
* total for arbitrary values and returns path-qualified violations.

View File

@@ -180,66 +180,172 @@ function assertAuthorKeys(source: Record<string, unknown>, path: string, allowed
}
}
/** Compile one implicit property map, collecting per-property requiredness. */
function compilePropertyMap(
input: unknown,
path: string,
seen: Set<object>,
): { properties: Record<string, JsonSchemaNode>; required?: string[] } {
if (!isPlainJsonRecord(input)) authorError(`${path} must be an object of value schemas`)
if (seen.has(input)) authorError(`${path} is circular`)
seen.add(input)
try {
const properties: Record<string, JsonSchemaNode> = {}
const required: string[] = []
for (const [key, property] of Object.entries(input)) {
if (!isPlainJsonRecord(property)) authorError(`${path}.${key} must be a value schema object`)
if (Object.hasOwn(property, 'required') && property.required !== true) {
authorError(`${path}.${key}.required must be true when present`)
}
Object.defineProperty(properties, key, {
value: compileValueSchema(property, `${path}.${key}`, seen, true),
/** Compiled form of one implicit property map. */
interface CompiledPropertyMap {
properties: Record<string, JsonSchemaNode>
required?: string[]
}
/** Mutable holder used only while an iterative compilation root is unresolved. */
interface CompileRoot<T> {
value?: T
}
/** Where one compiled value node is installed. */
type NodeDestination =
| { kind: 'root'; holder: CompileRoot<JsonSchemaNode> }
| { kind: 'property'; target: Record<string, JsonSchemaNode>; key: string }
| { kind: 'item'; target: JsonSchemaNode }
| { kind: 'one-of'; target: JsonSchemaNode[]; index: number }
/** Where one compiled property map is installed. */
type PropertyMapDestination =
| { kind: 'root'; holder: CompileRoot<CompiledPropertyMap> }
| { kind: 'object'; target: JsonSchemaNode }
/** Deferred work for stack-safe author-schema compilation. */
type CompileTask =
| { kind: 'value'; input: unknown; path: string; allowRequired: boolean; destination: NodeDestination }
| { kind: 'property-map'; input: unknown; path: string; destination: PropertyMapDestination }
| {
kind: 'property'
property: unknown
path: string
key: string
properties: Record<string, JsonSchemaNode>
required: string[]
}
| {
kind: 'property-map-tail'
compiled: CompiledPropertyMap
required: string[]
destination: PropertyMapDestination
}
| { kind: 'leave'; input: object }
/** Install a compiled node without giving `__proto__` assignment semantics. */
function assignCompiledNode(destination: NodeDestination, node: JsonSchemaNode): void {
switch (destination.kind) {
case 'root':
destination.holder.value = node
break
case 'property':
Object.defineProperty(destination.target, destination.key, {
value: node,
enumerable: true,
configurable: true,
writable: true,
})
if (property.required === true) required.push(key)
}
return required.length > 0 ? { properties, required } : { properties }
} finally {
seen.delete(input)
break
case 'item':
destination.target.items = node
break
case 'one-of':
destination.target[destination.index] = node
break
}
}
/** Compile one author node without applying any consumer root restriction. */
function compileValueSchema(
input: unknown,
path: string,
seen: Set<object>,
allowRequired = false,
): JsonSchemaNode {
if (!isPlainJsonRecord(input)) authorError(`${path} must be a value schema object`)
if (seen.has(input)) authorError(`${path} is circular`)
seen.add(input)
try {
const authorKeys = [...ANNOTATION_KEYS, ...(allowRequired ? ['required'] : [])]
/** Install a compiled property map at its root or containing object node. */
function assignCompiledPropertyMap(destination: PropertyMapDestination, compiled: CompiledPropertyMap): void {
if (destination.kind === 'root') {
destination.holder.value = compiled
} else {
destination.target.properties = compiled.properties
}
}
/** Execute an author-schema compilation task graph without recursive descent. */
function runSchemaCompiler(initial: CompileTask): void {
const seen = new Set<object>()
const tasks: CompileTask[] = [initial]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (task.kind === 'leave') {
seen.delete(task.input)
continue
}
if (task.kind === 'property-map-tail') {
if (task.required.length > 0) {
task.compiled.required = task.required
if (task.destination.kind === 'object') task.destination.target.required = task.required
}
continue
}
if (task.kind === 'property') {
if (!isPlainJsonRecord(task.property)) authorError(`${task.path} must be a value schema object`)
if (Object.hasOwn(task.property, 'required') && task.property.required !== true) {
authorError(`${task.path}.required must be true when present`)
}
if (task.property.required === true) task.required.push(task.key)
tasks.push({
kind: 'value',
input: task.property,
path: task.path,
allowRequired: true,
destination: { kind: 'property', target: task.properties, key: task.key },
})
continue
}
if (task.kind === 'property-map') {
if (!isPlainJsonRecord(task.input)) authorError(`${task.path} must be an object of value schemas`)
if (seen.has(task.input)) authorError(`${task.path} is circular`)
seen.add(task.input)
const compiled: CompiledPropertyMap = { properties: {} }
const required: string[] = []
assignCompiledPropertyMap(task.destination, compiled)
tasks.push({ kind: 'leave', input: task.input })
tasks.push({ kind: 'property-map-tail', compiled, required, destination: task.destination })
const entries = Object.entries(task.input)
for (let index = entries.length - 1; index >= 0; index--) {
const entry = entries[index]
/* v8 ignore next -- the loop is bounded by the captured entry count. */
if (entry === undefined) continue
tasks.push({
kind: 'property',
property: entry[1],
path: `${task.path}.${entry[0]}`,
key: entry[0],
properties: compiled.properties,
required,
})
}
continue
}
const { input, path } = task
if (!isPlainJsonRecord(input)) authorError(`${path} must be a value schema object`)
if (seen.has(input)) authorError(`${path} is circular`)
seen.add(input)
const authorKeys = [...ANNOTATION_KEYS, ...(task.allowRequired ? ['required'] : [])]
const node: JsonSchemaNode = {}
assignCompiledNode(task.destination, node)
tasks.push({ kind: 'leave', input })
if (Object.hasOwn(input, 'oneOf')) {
assertAuthorKeys(input, path, [...authorKeys, 'oneOf', 'type'])
if (Object.hasOwn(input, 'type')) authorError(`${path} cannot declare both type and oneOf`)
if (!Array.isArray(input.oneOf)) authorError(`${path}.oneOf must be an array of at least two value schemas`)
node.oneOf = input.oneOf.map((branch, index) => compileValueSchema(branch, `${path}.oneOf[${index}]`, seen))
const branches: JsonSchemaNode[] = []
node.oneOf = branches
copyAnnotations(input, node)
return node
for (let index = input.oneOf.length - 1; index >= 0; index--) {
tasks.push({
kind: 'value',
input: input.oneOf[index],
path: `${path}.oneOf[${index}]`,
allowRequired: false,
destination: { kind: 'one-of', target: branches, index },
})
}
continue
}
switch (input.type) {
case 'json':
assertAuthorKeys(input, path, [...authorKeys, 'type'])
copyAnnotations(input, node)
return node
case 'object': {
break
case 'object':
assertAuthorKeys(input, path, [...authorKeys, 'type', 'properties', 'additionalProperties'])
if (!Object.hasOwn(input, 'additionalProperties') || typeof input.additionalProperties !== 'boolean') {
authorError(`${path}.additionalProperties must be explicitly true or false`)
@@ -248,18 +354,28 @@ function compileValueSchema(
copyAnnotations(input, node)
node.additionalProperties = input.additionalProperties
if (Object.hasOwn(input, 'properties')) {
const compiled = compilePropertyMap(input.properties, `${path}.properties`, seen)
node.properties = compiled.properties
if (compiled.required !== undefined) node.required = compiled.required
tasks.push({
kind: 'property-map',
input: input.properties,
path: `${path}.properties`,
destination: { kind: 'object', target: node },
})
}
return node
}
break
case 'array':
assertAuthorKeys(input, path, [...authorKeys, 'type', 'items'])
node.type = 'array'
copyAnnotations(input, node)
if (Object.hasOwn(input, 'items')) node.items = compileValueSchema(input.items, `${path}.items`, seen)
return node
if (Object.hasOwn(input, 'items')) {
tasks.push({
kind: 'value',
input: input.items,
path: `${path}.items`,
allowRequired: false,
destination: { kind: 'item', target: node },
})
}
break
case 'string':
case 'number':
case 'integer':
@@ -274,15 +390,29 @@ function compileValueSchema(
: input.enum as JsonSchemaScalar[]
}
if (Object.hasOwn(input, 'const')) node.const = input.const as JsonSchemaScalar
return node
break
default:
return authorError(`${path}.type must be string/number/integer/boolean/null/array/object/json, or use oneOf`)
authorError(`${path}.type must be string/number/integer/boolean/null/array/object/json, or use oneOf`)
}
} finally {
seen.delete(input)
}
}
/** Compile one implicit property map, collecting per-property requiredness. */
function compilePropertyMap(input: unknown, path: string): CompiledPropertyMap {
const holder: CompileRoot<CompiledPropertyMap> = {}
runSchemaCompiler({ kind: 'property-map', input, path, destination: { kind: 'root', holder } })
/* v8 ignore next -- the root task assigns before scheduling any descendants. */
return holder.value ?? authorError(`${path} did not compile`)
}
/** Compile one author node without applying any consumer root restriction. */
function compileValueSchema(input: unknown, path: string): JsonSchemaNode {
const holder: CompileRoot<JsonSchemaNode> = {}
runSchemaCompiler({ kind: 'value', input, path, allowRequired: false, destination: { kind: 'root', holder } })
/* v8 ignore next -- the root task assigns before scheduling any descendants. */
return holder.value ?? authorError(`${path} did not compile`)
}
/**
* Compile one author-facing value schema to the enforced raw JSON Schema
* subset. The author-only `json` node becomes an annotation-only schema.
@@ -290,7 +420,7 @@ function compileValueSchema(
* @returns The asserted raw schema projection.
*/
export function valueSchemaSpecToJsonSchema(spec: ValueSchemaSpec): JsonSchemaNode {
const schema = compileValueSchema(spec, 'schema', new Set())
const schema = compileValueSchema(spec, 'schema')
assertSupportedJsonSchema(schema)
return schema
}
@@ -301,7 +431,7 @@ export function valueSchemaSpecToJsonSchema(spec: ValueSchemaSpec): JsonSchemaNo
* @returns An object-rooted raw schema with no implicit-root openness override.
*/
export function parameterSchemaSpecToJsonSchema(spec: ParameterSchemaSpec): ParameterJsonSchema {
const compiled = compilePropertyMap(spec, 'parameters', new Set())
const compiled = compilePropertyMap(spec, 'parameters')
const schema: ParameterJsonSchema = {
type: 'object',
properties: compiled.properties,

View File

@@ -8,7 +8,7 @@
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertSupportedJsonSchema } from './json-schema.ts'
import type { JsonSchemaScalar } from './json-schema.ts'
import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts'
/** Property names that are valid bare TS identifiers; anything else is quoted. */
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
@@ -47,9 +47,181 @@ function renderConstrainedScalar(node: Record<string, unknown>, type: string): s
return broad
}
/** Parenthesize a union or object intersection before applying `[]`. */
function arrayItem(type: string): string {
return type.includes('|') || type.includes('&') ? `(${type})[]` : `${type}[]`
/** A composable type document that can be flattened without recursive string concatenation. */
interface TypeDocument {
readonly parts: readonly (string | TypeDocument)[]
readonly containsUnionOrIntersection: boolean
}
/** Build one document from captured parts while retaining the legacy array-parenthesization test. */
function typeDocumentFrom(parts: readonly (string | TypeDocument)[]): TypeDocument {
return {
parts,
containsUnionOrIntersection: parts.some(part => typeof part === 'string'
? part.includes('|') || part.includes('&')
: part.containsUnionOrIntersection),
}
}
/** Build a small document without an intermediate array at each call site. */
function typeDocument(...parts: (string | TypeDocument)[]): TypeDocument {
return typeDocumentFrom(parts)
}
/** Flatten a nested document with an explicit work stack. */
function flattenTypeDocument(document: TypeDocument): string {
const chunks: string[] = []
const tasks: (string | TypeDocument)[] = [document]
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
if (typeof task === 'string') {
chunks.push(task)
continue
}
for (let index = task.parts.length - 1; index >= 0; index--) {
const part = task.parts[index]
/* v8 ignore next -- the loop is bounded by the captured part count. */
if (part !== undefined) tasks.push(part)
}
}
return chunks.join('')
}
/** One explicit call frame for stack-safe schema-to-TypeScript rendering. */
interface SchemaRenderFrame {
readonly node: JsonSchemaNode
readonly indent: number
phase: 'start' | 'children'
kind?: 'oneOf' | 'array' | 'object'
children: { node: JsonSchemaNode; indent: number }[]
childIndex: number
childDocuments: TypeDocument[]
entries: [string, JsonSchemaNode][]
}
/** Initialize one schema-render frame with empty aggregation state. */
function schemaRenderFrame(node: JsonSchemaNode, indent: number): SchemaRenderFrame {
return { node, indent, phase: 'start', children: [], childIndex: 0, childDocuments: [], entries: [] }
}
/** Render an already asserted schema to a composable document. */
function renderSupportedSchema(schema: JsonSchemaNode, indent: number): TypeDocument {
const frames: SchemaRenderFrame[] = [schemaRenderFrame(schema, indent)]
let rootDocument: TypeDocument | undefined
const finish = (document: TypeDocument): void => {
frames.pop()
const parent = frames.at(-1)
if (parent === undefined) rootDocument = document
else parent.childDocuments.push(document)
}
while (frames.length > 0) {
const frame = frames.at(-1)
/* v8 ignore next -- the loop condition guarantees a current frame. */
if (frame === undefined) break
if (frame.phase === 'children') {
if (frame.childIndex < frame.children.length) {
const child = frame.children[frame.childIndex]
/* v8 ignore next -- childIndex is bounded by children.length. */
if (child === undefined) throw new Error('missing schema render child')
frame.childIndex++
frames.push(schemaRenderFrame(child.node, child.indent))
continue
}
if (frame.kind === 'oneOf') {
const parts: (string | TypeDocument)[] = []
for (let index = 0; index < frame.childDocuments.length; index++) {
if (index > 0) parts.push(' | ')
const child = frame.childDocuments[index]
/* v8 ignore next -- child documents correspond one-to-one with children. */
if (child !== undefined) parts.push(child)
}
finish(typeDocumentFrom(parts))
continue
}
if (frame.kind === 'array') {
const child = frame.childDocuments[0]
/* v8 ignore next -- array frames always schedule exactly one child. */
if (child === undefined) throw new Error('missing array item type')
finish(child.containsUnionOrIntersection
? typeDocument('(', child, ')[]')
: typeDocument(child, '[]'))
continue
}
const required = new Set(frame.node.required)
const parts: (string | TypeDocument)[] = ['{']
for (let index = 0; index < frame.entries.length; index++) {
const entry = frame.entries[index]
const child = frame.childDocuments[index]
/* v8 ignore next -- object entries and child documents have the same length. */
if (entry === undefined || child === undefined) throw new Error('missing object property type')
const [name, prop] = entry
for (const line of docLines(prop.description, frame.indent + 1)) parts.push('\n', line)
parts.push('\n', `${pad(frame.indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: `, child, ';')
}
parts.push('\n', `${pad(frame.indent)}}`)
const declared = typeDocumentFrom(parts)
finish(frame.node.additionalProperties === false
? declared
: typeDocument(declared, ' & Record<string, JsonValue>'))
continue
}
const node = frame.node
if (node.oneOf !== undefined) {
frame.kind = 'oneOf'
frame.children = Array.from(node.oneOf, child => ({ node: child, indent: frame.indent }))
frame.childIndex = 0
frame.childDocuments = []
frame.phase = 'children'
continue
}
if (node.type === undefined) {
finish(typeDocument('JsonValue'))
continue
}
switch (node.type) {
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null':
finish(typeDocument(renderConstrainedScalar(node as Record<string, unknown>, node.type)))
break
case 'array':
if (node.items === undefined) {
finish(typeDocument('JsonValue[]'))
} else {
frame.kind = 'array'
frame.children = [{ node: node.items, indent: frame.indent }]
frame.childIndex = 0
frame.childDocuments = []
frame.phase = 'children'
}
break
case 'object': {
const open = node.additionalProperties !== false
const entries = Object.entries(node.properties ?? {})
if (entries.length === 0) {
finish(typeDocument(open ? 'Record<string, JsonValue>' : 'Record<string, never>'))
} else {
frame.kind = 'object'
frame.entries = entries
frame.children = entries.map(([, child]) => ({ node: child, indent: frame.indent + 1 }))
frame.childIndex = 0
frame.childDocuments = []
frame.phase = 'children'
}
break
}
/* v8 ignore next -- assertSupportedJsonSchema narrowed this closed type union. */
default:
finish(typeDocument('unknown'))
}
}
/* v8 ignore next -- every root frame produces one document. */
return rootDocument ?? typeDocument('unknown')
}
/**
@@ -63,43 +235,10 @@ function arrayItem(type: string): string {
export function jsonSchemaToTs(schema: unknown, indent = 0): string {
try {
assertSupportedJsonSchema(schema)
return flattenTypeDocument(renderSupportedSchema(schema, indent))
} catch {
return 'unknown'
}
const node = schema as Record<string, unknown>
if (Object.hasOwn(node, 'oneOf')) {
return (node.oneOf as unknown[]).map(branch => jsonSchemaToTs(branch, indent)).join(' | ')
}
if (!Object.hasOwn(node, 'type')) return 'JsonValue'
switch (node.type) {
case 'string': return renderConstrainedScalar(node, 'string')
case 'number': return renderConstrainedScalar(node, 'number')
case 'integer': return renderConstrainedScalar(node, 'integer')
case 'boolean': return renderConstrainedScalar(node, 'boolean')
case 'null': return renderConstrainedScalar(node, 'null')
case 'array': {
return arrayItem(Object.hasOwn(node, 'items') ? jsonSchemaToTs(node.items, indent) : 'JsonValue')
}
case 'object': {
const properties = node.properties
const open = node.additionalProperties !== false
if (properties === undefined) return open ? 'Record<string, JsonValue>' : 'Record<string, never>'
const entries = Object.entries(properties as Record<string, unknown>)
if (entries.length === 0) return open ? 'Record<string, JsonValue>' : 'Record<string, never>'
const required = new Set(node.required as string[] | undefined)
const lines: string[] = ['{']
for (const [name, prop] of entries) {
const description = (prop as Record<string, unknown>).description
lines.push(...docLines(description, indent + 1))
lines.push(`${pad(indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: ${jsonSchemaToTs(prop, indent + 1)};`)
}
lines.push(`${pad(indent)}}`)
const declared = lines.join('\n')
return open ? `${declared} & Record<string, JsonValue>` : declared
}
/* v8 ignore next -- assertSupportedJsonSchema narrowed this closed type union. */
default: return 'unknown'
}
}
/** The fixed model-facing usage contract rendered above the declarations (see the Code Mode Agent Note's "What the model sees"). */

View File

@@ -214,6 +214,14 @@ describe('the enforced raw JSON Schema subset', () => {
.toEqual(['schema.properties.at must be a schema object'])
})
it('asserts deeply nested raw unions without using the JavaScript call stack', () => {
const depth = 5_000
let schema: JsonSchemaNode = { type: 'string' }
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
expect(() => { assertSupportedJsonSchema(schema) }).not.toThrow()
})
it('uses own-property semantics for required declarations', () => {
expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
.toEqual(['schema.required names "toString" which is not in properties'])
@@ -322,6 +330,17 @@ describe('validateJsonSchemaValue', () => {
expect(validateJsonSchemaValue(overlap, 1.5)).toEqual([])
})
it('validates deeply nested exact-one unions without using the JavaScript call stack', () => {
const depth = 5_000
let schema: JsonSchemaNode = { type: 'string' }
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
assertSupportedJsonSchema(schema)
expect(validateJsonSchemaValue(schema, 'leaf')).toEqual([])
expect(validateJsonSchemaValue(schema, 42))
.toEqual(['"value" must match exactly one oneOf branch (matched 0)'])
})
it('an unconstrained schema accepts only lossless JSON values', () => {
const anyJson = asserted({})
for (const value of [null, true, 1, 'x', [1], { x: null }]) {

View File

@@ -92,6 +92,23 @@ describe('the unified author schema DSL', () => {
expect(() => parameterSchemaSpecToJsonSchema(properties as ParameterSchemaSpec)).toThrow(/circular/)
})
it('compiles deeply nested author unions without using the JavaScript call stack', () => {
const depth = 5_000
let spec: unknown = { type: 'string' }
for (let index = 0; index < depth; index++) spec = { oneOf: [spec, { type: 'null' }] }
const compiled = valueSchemaSpecToJsonSchema(spec as ValueSchemaSpec)
let cursor = compiled
let layers = 0
while (cursor.oneOf !== undefined) {
cursor = cursor.oneOf[0]!
layers++
}
expect(layers).toBe(depth)
expect(cursor).toEqual({ type: 'string' })
})
it('preserves a property literally named __proto__ as schema data', () => {
const properties = Object.create(null) as ParameterSchemaSpec
properties.__proto__ = { type: 'string', required: true }

View File

@@ -8,7 +8,7 @@ import ToolRegistry, {
defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH,
type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolDispatchExecution, type ToolExecutionResult,
type JsonSchemaNode, type ToolDispatchExecution, type ToolExecutionResult,
} from '@deepseek-ai/dsh-tools'
const testToolSignal = new AbortController().signal
@@ -1289,6 +1289,41 @@ describe('ToolRegistry', () => {
}])
})
it('schemas() snapshots deeply nested parameters without using structured-clone recursion', async () => {
const ctx = await setup()
const depth = 5_000
let nested: JsonSchemaNode = { type: 'string' }
for (let index = 0; index < depth; index++) nested = { oneOf: [nested, { type: 'null' }] }
ctx.tools.register({
...echoTool,
name: 'deep-schema',
parameters: { type: 'object', properties: { nested } },
})
const projected = ctx.tools.schemas()[0]!.parameters as JsonSchemaNode
let cursor = projected.properties!.nested!
let layers = 0
while (cursor.oneOf !== undefined) {
cursor = cursor.oneOf[0]!
layers++
}
expect(layers).toBe(depth)
expect(cursor).toEqual({ type: 'string' })
})
it('rejects schema projection when a raw registration is not lossless JSON', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'lossy-schema',
parameters: { type: 'object', default: Number.NaN },
})
expect(() => ctx.tools.schemas())
.toThrow('tool "lossy-schema" parameters must be lossless JSON before schema projection')
})
it('rejects a non-positive or non-finite registration timeout', async () => {
const ctx = await setup()
expect(() => ctx.tools.register({ ...echoTool, name: 'zero-timeout', timeoutMs: 0 }))

View File

@@ -93,6 +93,17 @@ describe('jsonSchemaToTs', () => {
expect(rendered).not.toContain('tool-*/ over')
expect(rendered).toContain(String.raw`tool-*\/ over`)
})
it('renders deeply nested unions without using the JavaScript call stack', () => {
const depth = 5_000
let schema: unknown = { type: 'string' }
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
const rendered = jsonSchemaToTs(schema)
expect(rendered.startsWith('string | null')).toBe(true)
expect(rendered.length).toBe('string'.length + depth * ' | null'.length)
})
})
describe('renderToolsSdk', () => {