mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
657 lines
26 KiB
TypeScript
657 lines
26 KiB
TypeScript
/**
|
|
* Enforced JSON Schema subset shared by tool outputs, generated Code Mode
|
|
* types, subagents, and workflows. The subset accepts any JSON root, an
|
|
* annotation-only schema for unconstrained JSON, one scalar `type`, object
|
|
* `properties`/`required`/boolean `additionalProperties`, array `items`,
|
|
* type-correct scalar `enum`/`const`, and exact-one `oneOf`.
|
|
*
|
|
* Unsupported or misplaced keywords reject rather than being accepted without
|
|
* enforcement. Consumers that require an object root apply
|
|
* {@link assertObjectJsonSchema} at their own boundary.
|
|
* @module dsh-tools/json-schema
|
|
*/
|
|
|
|
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
|
import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
|
|
|
|
/** Scalar JSON values supported by `enum` and `const`. */
|
|
export type JsonSchemaScalar = string | number | boolean | null
|
|
|
|
/** Single-type keywords accepted by the enforced subset. */
|
|
export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
|
|
|
|
/** Scalar-only schema types accepted by literal constraints. */
|
|
type JsonSchemaScalarType = Exclude<JsonSchemaType, 'object' | 'array'>
|
|
|
|
/**
|
|
* One raw JSON Schema node in the enforced subset. The optional fields express
|
|
* the external wire shape; {@link assertSupportedJsonSchema} rejects invalid
|
|
* combinations before a caller treats the node as trusted.
|
|
*/
|
|
export interface JsonSchemaNode {
|
|
/** Omit with no constraints for any JSON value, or use `oneOf`. */
|
|
type?: JsonSchemaType
|
|
/** Exactly one branch must validate; at least two branches are required. */
|
|
oneOf?: JsonSchemaNode[]
|
|
/** Nested property schemas (`type: 'object'` only). */
|
|
properties?: Record<string, JsonSchemaNode>
|
|
/** Required property names; each must appear in `properties`. */
|
|
required?: string[]
|
|
/** `false` rejects undeclared keys; absent/`true` follows JSON Schema's open default. */
|
|
additionalProperties?: boolean
|
|
/** Item schema (`type: 'array'` only); absent accepts any JSON item. */
|
|
items?: JsonSchemaNode
|
|
/** Allowed values for a scalar node. */
|
|
enum?: JsonSchemaScalar[]
|
|
/** The single allowed value for a scalar node. */
|
|
const?: JsonSchemaScalar
|
|
/** Annotation, ignored for validation. */
|
|
description?: string
|
|
/** Annotation, ignored for validation. */
|
|
title?: string
|
|
/** Annotation, ignored for validation but required to be lossless JSON. */
|
|
default?: JsonValue
|
|
/** Annotation, ignored for validation but required to be lossless JSON. */
|
|
examples?: JsonValue
|
|
}
|
|
|
|
/** A consumer-constrained object-rooted schema. */
|
|
export type ObjectJsonSchema = JsonSchemaNode & { type: 'object' }
|
|
|
|
/**
|
|
* Thrown when a raw schema falls outside the enforced subset. `violations`
|
|
* lists every offending path instead of stopping at the first author error.
|
|
*/
|
|
export class JsonSchemaError extends HarnessError {
|
|
/** Individual schema violations in walk order. */
|
|
readonly violations: string[]
|
|
|
|
constructor(violations: string[]) {
|
|
super(`unsupported JSON schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
|
|
this.name = 'JsonSchemaError'
|
|
this.violations = violations
|
|
}
|
|
}
|
|
|
|
const CONSTRAINT_KEYWORDS = new Set([
|
|
'type',
|
|
'oneOf',
|
|
'properties',
|
|
'required',
|
|
'additionalProperties',
|
|
'items',
|
|
'enum',
|
|
'const',
|
|
])
|
|
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.
|
|
* @param value - candidate record from any JavaScript realm.
|
|
* @returns Whether the value has a plain-object prototype chain.
|
|
*/
|
|
export function isPlainJsonRecord(value: unknown): value is Record<string, unknown> {
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
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. */
|
|
function isJsonNumber(value: unknown): value is number {
|
|
return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)
|
|
}
|
|
|
|
/** Whether a scalar is valid for one declared schema type. */
|
|
function scalarMatches(type: JsonSchemaScalarType, value: unknown): value is JsonSchemaScalar {
|
|
switch (type) {
|
|
case 'string': return typeof value === 'string'
|
|
case 'number': return isJsonNumber(value)
|
|
case 'integer': return isJsonNumber(value) && Number.isInteger(value)
|
|
case 'boolean': return typeof value === 'boolean'
|
|
case 'null': return value === null
|
|
/* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */
|
|
default: return assertNever(type, 'JsonSchemaType')
|
|
}
|
|
}
|
|
|
|
/** Deferred work for the stack-safe raw-schema walk. */
|
|
type SchemaWalkTask =
|
|
| { kind: 'enter'; node: unknown; path: string }
|
|
| { kind: 'leave'; node: object }
|
|
| { kind: 'one-of-tail'; node: Record<string, unknown>; path: string }
|
|
| { kind: 'object-tail'; node: Record<string, unknown>; path: string; properties: unknown }
|
|
|
|
/** Keywords that are invalid beside `oneOf`. */
|
|
const ONE_OF_SIBLING_KEYWORDS = ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const'] as const
|
|
|
|
/** Validate object-only fields after its property schemas have been visited. */
|
|
function checkObjectSchemaTail(
|
|
node: Record<string, unknown>,
|
|
path: string,
|
|
properties: unknown,
|
|
violations: string[],
|
|
): void {
|
|
const 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 = 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`)
|
|
}
|
|
}
|
|
}
|
|
if (Object.hasOwn(node, 'additionalProperties') && typeof node.additionalProperties !== 'boolean') {
|
|
violations.push(`${path}.additionalProperties must be a boolean`)
|
|
}
|
|
}
|
|
|
|
/** Collect every violation for one raw schema tree without using the JavaScript call stack. */
|
|
function checkSchemaNode(root: unknown, rootPath: string, violations: string[], seen: Set<object>): void {
|
|
const tasks: SchemaWalkTask[] = [{ kind: 'enter', node: root, path: rootPath }]
|
|
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
|
if (task.kind === 'leave') {
|
|
seen.delete(task.node)
|
|
continue
|
|
}
|
|
if (task.kind === 'one-of-tail') {
|
|
for (const key of ONE_OF_SIBLING_KEYWORDS) {
|
|
if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`)
|
|
}
|
|
continue
|
|
}
|
|
if (task.kind === 'object-tail') {
|
|
checkObjectSchemaTail(task.node, task.path, task.properties, violations)
|
|
continue
|
|
}
|
|
|
|
const { node, path } = task
|
|
if (!isJsonSchemaRecord(node)) {
|
|
violations.push(`${path} must be a schema object`)
|
|
continue
|
|
}
|
|
if (seen.has(node)) {
|
|
violations.push(`${path} is circular`)
|
|
continue
|
|
}
|
|
seen.add(node)
|
|
tasks.push({ kind: 'leave', node })
|
|
|
|
for (const key of Object.keys(node)) {
|
|
if (CONSTRAINT_KEYWORDS.has(key)) continue
|
|
if (ANNOTATION_KEYWORDS.has(key)) {
|
|
try {
|
|
if (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`)
|
|
} catch {
|
|
violations.push(`${path}.${key} annotation must be lossless JSON data`)
|
|
}
|
|
continue
|
|
}
|
|
violations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`)
|
|
}
|
|
if (Object.hasOwn(node, 'description') && typeof node.description !== 'string') {
|
|
violations.push(`${path}.description must be a string`)
|
|
}
|
|
if (Object.hasOwn(node, 'title') && typeof node.title !== 'string') {
|
|
violations.push(`${path}.title must be a string`)
|
|
}
|
|
|
|
const hasType = Object.hasOwn(node, 'type')
|
|
const hasOneOf = Object.hasOwn(node, 'oneOf')
|
|
if (hasType && hasOneOf) {
|
|
violations.push(`${path} cannot declare both type and oneOf`)
|
|
continue
|
|
}
|
|
if (!hasType && !hasOneOf) {
|
|
for (const key of ONE_OF_SIBLING_KEYWORDS) {
|
|
if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (hasOneOf) {
|
|
const oneOf = node.oneOf
|
|
tasks.push({ kind: 'one-of-tail', node, path })
|
|
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--) {
|
|
tasks.push({ kind: 'enter', node: oneOf[index], path: `${path}.oneOf[${index}]` })
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
const type = node.type
|
|
if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) {
|
|
violations.push(Array.isArray(type)
|
|
? `${path}.type must be a single type string (type arrays are not supported)`
|
|
: `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
|
|
continue
|
|
}
|
|
const schemaType = type as JsonSchemaType
|
|
const allowedFor: Record<string, JsonSchemaType[]> = {
|
|
properties: ['object'],
|
|
required: ['object'],
|
|
additionalProperties: ['object'],
|
|
items: ['array'],
|
|
enum: ['string', 'number', 'integer', 'boolean', 'null'],
|
|
const: ['string', 'number', 'integer', 'boolean', 'null'],
|
|
}
|
|
for (const [key, types] of Object.entries(allowedFor)) {
|
|
if (Object.hasOwn(node, key) && !types.includes(schemaType)) {
|
|
violations.push(`${path}.${key} is not supported on type "${schemaType}"`)
|
|
}
|
|
}
|
|
|
|
switch (schemaType) {
|
|
case 'object': {
|
|
const properties = Object.hasOwn(node, 'properties') ? node.properties : undefined
|
|
tasks.push({ kind: 'object-tail', node, path, properties })
|
|
if (Object.hasOwn(node, 'properties')) {
|
|
if (!isJsonSchemaRecord(properties)) {
|
|
violations.push(`${path}.properties must be an object of schemas`)
|
|
} else {
|
|
const entries = Object.entries(properties)
|
|
for (let index = entries.length - 1; index >= 0; index--) {
|
|
const entry = entries[index]
|
|
/* v8 ignore next -- the loop is bounded by the captured entry count. */
|
|
if (entry === undefined) continue
|
|
tasks.push({ kind: 'enter', node: entry[1], path: `${path}.properties.${entry[0]}` })
|
|
}
|
|
}
|
|
}
|
|
break
|
|
}
|
|
case 'array': {
|
|
if (Object.hasOwn(node, 'items')) tasks.push({ kind: 'enter', node: node.items, path: `${path}.items` })
|
|
break
|
|
}
|
|
case 'string':
|
|
case 'number':
|
|
case 'integer':
|
|
case 'boolean':
|
|
case 'null': {
|
|
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 (hasEnum && !enumValid) {
|
|
violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
|
|
}
|
|
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(declaredConst)) {
|
|
violations.push(`${path}.const must be one of ${path}.enum when both are declared`)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */
|
|
default: assertNever(schemaType, 'JsonSchemaType')
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assert that an arbitrary raw schema uses only the enforced subset.
|
|
* Annotation-only schemas are accepted as the standard unconstrained-JSON
|
|
* form; callers that require an object root use {@link assertObjectJsonSchema}.
|
|
* @param schema - untrusted raw JSON Schema.
|
|
* @returns Assertion that the schema belongs to the supported subset.
|
|
*/
|
|
export function assertSupportedJsonSchema(schema: unknown): asserts schema is JsonSchemaNode {
|
|
const violations: string[] = []
|
|
checkSchemaNode(schema, 'schema', violations, new Set())
|
|
if (violations.length > 0) throw new JsonSchemaError(violations)
|
|
}
|
|
|
|
/**
|
|
* Assert the enforced subset plus the object-root constraint retained by
|
|
* subagent and workflow structured outputs.
|
|
* @param schema - untrusted caller-supplied schema.
|
|
* @returns Assertion that the schema belongs to the supported subset and has an object root.
|
|
*/
|
|
export function assertObjectJsonSchema(schema: unknown): asserts schema is ObjectJsonSchema {
|
|
const violations: string[] = []
|
|
checkSchemaNode(schema, 'schema', violations, new Set())
|
|
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)
|
|
}
|
|
|
|
/** Safely test the lossless JSON boundary when a getter may throw. */
|
|
function safelyIsJsonValue(value: unknown): boolean {
|
|
try {
|
|
return isJsonValue(value)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/** Root-aware diagnostic path for the parameter validator's empty sentinel. */
|
|
function diagnosticPath(path: string): string {
|
|
return path === '' ? 'arguments' : path
|
|
}
|
|
|
|
/** Append one object property without a leading dot at an implicit root. */
|
|
function propertyPath(path: string, key: string): string {
|
|
return path === '' ? key : `${path}.${key}`
|
|
}
|
|
|
|
/** One child evaluation deferred by a container or exact-one union frame. */
|
|
interface ValueChild {
|
|
readonly node: JsonSchemaNode
|
|
readonly value: unknown
|
|
readonly path: string
|
|
}
|
|
|
|
/** Explicit call frame for stack-safe schema-value validation. */
|
|
interface ValueFrame {
|
|
readonly node: JsonSchemaNode
|
|
readonly value: unknown
|
|
readonly path: string
|
|
catches: boolean
|
|
phase: 'start' | 'children'
|
|
kind?: 'oneOf' | 'object' | 'array'
|
|
children: ValueChild[]
|
|
childIndex: number
|
|
violations: string[]
|
|
tailViolations: string[]
|
|
matches: number
|
|
}
|
|
|
|
/** The generic exception-containment diagnostic owned by one valid schema node. */
|
|
function losslessValueViolation(path: string): string[] {
|
|
return [`"${diagnosticPath(path)}" must be a lossless JSON value`]
|
|
}
|
|
|
|
/** Append diagnostics without spreading a potentially wide child result as call arguments. */
|
|
function appendViolations(target: string[], source: readonly string[]): void {
|
|
for (const violation of source) target.push(violation)
|
|
}
|
|
|
|
/** Initialize one validation frame with empty aggregation state. */
|
|
function valueFrame(node: JsonSchemaNode, value: unknown, path: string): ValueFrame {
|
|
return {
|
|
node,
|
|
value,
|
|
path,
|
|
catches: false,
|
|
phase: 'start',
|
|
children: [],
|
|
childIndex: 0,
|
|
violations: [],
|
|
tailViolations: [],
|
|
matches: 0,
|
|
}
|
|
}
|
|
|
|
/** Validate one scalar node after its primitive type check. */
|
|
function checkScalarValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
|
|
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)}`]
|
|
}
|
|
return []
|
|
}
|
|
|
|
/** Validate one trusted schema/value pair with explicit frames rather than recursive calls. */
|
|
function checkValue(schema: JsonSchemaNode, value: unknown, path: string): string[] {
|
|
const frames: ValueFrame[] = [valueFrame(schema, value, path)]
|
|
let rootResult: string[] | undefined
|
|
|
|
const receive = (result: string[]): void => {
|
|
const parent = frames.at(-1)
|
|
if (parent === undefined) {
|
|
rootResult = result
|
|
return
|
|
}
|
|
if (parent.kind === 'oneOf') {
|
|
if (result.length === 0) parent.matches++
|
|
} else {
|
|
appendViolations(parent.violations, result)
|
|
}
|
|
}
|
|
const finish = (result: string[]): void => {
|
|
frames.pop()
|
|
receive(result)
|
|
}
|
|
|
|
while (frames.length > 0) {
|
|
const frame = frames.at(-1)
|
|
/* v8 ignore next -- the loop condition guarantees a current frame. */
|
|
if (frame === undefined) break
|
|
try {
|
|
if (frame.phase === 'children') {
|
|
if (frame.childIndex < frame.children.length) {
|
|
const child = frame.children[frame.childIndex]
|
|
/* v8 ignore next -- childIndex is bounded by children.length. */
|
|
if (child === undefined) throw new Error('missing schema-value child frame')
|
|
frame.childIndex++
|
|
frames.push(valueFrame(child.node, child.value, child.path))
|
|
continue
|
|
}
|
|
if (frame.kind === 'oneOf') {
|
|
finish(frame.matches === 1 ? [] : [`"${diagnosticPath(frame.path)}" must match exactly one oneOf branch (matched ${frame.matches})`])
|
|
continue
|
|
}
|
|
appendViolations(frame.violations, frame.tailViolations)
|
|
if (frame.violations.length > 0) {
|
|
finish(frame.violations)
|
|
} else if (frame.kind === 'object') {
|
|
finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a lossless JSON object`])
|
|
} else {
|
|
finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a dense lossless JSON array`])
|
|
}
|
|
continue
|
|
}
|
|
|
|
const nodeType = Object.hasOwn(frame.node, 'type') ? frame.node.type : undefined
|
|
frame.catches = !(nodeType !== undefined && !(SCHEMA_TYPES as readonly unknown[]).includes(nodeType))
|
|
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 }))
|
|
frame.childIndex = 0
|
|
frame.matches = 0
|
|
frame.phase = 'children'
|
|
continue
|
|
}
|
|
if (nodeType === undefined) {
|
|
finish(safelyIsJsonValue(frame.value) ? [] : losslessValueViolation(frame.path))
|
|
continue
|
|
}
|
|
|
|
switch (nodeType) {
|
|
case 'object': {
|
|
if (!isPlainJsonRecord(frame.value)) {
|
|
finish([`"${diagnosticPath(frame.path)}" must be an object`])
|
|
break
|
|
}
|
|
const properties = Object.hasOwn(frame.node, 'properties') ? frame.node.properties ?? {} : {}
|
|
const violations: string[] = []
|
|
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)}"`)
|
|
}
|
|
}
|
|
const children: ValueChild[] = []
|
|
for (const [key, child] of Object.entries(properties)) {
|
|
if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) continue
|
|
children.push({ node: child, value: frame.value[key], path: propertyPath(frame.path, key) })
|
|
}
|
|
const tailViolations: string[] = []
|
|
if (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)`)
|
|
}
|
|
}
|
|
}
|
|
frame.kind = 'object'
|
|
frame.children = children
|
|
frame.childIndex = 0
|
|
frame.violations = violations
|
|
frame.tailViolations = tailViolations
|
|
frame.phase = 'children'
|
|
break
|
|
}
|
|
case 'array': {
|
|
if (!Array.isArray(frame.value)) {
|
|
finish([`"${diagnosticPath(frame.path)}" must be an array`])
|
|
break
|
|
}
|
|
const items = 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}]` }])
|
|
frame.kind = 'array'
|
|
frame.children = children
|
|
frame.childIndex = 0
|
|
frame.violations = []
|
|
frame.phase = 'children'
|
|
break
|
|
}
|
|
case 'string':
|
|
finish(typeof frame.value === 'string'
|
|
? checkScalarValue(frame.node, frame.value, frame.path)
|
|
: [`"${diagnosticPath(frame.path)}" must be a string`])
|
|
break
|
|
case 'number':
|
|
finish(typeof frame.value !== 'number'
|
|
? [`"${diagnosticPath(frame.path)}" must be a number`]
|
|
: !isJsonNumber(frame.value)
|
|
? [`"${diagnosticPath(frame.path)}" must be a finite JSON number`]
|
|
: checkScalarValue(frame.node, frame.value, frame.path))
|
|
break
|
|
case 'integer':
|
|
finish(!isJsonNumber(frame.value) || !Number.isInteger(frame.value)
|
|
? [`"${diagnosticPath(frame.path)}" must be an integer`]
|
|
: checkScalarValue(frame.node, frame.value, frame.path))
|
|
break
|
|
case 'boolean':
|
|
finish(typeof frame.value === 'boolean'
|
|
? checkScalarValue(frame.node, frame.value, frame.path)
|
|
: [`"${diagnosticPath(frame.path)}" must be a boolean`])
|
|
break
|
|
case 'null':
|
|
finish(frame.value === null
|
|
? checkScalarValue(frame.node, frame.value, frame.path)
|
|
: [`"${diagnosticPath(frame.path)}" must be null`])
|
|
break
|
|
default:
|
|
finish(assertNever(nodeType, 'JsonSchemaType'))
|
|
}
|
|
} catch (error) {
|
|
let failed = frames.pop()
|
|
while (failed !== undefined && !failed.catches) failed = frames.pop()
|
|
if (failed === undefined) throw error
|
|
receive(losslessValueViolation(failed.path))
|
|
}
|
|
}
|
|
|
|
/* v8 ignore next -- every root frame finishes or throws. */
|
|
return rootResult ?? losslessValueViolation(path)
|
|
}
|
|
|
|
/**
|
|
* Validate a candidate value against an asserted raw schema. The function is
|
|
* total for arbitrary values and returns path-qualified violations.
|
|
* @param schema - a schema accepted by {@link assertSupportedJsonSchema}.
|
|
* @param value - the candidate JSON value.
|
|
* @param path - root label used in diagnostics.
|
|
* @returns All violations in walk order; empty means valid.
|
|
*/
|
|
export function validateJsonSchemaValue(schema: JsonSchemaNode, value: unknown, path = 'value'): string[] {
|
|
return checkValue(schema, value, path)
|
|
}
|