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

# Conflicts:
#	docs/core-data-structures/tools.md
#	packages/core/tools/src/schema.ts
This commit is contained in:
Tianyi Cui
2026-07-23 01:08:18 +08:00
13 changed files with 336 additions and 101 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-20-unified-json-value-schema-dsl.md: e735472ee0ac6a696462aa7598d57fce04aa9c3e
2026-07-20-unified-json-value-schema-dsl.zh.md: dd34817a6d047b14346b35ba6c4bce0290ad7feb
2026-07-20-unified-json-value-schema-dsl.md: 3e35bce6eb48afeb31c564e9b5d7b84ff91a7b1f
2026-07-20-unified-json-value-schema-dsl.zh.md: 77a20d17aab61e759de6e490510b14b5bc408726
@@ -12,7 +12,9 @@ Tool parameters used a small author DSL while subagent/workflow structured outpu
`dsh-tools` owns one JSON-value schema vocabulary with two representations. `ValueSchemaSpec` is the author form for any JSON root; `ParameterSchemaSpec` is its implicit object-property-map form with per-property `required: true`. `JsonSchemaNode` is the raw wire form. Both support string, finite number, integer, boolean, null, array, object, type-correct scalar `enum`/`const`, and exact-one `oneOf`; `{ type: 'json' }` is author-only sugar for an annotation-only unconstrained raw node.
An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. `InferValue<S>` and `InferArgs<P>` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values. Intrinsic plain Object and Array containers remain plain across JavaScript realms; subclasses remain exotic. Author compilation, raw-schema assertion, value validation, schema-to-TypeScript rendering, registry detachment, and dynamic Cordis cross-realm normalization and cloning use explicit work stacks, so valid nesting is limited by available memory rather than the JavaScript call stack.
An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. Schema records contain only own enumerable string keys, schema arrays are dense intrinsic arrays, and supported keywords are read as own properties; custom prototypes, inherited constraints, symbols, and JSON-invisible decorations therefore cannot make compilation, projection, and validation observe different declarations. Intrinsic plain Object and Array containers remain plain across JavaScript realms, while subclasses and forged constructor prototypes remain exotic.
`InferValue<S>` and `InferArgs<P>` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. Exact inference is bounded to 16 container levels and then uses `JsonValue`, preventing TypeScript's type-instantiation stack from becoming the authoring limit. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values. Author compilation, raw-schema assertion, value validation, schema-to-TypeScript rendering, registry detachment, and dynamic Cordis cross-realm normalization and cloning use explicit work stacks, so runtime nesting is limited by available memory rather than the JavaScript call stack.
Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent and workflow caller-defined structured outputs use `assertObjectJsonSchema()` and `ObjectJsonSchema`; tool outputs may use any root. Dynamic Cordis registrations rebuild realm-foreign schemas into host-owned JSON, preserve raw-wrapper openness, and require direct-DSL object openness before calling the same compiler.
@@ -28,5 +30,6 @@ Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent
- Parameter validation, output validation, schema-to-TypeScript generation, subagent/workflow guards, and dynamic registration share one enforced vocabulary.
- Output declarations can infer object, array, scalar, or null roots; subagent/workflow structured outputs remain object-rooted at their existing seams.
- Explicit object openness and type-correct literal constraints make malformed declarations fail during authoring or registration rather than during a later model call.
- Bounded type inference retains useful exact types for ordinary declarations and degrades unusually deep tails to `JsonValue`; runtime schema enforcement remains exact at every depth.
- Raw tools may still register broader JSON Schema directly, but unified code generation treats unsupported schemas as unknown instead of pretending to enforce them.
- Runtime and compile-time tests cover every root, exact-one overlap/no-match behavior, raw open defaults, explicit openness, lossy JSON values, inference, and deep nesting across core and dynamic projections.
@@ -12,7 +12,9 @@ Status: implemented
`dsh-tools` 以两种表示形式统一管理一套 JSON 值 schema 词汇。`ValueSchemaSpec` 是可描述任意 JSON 根类型的作者侧形式;`ParameterSchemaSpec` 是其隐式对象属性映射形式,每个属性可标记 `required: true``JsonSchemaNode` 是原始协议表示。两种形式都支持字符串、有限数值、整数、布尔值、null、数组、对象、类型正确的标量 `enum``const`,以及要求恰好匹配一个分支的 `oneOf``{ type: 'json' }` 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。
显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue<S>``InferArgs<P>` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()``parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器;其子类仍视为非普通对象。作者侧 schema 编译、原始 schema 断言、值校验、schema 到 TypeScript 的渲染、注册表数据分离,以及动态 Cordis 的跨运行域规范化与克隆均使用显式工作栈,因此合法嵌套的深度上限由可用内存决定,而非 JavaScript 调用栈
显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。schema 记录只能包含自有且可枚举的字符串键,schema 数组必须是稠密的内建数组,系统只从自有属性读取受支持的关键字;因此,自定义原型、继承的约束、symbol 和 JSON 不可见的附加内容都无法让编译、投影和校验观察到不同的声明。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器,而子类和伪造构造函数的原型仍视为非普通对象
`InferValue<S>``InferArgs<P>` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()``parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。精确类型推导以 16 层容器为界,超过后使用 `JsonValue`,从而避免 TypeScript 的类型实例化栈限制作者能声明的嵌套深度。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。作者侧 schema 编译、原始 schema 断言、值校验、schema 到 TypeScript 的渲染、注册表数据分离,以及动态 Cordis 的跨运行域规范化与克隆均使用显式工作栈,因此运行时嵌套只受可用内存限制,不受 JavaScript 调用栈限制。
对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()``ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。
@@ -28,5 +30,6 @@ Status: implemented
- 参数校验、输出校验、schema 到 TypeScript 的代码生成、subagent/工作流门禁和动态注册共用一套强制执行的词汇。
- 输出声明可以推导对象、数组、标量或 null 根类型;subagent/工作流的结构化输出仍在其现有服务边界保持对象根限制。
- 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。
- 有界类型推导会为常规声明保留有用的精确类型,并将异常深的尾部结构退化为 `JsonValue`;运行时 schema 强制执行在任意深度仍保持精确。
- 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。
- 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导,以及核心投影和动态投影中的深层嵌套。
+10 -18
View File
@@ -111,36 +111,28 @@ type ParameterPropertySpec = ValueSchemaSpec & { required?: true }
* Tool parameter schema. The map itself is an implicit open object root;
* requiredness remains a per-property `required: true` annotation.
*/
type ParameterSchemaSpec = Record<string, ParameterPropertySpec>
type ParameterSchemaSpec = {
[key: string]: ParameterPropertySpec
[key: symbol]: never
}
```
`{ type: 'json' }` infers `JsonValue` and compiles to an annotation-only unconstrained raw schema. Output roots can be objects, arrays, scalars, or null. `InferValue<S>` honors literal constraints and object openness; `InferArgs<P>` turns per-property requiredness into required and optional keys:
`{ type: 'json' }` infers `JsonValue` and compiles to an annotation-only unconstrained raw schema. Output roots can be objects, arrays, scalars, or null. `InferValue<S>` honors literal constraints and object openness through 16 container levels, then falls back to `JsonValue` instead of exhausting TypeScript's type-instantiation stack. `InferArgs<P>` turns per-property requiredness into required and optional string keys:
```ts type-equiv
/**
* Infer the TypeScript value accepted by an author-facing value schema.
* Output schemas may therefore infer object, array, scalar, or null roots.
* Infer the TypeScript value accepted by an author-facing value schema. Exact
* inference is bounded to 16 container levels, then falls back to `JsonValue`.
*/
type InferValue<S extends ValueSchemaSpec, D extends readonly unknown[] = readonly []> =
D['length'] extends 12 ? JsonValue :
S extends StringValueSchemaSpec ? InferScalar<S, string> :
S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar<S, number> :
S extends BooleanValueSchemaSpec ? InferScalar<S, boolean> :
S extends NullValueSchemaSpec ? null :
S extends ArrayValueSchemaSpec
? S extends { items: infer I extends ValueSchemaSpec } ? InferValue<I, NextDepth<D>>[] : JsonValue[]
: S extends ObjectValueSchemaSpec ? InferObject<S, NextDepth<D>> :
S extends JsonValueSchemaSpec ? JsonValue :
S extends OneOfValueSchemaSpec ? InferValue<S['oneOf'][number], NextDepth<D>> :
never
type InferValue<S> = InferValueAt<S, []>
```
```ts type-equiv
/** Infer the TypeScript argument object for an implicit parameter schema. */
type InferArgs<S extends ParameterSchemaSpec> = InferProperties<S, readonly []>
type InferArgs<S> = InferProperties<S, []>
```
`defineTool({ name, description, parameters, output, execute, … })` ties parameter inference to `parameterSchemaSpecToJsonSchema()` and `validateArgs()`, and ties `execute`/`render`/`presentationMeta` to `InferValue<OutputSchema>`. Inference widens to `JsonValue` after twelve nested nodes so large schemas remain compilable; runtime validation keeps walking the complete schema. `valueSchemaSpecToJsonSchema()` compiles output declarations through the same enforced raw subset. A parameter mismatch throws `ToolArgsError` (`INVALID_ARGS`); an invalid body or post-policy value throws `ToolOutputError` (`INVALID_TOOL_OUTPUT`). Both use the normal tool-error path. Raw JSON Schema remains open by default; unsupported keywords reject instead of being accepted without enforcement.
`defineTool({ name, description, parameters, output, execute, … })` ties parameter inference to `parameterSchemaSpecToJsonSchema()` and `validateArgs()`, and ties `execute`/`render`/`presentationMeta` to `InferValue<OutputSchema>`. Schema records contain only own enumerable string keys, and schema arrays are dense intrinsic arrays, so inference, compilation, and validation observe the same declaration. Inference stays exact through 16 container levels and then widens to `JsonValue`; runtime validation keeps walking the complete schema. `valueSchemaSpecToJsonSchema()` compiles output declarations through the same enforced raw subset. A parameter mismatch throws `ToolArgsError` (`INVALID_ARGS`); an invalid body or post-policy value throws `ToolOutputError` (`INVALID_TOOL_OUTPUT`). Both use the normal tool-error path. Raw JSON Schema remains open by default; unsupported keywords reject instead of being accepted without enforcement.
Registration is a trusted same-process contract. The registry borrows the typed definition as readonly input, requires `output`, validates its raw schema, and checks semantic requirements such as a positive finite `timeoutMs`; `schemas()` materializes the explicit model-facing projection at the model boundary so execution and presentation share one resolved definition without leaking callbacks onto the wire.
+9 -4
View File
@@ -38,13 +38,18 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
}
/* jscpd:ignore-start -- this VM boundary mirrors the session-owned realm-safe intrinsic test */
/** Whether a realm-owned intrinsic prototype names and points back to its constructor. */
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
const constructor: unknown = descriptor?.value
return typeof constructor === 'function'
&& constructor.name === name
&& constructor.prototype === prototype
if (typeof constructor !== 'function') return false
try {
return constructor.name === name
&& constructor.prototype === prototype
&& Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
} catch {
return false
}
}
/** Whether an array uses one realm's intrinsic Array prototype rather than a subclass. */
@@ -452,6 +452,9 @@ describe('cordis_mount', () => {
['parameters: { value: { type: \'json\', default: new (class DefaultValue { constructor() { this.ok = true } })() } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: new (class DefaultList extends Array {})() } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: new Date(0) } }', 'parameters.value.default must be lossless JSON data'],
['parameters: (() => { const p = Object.create(null); const C = function C() {}; Object.defineProperty(C, \'name\', { value: \'Object\' }); C.prototype = p; Object.defineProperty(p, \'constructor\', { value: C }); return Object.create(p) })()', 'must be a ParameterSchemaSpec object'],
['parameters: (() => { const p = Object.create(null); const C = function C() {}; Object.defineProperty(C, \'name\', { value: \'Object\' }); C.prototype = p; const r = Proxy.revocable(C, {}); Object.defineProperty(p, \'constructor\', { value: r.proxy }); r.revoke(); return Object.create(p) })()', 'must be a ParameterSchemaSpec object'],
['parameters: Object.create(Object.create(null))', 'must be a ParameterSchemaSpec object'],
])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
+9 -4
View File
@@ -12,13 +12,18 @@
*/
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
/** Whether a realm-owned intrinsic prototype names and points back to its constructor. */
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
const constructor: unknown = descriptor?.value
return typeof constructor === 'function'
&& constructor.name === name
&& constructor.prototype === prototype
if (typeof constructor !== 'function') return false
try {
return constructor.name === name
&& constructor.prototype === prototype
&& Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
} catch {
return false
}
}
/** Whether a candidate is one realm's intrinsic `Object.prototype`. */
+19
View File
@@ -2,6 +2,17 @@ import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { isJsonValue, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
function objectWithForgedIntrinsicPrototype(revoked = false): Record<string, unknown> {
const prototype = Object.create(null) as Record<string, unknown>
const ForgedObject = function ForgedObject(): void {}
Object.defineProperty(ForgedObject, 'name', { value: 'Object' })
ForgedObject.prototype = prototype
const constructor = revoked ? Proxy.revocable(ForgedObject, {}) : undefined
if (constructor !== undefined) constructor.revoke()
Object.defineProperty(prototype, 'constructor', { value: constructor?.proxy ?? ForgedObject })
return Object.assign(Object.create(prototype) as Record<string, unknown>, { value: 1 })
}
describe('snapshotJsonValue', () => {
it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => {
const unsupportedFunction = (): void => {}
@@ -109,6 +120,8 @@ describe('snapshotJsonValue', () => {
const symbolObject = { [Symbol('extra')]: true }
const customPrototype = Object.create(null) as Record<string, unknown>
const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record<string, unknown>, { value: 1 })
const forgedIntrinsicObject = objectWithForgedIntrinsicPrototype()
const revokedIntrinsicObject = objectWithForgedIntrinsicPrototype(true)
const forgedPrototype: unknown[] = []
Object.setPrototypeOf(forgedPrototype, null)
const forgedArray = [1]
@@ -133,6 +146,8 @@ describe('snapshotJsonValue', () => {
expect(snapshotJsonValue(hiddenObject)).toBeUndefined()
expect(snapshotJsonValue(symbolObject)).toBeUndefined()
expect(snapshotJsonValue(customPrototypeObject)).toBeUndefined()
expect(snapshotJsonValue(forgedIntrinsicObject)).toBeUndefined()
expect(snapshotJsonValue(revokedIntrinsicObject)).toBeUndefined()
expect(snapshotJsonValue(forgedArray)).toBeUndefined()
expect(snapshotJsonValue(cyclic)).toBeUndefined()
expect(snapshotJsonValue([undefined])).toBeUndefined()
@@ -206,6 +221,8 @@ describe('isJsonValue', () => {
const symbolObject = { [Symbol('extra')]: true }
const customPrototype = Object.create(null) as Record<string, unknown>
const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record<string, unknown>, { value: 1 })
const forgedIntrinsicObject = objectWithForgedIntrinsicPrototype()
const revokedIntrinsicObject = objectWithForgedIntrinsicPrototype(true)
const forgedPrototype: unknown[] = []
Object.setPrototypeOf(forgedPrototype, null)
const forgedArray = [1]
@@ -220,6 +237,8 @@ describe('isJsonValue', () => {
expect(isJsonValue(hiddenObject)).toBe(false)
expect(isJsonValue(symbolObject)).toBe(false)
expect(isJsonValue(customPrototypeObject)).toBe(false)
expect(isJsonValue(forgedIntrinsicObject)).toBe(false)
expect(isJsonValue(revokedIntrinsicObject)).toBe(false)
expect(isJsonValue(forgedArray)).toBe(false)
expect(isJsonValue(new ExoticArray(1))).toBe(false)
expect(isJsonValue([undefined])).toBe(false)
+1 -1
View File
@@ -87,7 +87,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. 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.
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. Schema records accept only own enumerable string keys, and schema arrays must be dense ordinary arrays. Compilation, validation, registry detachment, and schema-to-TypeScript rendering use explicit work stacks, so runtime processing of valid deep schemas is memory-bounded rather than call-stack-bounded; `InferValue` preserves exact types through 16 container levels and then falls back to `JsonValue` so TypeScript itself remains stack-safe.
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. It also infers the body return and pure output projectors from `output.schema`; the registry snapshots and validates the returned lossless JSON before presentation. 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 input validation but still declare and receive registry-enforced output.
+107 -27
View File
@@ -86,6 +86,26 @@ const CONSTRAINT_KEYWORDS = new Set([
const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
const SCHEMA_TYPES: readonly JsonSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
/* jscpd:ignore-start -- this realm boundary mirrors the session-owned lossless-JSON intrinsic test */
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
const constructor: unknown = descriptor?.value
if (typeof constructor !== 'function') return false
try {
return constructor.name === name
&& constructor.prototype === prototype
&& Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
} catch {
return false
}
}
/** Whether a candidate is one realm's intrinsic `Object.prototype`. */
function isIntrinsicObjectPrototype(value: object): boolean {
return Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, 'Object')
}
/**
* Test for a realm-agnostic plain JSON record without accepting arrays or
* exotic objects.
@@ -94,8 +114,61 @@ const SCHEMA_TYPES: readonly JsonSchemaType[] = ['object', 'array', 'string', 'n
*/
export function isPlainJsonRecord(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const proto: unknown = Object.getPrototypeOf(value)
return proto === null || Object.getPrototypeOf(proto) === null
try {
const prototype: unknown = Object.getPrototypeOf(value)
return prototype === null
|| typeof prototype === 'object' && isIntrinsicObjectPrototype(prototype)
} catch {
return false
}
}
/** Whether an array uses one realm's intrinsic `Array.prototype`. */
function hasPlainArrayPrototype(value: unknown[]): boolean {
const prototype: unknown = Object.getPrototypeOf(value)
if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
const objectPrototype: unknown = Object.getPrototypeOf(prototype)
return typeof objectPrototype === 'object'
&& objectPrototype !== null
&& isIntrinsicObjectPrototype(objectPrototype)
}
/* jscpd:ignore-end */
/** Return whether a record contains only own enumerable string keys. */
function hasOnlyEnumerableStringKeys(value: object): boolean {
try {
return Reflect.ownKeys(value)
.every(key => typeof key === 'string' && Object.prototype.propertyIsEnumerable.call(value, key))
} catch {
return false
}
}
/**
* Test for an ordinary schema record whose keys survive JSON projection.
* @param value - candidate record from any JavaScript realm.
* @returns Whether the record has an intrinsic prototype and only own enumerable string keys.
*/
export function isJsonSchemaRecord(value: unknown): value is Record<string, unknown> {
return isPlainJsonRecord(value) && hasOnlyEnumerableStringKeys(value)
}
/**
* Test for a dense ordinary array with no JSON-invisible decorations.
* @param value - candidate array from any JavaScript realm.
* @returns Whether the array is intrinsic, dense, and undecorated.
*/
export function isPlainJsonArray(value: unknown): value is unknown[] {
if (!Array.isArray(value)) return false
try {
if (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) return false
for (let index = 0; index < value.length; index++) {
if (!Object.hasOwn(value, index)) return false
}
return true
} catch {
return false
}
}
/** Lossless finite JSON number, excluding negative zero. */
@@ -133,12 +206,13 @@ function checkObjectSchemaTail(
properties: unknown,
violations: string[],
): void {
const required = node.required
if (Object.hasOwn(node, 'required')) {
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
const hasRequired = Object.hasOwn(node, 'required')
const required = hasRequired ? node.required : undefined
if (hasRequired) {
if (!isPlainJsonArray(required) || required.some(entry => typeof entry !== 'string')) {
violations.push(`${path}.required must be an array of strings`)
} else {
const declared = isPlainJsonRecord(properties) ? properties : {}
const declared = isJsonSchemaRecord(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`)
}
@@ -169,7 +243,7 @@ function checkSchemaNode(root: unknown, rootPath: string, violations: string[],
}
const { node, path } = task
if (!isPlainJsonRecord(node)) {
if (!isJsonSchemaRecord(node)) {
violations.push(`${path} must be a schema object`)
continue
}
@@ -192,10 +266,10 @@ function checkSchemaNode(root: unknown, rootPath: string, violations: string[],
}
violations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`)
}
if (node.description !== undefined && typeof node.description !== 'string') {
if (Object.hasOwn(node, 'description') && typeof node.description !== 'string') {
violations.push(`${path}.description must be a string`)
}
if (node.title !== undefined && typeof node.title !== 'string') {
if (Object.hasOwn(node, 'title') && typeof node.title !== 'string') {
violations.push(`${path}.title must be a string`)
}
@@ -215,7 +289,7 @@ function checkSchemaNode(root: unknown, rootPath: string, violations: string[],
if (hasOneOf) {
const oneOf = node.oneOf
tasks.push({ kind: 'one-of-tail', node, path })
if (!Array.isArray(oneOf) || oneOf.length < 2) {
if (!isPlainJsonArray(oneOf) || oneOf.length < 2) {
violations.push(`${path}.oneOf must be an array of at least two schemas`)
} else {
for (let index = oneOf.length - 1; index >= 0; index--) {
@@ -249,10 +323,10 @@ function checkSchemaNode(root: unknown, rootPath: string, violations: string[],
switch (schemaType) {
case 'object': {
const properties = node.properties
const properties = Object.hasOwn(node, 'properties') ? node.properties : undefined
tasks.push({ kind: 'object-tail', node, path, properties })
if (Object.hasOwn(node, 'properties')) {
if (!isPlainJsonRecord(properties)) {
if (!isJsonSchemaRecord(properties)) {
violations.push(`${path}.properties must be an object of schemas`)
} else {
const entries = Object.entries(properties)
@@ -275,18 +349,21 @@ function checkSchemaNode(root: unknown, rootPath: string, violations: string[],
case 'integer':
case 'boolean':
case 'null': {
const allowed = node.enum
const enumValid = Array.isArray(allowed)
const hasEnum = Object.hasOwn(node, 'enum')
const allowed = hasEnum ? node.enum : undefined
const enumValid = isPlainJsonArray(allowed)
&& allowed.length > 0
&& allowed.every(entry => scalarMatches(schemaType, entry))
if (Object.hasOwn(node, 'enum') && !enumValid) {
if (hasEnum && !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')) {
const hasConst = Object.hasOwn(node, 'const')
const declaredConst = hasConst ? node.const : undefined
const constValid = scalarMatches(schemaType, declaredConst)
if (hasConst) {
if (!constValid) {
violations.push(`${path}.const must be a ${schemaType} value`)
} else if (enumValid && !allowed.includes(node.const as JsonSchemaScalar)) {
} else if (enumValid && !allowed.includes(declaredConst)) {
violations.push(`${path}.const must be one of ${path}.enum when both are declared`)
}
}
@@ -320,7 +397,8 @@ export function assertSupportedJsonSchema(schema: unknown): asserts schema is Js
export function assertObjectJsonSchema(schema: unknown): asserts schema is ObjectJsonSchema {
const violations: string[] = []
checkSchemaNode(schema, 'schema', violations, new Set())
if (violations.length === 0 && (schema as JsonSchemaNode).type !== 'object') {
if (violations.length === 0
&& (!isJsonSchemaRecord(schema) || !Object.hasOwn(schema, 'type') || schema.type !== 'object')) {
violations.push('schema.type must be "object" (structured output is object-rooted)')
}
if (violations.length > 0) throw new JsonSchemaError(violations)
@@ -395,8 +473,9 @@ function valueFrame(node: JsonSchemaNode, value: unknown, path: string): ValueFr
/** 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)}`]
const allowed = Object.hasOwn(node, 'enum') ? node.enum : undefined
if (allowed !== undefined && !allowed.includes(value as JsonSchemaScalar)) {
return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(allowed)}`]
}
if (Object.hasOwn(node, 'const') && value !== node.const) {
return [`"${diagnosticPath(path)}" must be ${JSON.stringify(node.const)}`]
@@ -455,9 +534,9 @@ function checkValue(schema: JsonSchemaNode, value: unknown, path: string): strin
continue
}
const nodeType = frame.node.type
const nodeType = Object.hasOwn(frame.node, 'type') ? frame.node.type : undefined
frame.catches = !(nodeType !== undefined && !(SCHEMA_TYPES as readonly unknown[]).includes(nodeType))
const oneOf = frame.node.oneOf
const oneOf = Object.hasOwn(frame.node, 'oneOf') ? frame.node.oneOf : undefined
if (oneOf !== undefined) {
frame.kind = 'oneOf'
frame.children = Array.from(oneOf, branch => ({ node: branch, value: frame.value, path: frame.path }))
@@ -477,9 +556,10 @@ function checkValue(schema: JsonSchemaNode, value: unknown, path: string): strin
finish([`"${diagnosticPath(frame.path)}" must be an object`])
break
}
const properties = frame.node.properties ?? {}
const properties = Object.hasOwn(frame.node, 'properties') ? frame.node.properties ?? {} : {}
const violations: string[] = []
for (const key of frame.node.required ?? []) {
const required = Object.hasOwn(frame.node, 'required') ? frame.node.required ?? [] : []
for (const key of required) {
if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) {
violations.push(`missing required property "${propertyPath(frame.path, key)}"`)
}
@@ -490,7 +570,7 @@ function checkValue(schema: JsonSchemaNode, value: unknown, path: string): strin
children.push({ node: child, value: frame.value[key], path: propertyPath(frame.path, key) })
}
const tailViolations: string[] = []
if (frame.node.additionalProperties === false) {
if (Object.hasOwn(frame.node, 'additionalProperties') && 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)`)
@@ -510,7 +590,7 @@ function checkValue(schema: JsonSchemaNode, value: unknown, path: string): strin
finish([`"${diagnosticPath(frame.path)}" must be an array`])
break
}
const items = frame.node.items
const items = Object.hasOwn(frame.node, 'items') ? frame.node.items : undefined
const children = items === undefined
? []
: frame.value.flatMap((entry, index): ValueChild[] => [{ node: items, value: entry, path: `${frame.path}[${index}]` }])
+52 -42
View File
@@ -4,7 +4,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { ToolDefinition, ToolRunContext, ToolResult } from './index.ts'
import { assertSupportedJsonSchema, isPlainJsonRecord, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts'
import { assertSupportedJsonSchema, isJsonSchemaRecord, isPlainJsonArray, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts'
import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts'
import type { ToolCallView, ToolResultView } from './presentation.ts'
@@ -100,7 +100,10 @@ export type ParameterPropertySpec = ValueSchemaSpec & { required?: true }
* Tool parameter schema. The map itself is an implicit open object root;
* requiredness remains a per-property `required: true` annotation.
*/
export type ParameterSchemaSpec = Record<string, ParameterPropertySpec>
export type ParameterSchemaSpec = {
[key: string]: ParameterPropertySpec
[key: symbol]: never
}
/** Raw JSON Schema projection of the implicit parameter object. */
export interface ParameterJsonSchema extends ObjectJsonSchema {
@@ -110,30 +113,29 @@ export interface ParameterJsonSchema extends ObjectJsonSchema {
/** Flatten an intersection into one object type for readable hovers. */
type Simplify<T> = { [K in keyof T]: T[K] } & {}
/** Keys of a property map marked `required: true`. */
type RequiredKeys<S extends ParameterSchemaSpec> = {
[K in keyof S]: S[K] extends { required: true } ? K : never
}[keyof S]
/** String keys of one property map; runtime compilation rejects symbol keys. */
type StringKeyOf<S> = Extract<keyof S, string>
/** Advance the bounded inference walk through one nested schema node. */
type NextDepth<D extends readonly unknown[]> = readonly [...D, unknown]
/** Keys of a property map marked `required: true`. */
type RequiredKeys<S> = {
[K in StringKeyOf<S>]: S[K] extends { required: true } ? K : never
}[StringKeyOf<S>]
/** Infer the declared value of one parameter property without key optionality. */
type InferProperty<P extends ParameterPropertySpec, D extends readonly unknown[]> =
P extends ValueSchemaSpec ? InferValue<P, D> : never
type InferProperty<P, Depth extends unknown[]> = InferValueAt<P, Depth>
/** Infer an implicit property map into required and optional object keys. */
type InferProperties<S extends ParameterSchemaSpec, D extends readonly unknown[]> = Simplify<
& { [K in RequiredKeys<S>]: InferProperty<S[K], D> }
& { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferProperty<S[K], D> }
type InferProperties<S, Depth extends unknown[]> = Simplify<
& { [K in RequiredKeys<S>]: InferProperty<S[K], Depth> }
& { [K in Exclude<StringKeyOf<S>, RequiredKeys<S>>]?: InferProperty<S[K], Depth> }
>
/** Infer an explicit object node, including its declared openness. */
type InferObject<S extends ObjectValueSchemaSpec, D extends readonly unknown[]> =
S extends { properties: infer P extends ParameterSchemaSpec }
type InferObject<S extends { additionalProperties: boolean }, Depth extends unknown[]> =
S extends { properties: infer P }
? S['additionalProperties'] extends true
? InferProperties<P, D> & Record<string, JsonValue>
: InferProperties<P, D>
? InferProperties<P, Depth> & Record<string, JsonValue>
: InferProperties<P, Depth>
: S['additionalProperties'] extends true
? Record<string, JsonValue>
: Record<string, never>
@@ -144,25 +146,33 @@ type InferScalar<S, Fallback> =
S extends { enum: readonly (infer E)[] } ? E :
Fallback
/** Add one schema-container level to bounded compile-time inference. */
type NextInferenceDepth<Depth extends unknown[]> = [unknown, ...Depth]
/** Infer one node without recursively checking it against the full author union. */
type InferValueAt<S, Depth extends unknown[]> =
Depth['length'] extends 16 ? JsonValue :
S extends { type: 'string' } ? InferScalar<S, string> :
S extends { type: 'number' | 'integer' } ? InferScalar<S, number> :
S extends { type: 'boolean' } ? InferScalar<S, boolean> :
S extends { type: 'null' } ? null :
S extends { type: 'array' }
? S extends { items: infer I } ? InferValueAt<I, NextInferenceDepth<Depth>>[] : JsonValue[]
: S extends { type: 'object'; additionalProperties: boolean }
? InferObject<S, NextInferenceDepth<Depth>>
: S extends { type: 'json' } ? JsonValue :
S extends { oneOf: readonly unknown[] }
? InferValueAt<S['oneOf'][number], NextInferenceDepth<Depth>>
: never
/**
* Infer the TypeScript value accepted by an author-facing value schema.
* Output schemas may therefore infer object, array, scalar, or null roots.
* Infer the TypeScript value accepted by an author-facing value schema. Exact
* inference is bounded to 16 container levels, then falls back to `JsonValue`.
*/
export type InferValue<S extends ValueSchemaSpec, D extends readonly unknown[] = readonly []> =
D['length'] extends 12 ? JsonValue :
S extends StringValueSchemaSpec ? InferScalar<S, string> :
S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar<S, number> :
S extends BooleanValueSchemaSpec ? InferScalar<S, boolean> :
S extends NullValueSchemaSpec ? null :
S extends ArrayValueSchemaSpec
? S extends { items: infer I extends ValueSchemaSpec } ? InferValue<I, NextDepth<D>>[] : JsonValue[]
: S extends ObjectValueSchemaSpec ? InferObject<S, NextDepth<D>> :
S extends JsonValueSchemaSpec ? JsonValue :
S extends OneOfValueSchemaSpec ? InferValue<S['oneOf'][number], NextDepth<D>> :
never
export type InferValue<S> = InferValueAt<S, []>
/** Infer the TypeScript argument object for an implicit parameter schema. */
export type InferArgs<S extends ParameterSchemaSpec> = InferProperties<S, readonly []>
export type InferArgs<S> = InferProperties<S, []>
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
@@ -278,11 +288,11 @@ function runSchemaCompiler(initial: CompileTask): void {
continue
}
if (task.kind === 'property') {
if (!isPlainJsonRecord(task.property)) authorError(`${task.path} must be a value schema object`)
if (!isJsonSchemaRecord(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)
if (Object.hasOwn(task.property, 'required') && task.property.required === true) task.required.push(task.key)
tasks.push({
kind: 'value',
input: task.property,
@@ -293,7 +303,7 @@ function runSchemaCompiler(initial: CompileTask): void {
continue
}
if (task.kind === 'property-map') {
if (!isPlainJsonRecord(task.input)) authorError(`${task.path} must be an object of value schemas`)
if (!isJsonSchemaRecord(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: {} }
@@ -319,7 +329,7 @@ function runSchemaCompiler(initial: CompileTask): void {
}
const { input, path } = task
if (!isPlainJsonRecord(input)) authorError(`${path} must be a value schema object`)
if (!isJsonSchemaRecord(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'] : [])]
@@ -330,7 +340,7 @@ function runSchemaCompiler(initial: CompileTask): void {
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`)
if (!isPlainJsonArray(input.oneOf)) authorError(`${path}.oneOf must be an array of at least two value schemas`)
const branches: JsonSchemaNode[] = []
node.oneOf = branches
copyAnnotations(input, node)
@@ -346,7 +356,8 @@ function runSchemaCompiler(initial: CompileTask): void {
continue
}
switch (input.type) {
const inputType = Object.hasOwn(input, 'type') ? input.type : undefined
switch (inputType) {
case 'json':
assertAuthorKeys(input, path, [...authorKeys, 'type'])
copyAnnotations(input, node)
@@ -388,12 +399,11 @@ function runSchemaCompiler(initial: CompileTask): void {
case 'boolean':
case 'null':
assertAuthorKeys(input, path, [...authorKeys, 'type', 'enum', 'const'])
node.type = input.type
node.type = inputType
copyAnnotations(input, node)
if (Object.hasOwn(input, 'enum')) {
node.enum = Array.isArray(input.enum)
? Array.from(input.enum as unknown[], entry => entry as JsonSchemaScalar)
: input.enum as JsonSchemaScalar[]
if (!isPlainJsonArray(input.enum)) authorError(`${path}.enum must be a non-empty array of scalar values`)
node.enum = Array.from(input.enum, entry => entry as JsonSchemaScalar)
}
if (Object.hasOwn(input, 'const')) node.const = input.const as JsonSchemaScalar
break
@@ -30,6 +30,21 @@ function violationsOf(schema: unknown, objectRoot = false): string[] {
throw new Error('expected schema rejection')
}
function recordWithForgedIntrinsicPrototype(
own: Record<string, unknown>,
inherited: Record<string, unknown> = {},
revoked = false,
): Record<string, unknown> {
const prototype = Object.assign(Object.create(null) as Record<string, unknown>, inherited)
const ForgedObject = function ForgedObject(): void {}
Object.defineProperty(ForgedObject, 'name', { value: 'Object' })
ForgedObject.prototype = prototype
const constructor = revoked ? Proxy.revocable(ForgedObject, {}) : undefined
if (constructor !== undefined) constructor.revoke()
Object.defineProperty(prototype, 'constructor', { value: constructor?.proxy ?? ForgedObject })
return Object.assign(Object.create(prototype) as Record<string, unknown>, own)
}
describe('the enforced raw JSON Schema subset', () => {
it('accepts every JSON root and every supported node', () => {
for (const schema of [
@@ -81,6 +96,23 @@ describe('the enforced raw JSON Schema subset', () => {
.toEqual(['schema.items is not supported beside oneOf'])
expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'weird' }] })[0])
.toContain('schema.oneOf[1].type')
const sparse = new Array<unknown>(2)
sparse[0] = { type: 'string' }
expect(violationsOf({ oneOf: sparse }))
.toEqual(['schema.oneOf must be an array of at least two schemas'])
const compensatedSparse = new Array<unknown>(2)
compensatedSparse[0] = { type: 'string' }
Object.defineProperty(compensatedSparse, 'extra', { value: true })
expect(violationsOf({ oneOf: compensatedSparse }))
.toEqual(['schema.oneOf must be an array of at least two schemas'])
class ExoticBranches extends Array<unknown> {}
expect(violationsOf({ oneOf: new ExoticBranches({ type: 'string' }, { type: 'null' }) }))
.toEqual(['schema.oneOf must be an array of at least two schemas'])
const explosiveArray = new Proxy([{ type: 'string' }, { type: 'null' }], {
getPrototypeOf() { throw new Error('prototype trap') },
})
expect(violationsOf({ oneOf: explosiveArray }))
.toEqual(['schema.oneOf must be an array of at least two schemas'])
})
it('rejects unknown and misplaced keywords without accepted-then-ignored behavior', () => {
@@ -134,6 +166,9 @@ describe('the enforced raw JSON Schema subset', () => {
'schema.properties must be an object of schemas',
'schema.required names "missing" which is not in properties',
])
const sparseRequired = new Array<string>(1)
expect(violationsOf({ type: 'object', required: sparseRequired }))
.toEqual(['schema.required must be an array of strings'])
})
it('requires type-correct scalar enum and const values', () => {
@@ -163,6 +198,9 @@ describe('the enforced raw JSON Schema subset', () => {
.toEqual(['schema.enum must be a non-empty array of string values'])
expect(violationsOf({ type: 'string', enum: ['a'], const: 'b' }))
.toEqual(['schema.const must be one of schema.enum when both are declared'])
const sparseEnum = new Array<string>(1)
expect(violationsOf({ type: 'string', enum: sparseEnum }))
.toEqual(['schema.enum must be a non-empty array of string values'])
})
it('validates annotation types and lossless JSON payloads', () => {
@@ -195,6 +233,8 @@ describe('the enforced raw JSON Schema subset', () => {
it('accepts lossless annotation containers from another JavaScript realm', () => {
const schema = runInNewContext(`({
type: 'object',
properties: { value: { type: 'string', enum: ['x'] } },
required: ['value'],
default: { x: 1 },
examples: [[{ ok: true }]],
})`) as unknown
@@ -212,6 +252,28 @@ describe('the enforced raw JSON Schema subset', () => {
.toEqual(['schema.properties must be an object of schemas'])
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
.toEqual(['schema.properties.at must be a schema object'])
const forgedSchema = recordWithForgedIntrinsicPrototype(
{ type: 'object' },
{ oneOf: [{ type: 'string' }, { type: 'null' }] },
)
expect(violationsOf(forgedSchema)).toEqual(['schema must be a schema object'])
expect(violationsOf(forgedSchema, true)).toEqual(['schema must be a schema object'])
expect(violationsOf(recordWithForgedIntrinsicPrototype({ type: 'string' }, {}, true)))
.toEqual(['schema must be a schema object'])
const prototypeWithoutConstructor = Object.create(null) as object
expect(violationsOf(Object.create(prototypeWithoutConstructor) as unknown))
.toEqual(['schema must be a schema object'])
expect(violationsOf(Object.defineProperty({ type: 'string' }, 'hidden', { value: true })))
.toEqual(['schema must be a schema object'])
expect(violationsOf({ type: 'string', [Symbol('hidden')]: true }))
.toEqual(['schema must be a schema object'])
expect(violationsOf(new Proxy({}, {
getPrototypeOf() { throw new Error('prototype trap') },
}))).toEqual(['schema must be a schema object'])
expect(violationsOf(new Proxy({}, {
ownKeys() { throw new Error('keys trap') },
}))).toEqual(['schema must be a schema object'])
})
it('asserts deeply nested raw unions without using the JavaScript call stack', () => {
@@ -369,6 +431,21 @@ describe('validateJsonSchemaValue', () => {
asserted({ type: 'object', properties: { constructor: { type: 'string' } } }),
{},
)).toEqual([])
const inheritedUnion = Object.assign(
Object.create({ oneOf: [{ type: 'string' }, { type: 'null' }] }) as JsonSchemaNode,
{ type: 'object' as const },
)
expect(validateJsonSchemaValue(inheritedUnion, {})).toEqual([])
expect(validateJsonSchemaValue(inheritedUnion, 'x')).toEqual(['"value" must be an object'])
expect(validateJsonSchemaValue(
{ type: 'object', properties: undefined } as unknown as JsonSchemaNode,
{},
)).toEqual([])
expect(validateJsonSchemaValue(
{ type: 'object', required: undefined } as unknown as JsonSchemaNode,
{},
)).toEqual([])
})
it('keeps assertNever as a forged-schema backstop', () => {
+39 -1
View File
@@ -71,6 +71,7 @@ describe('the unified author schema DSL', () => {
{ type: 'string', oneOf: [{ type: 'string' }, { type: 'null' }] },
{ oneOf: 'not-an-array' },
{ type: 'string', enum: 'a' },
{},
null,
]) {
expect(() => valueSchemaSpecToJsonSchema(schema as ValueSchemaSpec), JSON.stringify(schema)).toThrow(JsonSchemaError)
@@ -80,6 +81,24 @@ describe('the unified author schema DSL', () => {
} as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
expect(() => parameterSchemaSpecToJsonSchema(null as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
expect(() => parameterSchemaSpecToJsonSchema({ bad: 42 } as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
const symbolKey = Symbol('hidden')
expect(() => parameterSchemaSpecToJsonSchema({
value: { type: 'string' },
[symbolKey]: { type: 'number' },
} as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
const hiddenKey = Object.defineProperty({ value: { type: 'string' } }, 'hidden', {
value: { type: 'number' },
})
expect(() => parameterSchemaSpecToJsonSchema(hiddenKey as ParameterSchemaSpec)).toThrow(JsonSchemaError)
const sparseOneOf = new Array<ValueSchemaSpec>(2)
sparseOneOf[0] = { type: 'string' }
expect(() => valueSchemaSpecToJsonSchema({ oneOf: sparseOneOf } as unknown as ValueSchemaSpec)).toThrow(JsonSchemaError)
const decoratedEnum = Object.assign(['a'], { hidden: true })
expect(() => valueSchemaSpecToJsonSchema({
type: 'string',
enum: decoratedEnum,
})).toThrow(JsonSchemaError)
})
it('rejects cyclic author schemas', () => {
@@ -143,6 +162,22 @@ describe('the unified author schema DSL', () => {
}>>().toEqualTypeOf<{ id: number } & Record<string, JsonValue>>()
})
it('bounds inference for deeply nested author schemas', () => {
type Repeat<Count extends number, Result extends unknown[] = []> =
Result['length'] extends Count ? Result : Repeat<Count, [unknown, ...Result]>
type DeepArraySchema<Levels extends unknown[]> =
Levels extends [unknown, ...infer Rest]
? { type: 'array'; items: DeepArraySchema<Rest> }
: { type: 'string' }
type PeelArrays<Value, Levels extends unknown[]> =
Levels extends [unknown, ...infer Rest]
? Value extends (infer Item)[] ? PeelArrays<Item, Rest> : never
: Value
type DeepValue = InferValue<DeepArraySchema<Repeat<50>>>
expectTypeOf<PeelArrays<DeepValue, Repeat<16>>>().toEqualTypeOf<JsonValue>()
})
it('infers required and optional parameter keys', () => {
expectTypeOf<InferArgs<{
path: { type: 'string'; required: true }
@@ -152,6 +187,7 @@ describe('the unified author schema DSL', () => {
})
it('makes invalid author forms compile-time errors', () => {
const symbolKey = Symbol('parameter')
const invalidObjects = {
// @ts-expect-error explicit object schemas require an openness decision
object: { type: 'object' } satisfies ValueSchemaSpec,
@@ -161,7 +197,9 @@ describe('the unified author schema DSL', () => {
enum: { type: 'number', enum: ['1'] } satisfies ValueSchemaSpec,
// @ts-expect-error parameter requiredness is true-or-absent
required: { value: { type: 'string', required: false } } satisfies ParameterSchemaSpec,
// @ts-expect-error parameter maps accept string keys only
symbol: { [symbolKey]: { type: 'string' } } satisfies ParameterSchemaSpec,
}
expect(Object.keys(invalidObjects)).toHaveLength(4)
expect(Object.keys(invalidObjects)).toHaveLength(5)
})
})