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:
@@ -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`. */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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}]` }])
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user