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:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write
|
||||||
2026-07-20-unified-json-value-schema-dsl.md: e735472ee0ac6a696462aa7598d57fce04aa9c3e
|
2026-07-20-unified-json-value-schema-dsl.md: 3e35bce6eb48afeb31c564e9b5d7b84ff91a7b1f
|
||||||
2026-07-20-unified-json-value-schema-dsl.zh.md: dd34817a6d047b14346b35ba6c4bce0290ad7feb
|
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.
|
`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.
|
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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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' }` 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。
|
`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 声明的对象明确选择开放方式,然后再调用同一编译器。
|
对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。
|
||||||
|
|
||||||
@@ -28,5 +30,6 @@ Status: implemented
|
|||||||
- 参数校验、输出校验、schema 到 TypeScript 的代码生成、subagent/工作流门禁和动态注册共用一套强制执行的词汇。
|
- 参数校验、输出校验、schema 到 TypeScript 的代码生成、subagent/工作流门禁和动态注册共用一套强制执行的词汇。
|
||||||
- 输出声明可以推导对象、数组、标量或 null 根类型;subagent/工作流的结构化输出仍在其现有服务边界保持对象根限制。
|
- 输出声明可以推导对象、数组、标量或 null 根类型;subagent/工作流的结构化输出仍在其现有服务边界保持对象根限制。
|
||||||
- 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。
|
- 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。
|
||||||
|
- 有界类型推导会为常规声明保留有用的精确类型,并将异常深的尾部结构退化为 `JsonValue`;运行时 schema 强制执行在任意深度仍保持精确。
|
||||||
- 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。
|
- 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。
|
||||||
- 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导,以及核心投影和动态投影中的深层嵌套。
|
- 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导,以及核心投影和动态投影中的深层嵌套。
|
||||||
|
|||||||
@@ -111,36 +111,28 @@ type ParameterPropertySpec = ValueSchemaSpec & { required?: true }
|
|||||||
* Tool parameter schema. The map itself is an implicit open object root;
|
* Tool parameter schema. The map itself is an implicit open object root;
|
||||||
* requiredness remains a per-property `required: true` annotation.
|
* 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
|
```ts type-equiv
|
||||||
/**
|
/**
|
||||||
* Infer the TypeScript value accepted by an author-facing value schema.
|
* Infer the TypeScript value accepted by an author-facing value schema. Exact
|
||||||
* Output schemas may therefore infer object, array, scalar, or null roots.
|
* inference is bounded to 16 container levels, then falls back to `JsonValue`.
|
||||||
*/
|
*/
|
||||||
type InferValue<S extends ValueSchemaSpec, D extends readonly unknown[] = readonly []> =
|
type InferValue<S> = InferValueAt<S, []>
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts type-equiv
|
```ts type-equiv
|
||||||
/** Infer the TypeScript argument object for an implicit parameter schema. */
|
/** 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
/* 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 {
|
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
|
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
|
||||||
const constructor: unknown = descriptor?.value
|
const constructor: unknown = descriptor?.value
|
||||||
return typeof constructor === 'function'
|
if (typeof constructor !== 'function') return false
|
||||||
&& constructor.name === name
|
try {
|
||||||
&& constructor.prototype === prototype
|
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. */
|
/** 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 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 (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: { 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) => {
|
])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => {
|
||||||
const ctx = await setup()
|
const ctx = await setup()
|
||||||
const result = await call(ctx, 'cordis_mount', {
|
const result = await call(ctx, 'cordis_mount', {
|
||||||
|
|||||||
@@ -12,13 +12,18 @@
|
|||||||
*/
|
*/
|
||||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
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 {
|
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
|
||||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
|
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
|
||||||
const constructor: unknown = descriptor?.value
|
const constructor: unknown = descriptor?.value
|
||||||
return typeof constructor === 'function'
|
if (typeof constructor !== 'function') return false
|
||||||
&& constructor.name === name
|
try {
|
||||||
&& constructor.prototype === prototype
|
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`. */
|
/** Whether a candidate is one realm's intrinsic `Object.prototype`. */
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import { runInNewContext } from 'node:vm'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { isJsonValue, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
|
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', () => {
|
describe('snapshotJsonValue', () => {
|
||||||
it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => {
|
it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => {
|
||||||
const unsupportedFunction = (): void => {}
|
const unsupportedFunction = (): void => {}
|
||||||
@@ -109,6 +120,8 @@ describe('snapshotJsonValue', () => {
|
|||||||
const symbolObject = { [Symbol('extra')]: true }
|
const symbolObject = { [Symbol('extra')]: true }
|
||||||
const customPrototype = Object.create(null) as Record<string, unknown>
|
const customPrototype = Object.create(null) as Record<string, unknown>
|
||||||
const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record<string, unknown>, { value: 1 })
|
const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record<string, unknown>, { value: 1 })
|
||||||
|
const forgedIntrinsicObject = objectWithForgedIntrinsicPrototype()
|
||||||
|
const revokedIntrinsicObject = objectWithForgedIntrinsicPrototype(true)
|
||||||
const forgedPrototype: unknown[] = []
|
const forgedPrototype: unknown[] = []
|
||||||
Object.setPrototypeOf(forgedPrototype, null)
|
Object.setPrototypeOf(forgedPrototype, null)
|
||||||
const forgedArray = [1]
|
const forgedArray = [1]
|
||||||
@@ -133,6 +146,8 @@ describe('snapshotJsonValue', () => {
|
|||||||
expect(snapshotJsonValue(hiddenObject)).toBeUndefined()
|
expect(snapshotJsonValue(hiddenObject)).toBeUndefined()
|
||||||
expect(snapshotJsonValue(symbolObject)).toBeUndefined()
|
expect(snapshotJsonValue(symbolObject)).toBeUndefined()
|
||||||
expect(snapshotJsonValue(customPrototypeObject)).toBeUndefined()
|
expect(snapshotJsonValue(customPrototypeObject)).toBeUndefined()
|
||||||
|
expect(snapshotJsonValue(forgedIntrinsicObject)).toBeUndefined()
|
||||||
|
expect(snapshotJsonValue(revokedIntrinsicObject)).toBeUndefined()
|
||||||
expect(snapshotJsonValue(forgedArray)).toBeUndefined()
|
expect(snapshotJsonValue(forgedArray)).toBeUndefined()
|
||||||
expect(snapshotJsonValue(cyclic)).toBeUndefined()
|
expect(snapshotJsonValue(cyclic)).toBeUndefined()
|
||||||
expect(snapshotJsonValue([undefined])).toBeUndefined()
|
expect(snapshotJsonValue([undefined])).toBeUndefined()
|
||||||
@@ -206,6 +221,8 @@ describe('isJsonValue', () => {
|
|||||||
const symbolObject = { [Symbol('extra')]: true }
|
const symbolObject = { [Symbol('extra')]: true }
|
||||||
const customPrototype = Object.create(null) as Record<string, unknown>
|
const customPrototype = Object.create(null) as Record<string, unknown>
|
||||||
const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record<string, unknown>, { value: 1 })
|
const customPrototypeObject = Object.assign(Object.create(customPrototype) as Record<string, unknown>, { value: 1 })
|
||||||
|
const forgedIntrinsicObject = objectWithForgedIntrinsicPrototype()
|
||||||
|
const revokedIntrinsicObject = objectWithForgedIntrinsicPrototype(true)
|
||||||
const forgedPrototype: unknown[] = []
|
const forgedPrototype: unknown[] = []
|
||||||
Object.setPrototypeOf(forgedPrototype, null)
|
Object.setPrototypeOf(forgedPrototype, null)
|
||||||
const forgedArray = [1]
|
const forgedArray = [1]
|
||||||
@@ -220,6 +237,8 @@ describe('isJsonValue', () => {
|
|||||||
expect(isJsonValue(hiddenObject)).toBe(false)
|
expect(isJsonValue(hiddenObject)).toBe(false)
|
||||||
expect(isJsonValue(symbolObject)).toBe(false)
|
expect(isJsonValue(symbolObject)).toBe(false)
|
||||||
expect(isJsonValue(customPrototypeObject)).toBe(false)
|
expect(isJsonValue(customPrototypeObject)).toBe(false)
|
||||||
|
expect(isJsonValue(forgedIntrinsicObject)).toBe(false)
|
||||||
|
expect(isJsonValue(revokedIntrinsicObject)).toBe(false)
|
||||||
expect(isJsonValue(forgedArray)).toBe(false)
|
expect(isJsonValue(forgedArray)).toBe(false)
|
||||||
expect(isJsonValue(new ExoticArray(1))).toBe(false)
|
expect(isJsonValue(new ExoticArray(1))).toBe(false)
|
||||||
expect(isJsonValue([undefined])).toBe(false)
|
expect(isJsonValue([undefined])).toBe(false)
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,26 @@ const CONSTRAINT_KEYWORDS = new Set([
|
|||||||
const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
|
const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
|
||||||
const SCHEMA_TYPES: readonly JsonSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
|
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
|
* Test for a realm-agnostic plain JSON record without accepting arrays or
|
||||||
* exotic objects.
|
* 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> {
|
export function isPlainJsonRecord(value: unknown): value is Record<string, unknown> {
|
||||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
||||||
const proto: unknown = Object.getPrototypeOf(value)
|
try {
|
||||||
return proto === null || Object.getPrototypeOf(proto) === null
|
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. */
|
/** Lossless finite JSON number, excluding negative zero. */
|
||||||
@@ -133,12 +206,13 @@ function checkObjectSchemaTail(
|
|||||||
properties: unknown,
|
properties: unknown,
|
||||||
violations: string[],
|
violations: string[],
|
||||||
): void {
|
): void {
|
||||||
const required = node.required
|
const hasRequired = Object.hasOwn(node, 'required')
|
||||||
if (Object.hasOwn(node, 'required')) {
|
const required = hasRequired ? node.required : undefined
|
||||||
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
|
if (hasRequired) {
|
||||||
|
if (!isPlainJsonArray(required) || required.some(entry => typeof entry !== 'string')) {
|
||||||
violations.push(`${path}.required must be an array of strings`)
|
violations.push(`${path}.required must be an array of strings`)
|
||||||
} else {
|
} else {
|
||||||
const declared = isPlainJsonRecord(properties) ? properties : {}
|
const declared = isJsonSchemaRecord(properties) ? properties : {}
|
||||||
for (const key of required as string[]) {
|
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(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
|
const { node, path } = task
|
||||||
if (!isPlainJsonRecord(node)) {
|
if (!isJsonSchemaRecord(node)) {
|
||||||
violations.push(`${path} must be a schema object`)
|
violations.push(`${path} must be a schema object`)
|
||||||
continue
|
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)`)
|
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`)
|
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`)
|
violations.push(`${path}.title must be a string`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +289,7 @@ function checkSchemaNode(root: unknown, rootPath: string, violations: string[],
|
|||||||
if (hasOneOf) {
|
if (hasOneOf) {
|
||||||
const oneOf = node.oneOf
|
const oneOf = node.oneOf
|
||||||
tasks.push({ kind: 'one-of-tail', node, path })
|
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`)
|
violations.push(`${path}.oneOf must be an array of at least two schemas`)
|
||||||
} else {
|
} else {
|
||||||
for (let index = oneOf.length - 1; index >= 0; index--) {
|
for (let index = oneOf.length - 1; index >= 0; index--) {
|
||||||
@@ -249,10 +323,10 @@ function checkSchemaNode(root: unknown, rootPath: string, violations: string[],
|
|||||||
|
|
||||||
switch (schemaType) {
|
switch (schemaType) {
|
||||||
case 'object': {
|
case 'object': {
|
||||||
const properties = node.properties
|
const properties = Object.hasOwn(node, 'properties') ? node.properties : undefined
|
||||||
tasks.push({ kind: 'object-tail', node, path, properties })
|
tasks.push({ kind: 'object-tail', node, path, properties })
|
||||||
if (Object.hasOwn(node, 'properties')) {
|
if (Object.hasOwn(node, 'properties')) {
|
||||||
if (!isPlainJsonRecord(properties)) {
|
if (!isJsonSchemaRecord(properties)) {
|
||||||
violations.push(`${path}.properties must be an object of schemas`)
|
violations.push(`${path}.properties must be an object of schemas`)
|
||||||
} else {
|
} else {
|
||||||
const entries = Object.entries(properties)
|
const entries = Object.entries(properties)
|
||||||
@@ -275,18 +349,21 @@ function checkSchemaNode(root: unknown, rootPath: string, violations: string[],
|
|||||||
case 'integer':
|
case 'integer':
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
case 'null': {
|
case 'null': {
|
||||||
const allowed = node.enum
|
const hasEnum = Object.hasOwn(node, 'enum')
|
||||||
const enumValid = Array.isArray(allowed)
|
const allowed = hasEnum ? node.enum : undefined
|
||||||
|
const enumValid = isPlainJsonArray(allowed)
|
||||||
&& allowed.length > 0
|
&& allowed.length > 0
|
||||||
&& allowed.every(entry => scalarMatches(schemaType, entry))
|
&& 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`)
|
violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
|
||||||
}
|
}
|
||||||
const constValid = scalarMatches(schemaType, node.const)
|
const hasConst = Object.hasOwn(node, 'const')
|
||||||
if (Object.hasOwn(node, 'const')) {
|
const declaredConst = hasConst ? node.const : undefined
|
||||||
|
const constValid = scalarMatches(schemaType, declaredConst)
|
||||||
|
if (hasConst) {
|
||||||
if (!constValid) {
|
if (!constValid) {
|
||||||
violations.push(`${path}.const must be a ${schemaType} value`)
|
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`)
|
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 {
|
export function assertObjectJsonSchema(schema: unknown): asserts schema is ObjectJsonSchema {
|
||||||
const violations: string[] = []
|
const violations: string[] = []
|
||||||
checkSchemaNode(schema, 'schema', violations, new Set())
|
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)')
|
violations.push('schema.type must be "object" (structured output is object-rooted)')
|
||||||
}
|
}
|
||||||
if (violations.length > 0) throw new JsonSchemaError(violations)
|
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. */
|
/** Validate one scalar node after its primitive type check. */
|
||||||
function checkScalarValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
|
function checkScalarValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
|
||||||
if (node.enum !== undefined && !node.enum.includes(value as JsonSchemaScalar)) {
|
const allowed = Object.hasOwn(node, 'enum') ? node.enum : undefined
|
||||||
return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(node.enum)}`]
|
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) {
|
if (Object.hasOwn(node, 'const') && value !== node.const) {
|
||||||
return [`"${diagnosticPath(path)}" must be ${JSON.stringify(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
|
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))
|
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) {
|
if (oneOf !== undefined) {
|
||||||
frame.kind = 'oneOf'
|
frame.kind = 'oneOf'
|
||||||
frame.children = Array.from(oneOf, branch => ({ node: branch, value: frame.value, path: frame.path }))
|
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`])
|
finish([`"${diagnosticPath(frame.path)}" must be an object`])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const properties = frame.node.properties ?? {}
|
const properties = Object.hasOwn(frame.node, 'properties') ? frame.node.properties ?? {} : {}
|
||||||
const violations: string[] = []
|
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) {
|
if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) {
|
||||||
violations.push(`missing required property "${propertyPath(frame.path, key)}"`)
|
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) })
|
children.push({ node: child, value: frame.value[key], path: propertyPath(frame.path, key) })
|
||||||
}
|
}
|
||||||
const tailViolations: string[] = []
|
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)) {
|
for (const key of Object.keys(frame.value)) {
|
||||||
if (!Object.hasOwn(properties, key)) {
|
if (!Object.hasOwn(properties, key)) {
|
||||||
tailViolations.push(`"${propertyPath(frame.path, key)}" is not a declared property (additionalProperties: false)`)
|
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`])
|
finish([`"${diagnosticPath(frame.path)}" must be an array`])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
const items = frame.node.items
|
const items = Object.hasOwn(frame.node, 'items') ? frame.node.items : undefined
|
||||||
const children = items === undefined
|
const children = items === undefined
|
||||||
? []
|
? []
|
||||||
: frame.value.flatMap((entry, index): ValueChild[] => [{ node: items, value: entry, path: `${frame.path}[${index}]` }])
|
: frame.value.flatMap((entry, index): ValueChild[] => [{ node: items, value: entry, path: `${frame.path}[${index}]` }])
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { HarnessError } from '@deepseek-ai/dsh-llm'
|
|||||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||||
import type { ToolDefinition, ToolRunContext, ToolResult } from './index.ts'
|
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 { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts'
|
||||||
import type { ToolCallView, ToolResultView } from './presentation.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;
|
* Tool parameter schema. The map itself is an implicit open object root;
|
||||||
* requiredness remains a per-property `required: true` annotation.
|
* 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. */
|
/** Raw JSON Schema projection of the implicit parameter object. */
|
||||||
export interface ParameterJsonSchema extends ObjectJsonSchema {
|
export interface ParameterJsonSchema extends ObjectJsonSchema {
|
||||||
@@ -110,30 +113,29 @@ export interface ParameterJsonSchema extends ObjectJsonSchema {
|
|||||||
/** Flatten an intersection into one object type for readable hovers. */
|
/** Flatten an intersection into one object type for readable hovers. */
|
||||||
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
||||||
|
|
||||||
/** Keys of a property map marked `required: true`. */
|
/** String keys of one property map; runtime compilation rejects symbol keys. */
|
||||||
type RequiredKeys<S extends ParameterSchemaSpec> = {
|
type StringKeyOf<S> = Extract<keyof S, string>
|
||||||
[K in keyof S]: S[K] extends { required: true } ? K : never
|
|
||||||
}[keyof S]
|
|
||||||
|
|
||||||
/** Advance the bounded inference walk through one nested schema node. */
|
/** Keys of a property map marked `required: true`. */
|
||||||
type NextDepth<D extends readonly unknown[]> = readonly [...D, unknown]
|
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. */
|
/** Infer the declared value of one parameter property without key optionality. */
|
||||||
type InferProperty<P extends ParameterPropertySpec, D extends readonly unknown[]> =
|
type InferProperty<P, Depth extends unknown[]> = InferValueAt<P, Depth>
|
||||||
P extends ValueSchemaSpec ? InferValue<P, D> : never
|
|
||||||
|
|
||||||
/** Infer an implicit property map into required and optional object keys. */
|
/** Infer an implicit property map into required and optional object keys. */
|
||||||
type InferProperties<S extends ParameterSchemaSpec, D extends readonly unknown[]> = Simplify<
|
type InferProperties<S, Depth extends unknown[]> = Simplify<
|
||||||
& { [K in RequiredKeys<S>]: InferProperty<S[K], D> }
|
& { [K in RequiredKeys<S>]: InferProperty<S[K], Depth> }
|
||||||
& { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferProperty<S[K], D> }
|
& { [K in Exclude<StringKeyOf<S>, RequiredKeys<S>>]?: InferProperty<S[K], Depth> }
|
||||||
>
|
>
|
||||||
|
|
||||||
/** Infer an explicit object node, including its declared openness. */
|
/** Infer an explicit object node, including its declared openness. */
|
||||||
type InferObject<S extends ObjectValueSchemaSpec, D extends readonly unknown[]> =
|
type InferObject<S extends { additionalProperties: boolean }, Depth extends unknown[]> =
|
||||||
S extends { properties: infer P extends ParameterSchemaSpec }
|
S extends { properties: infer P }
|
||||||
? S['additionalProperties'] extends true
|
? S['additionalProperties'] extends true
|
||||||
? InferProperties<P, D> & Record<string, JsonValue>
|
? InferProperties<P, Depth> & Record<string, JsonValue>
|
||||||
: InferProperties<P, D>
|
: InferProperties<P, Depth>
|
||||||
: S['additionalProperties'] extends true
|
: S['additionalProperties'] extends true
|
||||||
? Record<string, JsonValue>
|
? Record<string, JsonValue>
|
||||||
: Record<string, never>
|
: Record<string, never>
|
||||||
@@ -144,25 +146,33 @@ type InferScalar<S, Fallback> =
|
|||||||
S extends { enum: readonly (infer E)[] } ? E :
|
S extends { enum: readonly (infer E)[] } ? E :
|
||||||
Fallback
|
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.
|
* Infer the TypeScript value accepted by an author-facing value schema. Exact
|
||||||
* Output schemas may therefore infer object, array, scalar, or null roots.
|
* inference is bounded to 16 container levels, then falls back to `JsonValue`.
|
||||||
*/
|
*/
|
||||||
export type InferValue<S extends ValueSchemaSpec, D extends readonly unknown[] = readonly []> =
|
export type InferValue<S> = InferValueAt<S, []>
|
||||||
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
|
|
||||||
|
|
||||||
/** Infer the TypeScript argument object for an implicit parameter schema. */
|
/** 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
|
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
|
||||||
|
|
||||||
@@ -278,11 +288,11 @@ function runSchemaCompiler(initial: CompileTask): void {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (task.kind === 'property') {
|
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) {
|
if (Object.hasOwn(task.property, 'required') && task.property.required !== true) {
|
||||||
authorError(`${task.path}.required must be true when present`)
|
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({
|
tasks.push({
|
||||||
kind: 'value',
|
kind: 'value',
|
||||||
input: task.property,
|
input: task.property,
|
||||||
@@ -293,7 +303,7 @@ function runSchemaCompiler(initial: CompileTask): void {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (task.kind === 'property-map') {
|
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`)
|
if (seen.has(task.input)) authorError(`${task.path} is circular`)
|
||||||
seen.add(task.input)
|
seen.add(task.input)
|
||||||
const compiled: CompiledPropertyMap = { properties: {} }
|
const compiled: CompiledPropertyMap = { properties: {} }
|
||||||
@@ -319,7 +329,7 @@ function runSchemaCompiler(initial: CompileTask): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { input, path } = task
|
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`)
|
if (seen.has(input)) authorError(`${path} is circular`)
|
||||||
seen.add(input)
|
seen.add(input)
|
||||||
const authorKeys = [...ANNOTATION_KEYS, ...(task.allowRequired ? ['required'] : [])]
|
const authorKeys = [...ANNOTATION_KEYS, ...(task.allowRequired ? ['required'] : [])]
|
||||||
@@ -330,7 +340,7 @@ function runSchemaCompiler(initial: CompileTask): void {
|
|||||||
if (Object.hasOwn(input, 'oneOf')) {
|
if (Object.hasOwn(input, 'oneOf')) {
|
||||||
assertAuthorKeys(input, path, [...authorKeys, 'oneOf', 'type'])
|
assertAuthorKeys(input, path, [...authorKeys, 'oneOf', 'type'])
|
||||||
if (Object.hasOwn(input, 'type')) authorError(`${path} cannot declare both type and oneOf`)
|
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[] = []
|
const branches: JsonSchemaNode[] = []
|
||||||
node.oneOf = branches
|
node.oneOf = branches
|
||||||
copyAnnotations(input, node)
|
copyAnnotations(input, node)
|
||||||
@@ -346,7 +356,8 @@ function runSchemaCompiler(initial: CompileTask): void {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (input.type) {
|
const inputType = Object.hasOwn(input, 'type') ? input.type : undefined
|
||||||
|
switch (inputType) {
|
||||||
case 'json':
|
case 'json':
|
||||||
assertAuthorKeys(input, path, [...authorKeys, 'type'])
|
assertAuthorKeys(input, path, [...authorKeys, 'type'])
|
||||||
copyAnnotations(input, node)
|
copyAnnotations(input, node)
|
||||||
@@ -388,12 +399,11 @@ function runSchemaCompiler(initial: CompileTask): void {
|
|||||||
case 'boolean':
|
case 'boolean':
|
||||||
case 'null':
|
case 'null':
|
||||||
assertAuthorKeys(input, path, [...authorKeys, 'type', 'enum', 'const'])
|
assertAuthorKeys(input, path, [...authorKeys, 'type', 'enum', 'const'])
|
||||||
node.type = input.type
|
node.type = inputType
|
||||||
copyAnnotations(input, node)
|
copyAnnotations(input, node)
|
||||||
if (Object.hasOwn(input, 'enum')) {
|
if (Object.hasOwn(input, 'enum')) {
|
||||||
node.enum = Array.isArray(input.enum)
|
if (!isPlainJsonArray(input.enum)) authorError(`${path}.enum must be a non-empty array of scalar values`)
|
||||||
? Array.from(input.enum as unknown[], entry => entry as JsonSchemaScalar)
|
node.enum = Array.from(input.enum, entry => entry as JsonSchemaScalar)
|
||||||
: input.enum as JsonSchemaScalar[]
|
|
||||||
}
|
}
|
||||||
if (Object.hasOwn(input, 'const')) node.const = input.const as JsonSchemaScalar
|
if (Object.hasOwn(input, 'const')) node.const = input.const as JsonSchemaScalar
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ function violationsOf(schema: unknown, objectRoot = false): string[] {
|
|||||||
throw new Error('expected schema rejection')
|
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', () => {
|
describe('the enforced raw JSON Schema subset', () => {
|
||||||
it('accepts every JSON root and every supported node', () => {
|
it('accepts every JSON root and every supported node', () => {
|
||||||
for (const schema of [
|
for (const schema of [
|
||||||
@@ -81,6 +96,23 @@ describe('the enforced raw JSON Schema subset', () => {
|
|||||||
.toEqual(['schema.items is not supported beside oneOf'])
|
.toEqual(['schema.items is not supported beside oneOf'])
|
||||||
expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'weird' }] })[0])
|
expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'weird' }] })[0])
|
||||||
.toContain('schema.oneOf[1].type')
|
.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', () => {
|
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.properties must be an object of schemas',
|
||||||
'schema.required names "missing" which is not in properties',
|
'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', () => {
|
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'])
|
.toEqual(['schema.enum must be a non-empty array of string values'])
|
||||||
expect(violationsOf({ type: 'string', enum: ['a'], const: 'b' }))
|
expect(violationsOf({ type: 'string', enum: ['a'], const: 'b' }))
|
||||||
.toEqual(['schema.const must be one of schema.enum when both are declared'])
|
.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', () => {
|
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', () => {
|
it('accepts lossless annotation containers from another JavaScript realm', () => {
|
||||||
const schema = runInNewContext(`({
|
const schema = runInNewContext(`({
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
properties: { value: { type: 'string', enum: ['x'] } },
|
||||||
|
required: ['value'],
|
||||||
default: { x: 1 },
|
default: { x: 1 },
|
||||||
examples: [[{ ok: true }]],
|
examples: [[{ ok: true }]],
|
||||||
})`) as unknown
|
})`) as unknown
|
||||||
@@ -212,6 +252,28 @@ describe('the enforced raw JSON Schema subset', () => {
|
|||||||
.toEqual(['schema.properties must be an object of schemas'])
|
.toEqual(['schema.properties must be an object of schemas'])
|
||||||
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
|
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
|
||||||
.toEqual(['schema.properties.at must be a schema object'])
|
.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', () => {
|
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' } } }),
|
asserted({ type: 'object', properties: { constructor: { type: 'string' } } }),
|
||||||
{},
|
{},
|
||||||
)).toEqual([])
|
)).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', () => {
|
it('keeps assertNever as a forged-schema backstop', () => {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ describe('the unified author schema DSL', () => {
|
|||||||
{ type: 'string', oneOf: [{ type: 'string' }, { type: 'null' }] },
|
{ type: 'string', oneOf: [{ type: 'string' }, { type: 'null' }] },
|
||||||
{ oneOf: 'not-an-array' },
|
{ oneOf: 'not-an-array' },
|
||||||
{ type: 'string', enum: 'a' },
|
{ type: 'string', enum: 'a' },
|
||||||
|
{},
|
||||||
null,
|
null,
|
||||||
]) {
|
]) {
|
||||||
expect(() => valueSchemaSpecToJsonSchema(schema as ValueSchemaSpec), JSON.stringify(schema)).toThrow(JsonSchemaError)
|
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)
|
} as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
|
||||||
expect(() => parameterSchemaSpecToJsonSchema(null 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)
|
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', () => {
|
it('rejects cyclic author schemas', () => {
|
||||||
@@ -143,6 +162,22 @@ describe('the unified author schema DSL', () => {
|
|||||||
}>>().toEqualTypeOf<{ id: number } & Record<string, JsonValue>>()
|
}>>().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', () => {
|
it('infers required and optional parameter keys', () => {
|
||||||
expectTypeOf<InferArgs<{
|
expectTypeOf<InferArgs<{
|
||||||
path: { type: 'string'; required: true }
|
path: { type: 'string'; required: true }
|
||||||
@@ -152,6 +187,7 @@ describe('the unified author schema DSL', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('makes invalid author forms compile-time errors', () => {
|
it('makes invalid author forms compile-time errors', () => {
|
||||||
|
const symbolKey = Symbol('parameter')
|
||||||
const invalidObjects = {
|
const invalidObjects = {
|
||||||
// @ts-expect-error explicit object schemas require an openness decision
|
// @ts-expect-error explicit object schemas require an openness decision
|
||||||
object: { type: 'object' } satisfies ValueSchemaSpec,
|
object: { type: 'object' } satisfies ValueSchemaSpec,
|
||||||
@@ -161,7 +197,9 @@ describe('the unified author schema DSL', () => {
|
|||||||
enum: { type: 'number', enum: ['1'] } satisfies ValueSchemaSpec,
|
enum: { type: 'number', enum: ['1'] } satisfies ValueSchemaSpec,
|
||||||
// @ts-expect-error parameter requiredness is true-or-absent
|
// @ts-expect-error parameter requiredness is true-or-absent
|
||||||
required: { value: { type: 'string', required: false } } satisfies ParameterSchemaSpec,
|
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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user