2026-07-06 23:29:08 +08:00
/**
2026-07-21 01:11:55 +08:00
* 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.
2026-07-06 23:29:08 +08:00
* @module dsh-tools/json-schema
*/
import { assertNever , HarnessError } from '@deepseek-ai/dsh-llm'
2026-07-21 01:11:55 +08:00
import { isJsonValue , type JsonValue } from '@deepseek-ai/dsh-session'
2026-07-06 23:29:08 +08:00
2026-07-21 01:11:55 +08:00
/** Scalar JSON values supported by `enum` and `const`. */
export type JsonSchemaScalar = string | number | boolean | null
2026-07-06 23:29:08 +08:00
2026-07-21 01:11:55 +08:00
/** 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' >
2026-07-06 23:29:08 +08:00
/**
2026-07-21 01:11:55 +08:00
* 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.
2026-07-06 23:29:08 +08:00
*/
2026-07-21 01:11:55 +08:00
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 [ ]
2026-07-06 23:29:08 +08:00
/** Nested property schemas (`type: 'object'` only). */
2026-07-21 01:11:55 +08:00
properties? : Record < string , JsonSchemaNode >
2026-07-06 23:29:08 +08:00
/** Required property names; each must appear in `properties`. */
required? : string [ ]
2026-07-21 01:11:55 +08:00
/** `false` rejects undeclared keys; absent/`true` follows JSON Schema's open default. */
2026-07-06 23:29:08 +08:00
additionalProperties? : boolean
2026-07-21 01:11:55 +08:00
/** 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
2026-07-06 23:29:08 +08:00
/** Annotation, ignored for validation. */
description? : string
/** Annotation, ignored for validation. */
title? : string
2026-07-21 01:11:55 +08:00
/** Annotation, ignored for validation but required to be lossless JSON. */
default ? : JsonValue
/** Annotation, ignored for validation but required to be lossless JSON. */
examples? : JsonValue
2026-07-06 23:29:08 +08:00
}
2026-07-21 01:11:55 +08:00
/** A consumer-constrained object-rooted schema. */
export type ObjectJsonSchema = JsonSchemaNode & { type : 'object' }
2026-07-06 23:29:08 +08:00
/**
2026-07-21 01:11:55 +08:00
* Thrown when a raw schema falls outside the enforced subset. `violations`
* lists every offending path instead of stopping at the first author error.
2026-07-06 23:29:08 +08:00
*/
2026-07-21 01:11:55 +08:00
export class JsonSchemaError extends HarnessError {
/** Individual schema violations in walk order. */
2026-07-06 23:29:08 +08:00
readonly violations : string [ ]
constructor ( violations : string [ ] ) {
2026-07-21 01:11:55 +08:00
super ( ` unsupported JSON schema: ${ violations . join ( '; ' ) } ` , 'UNSUPPORTED_SCHEMA' )
this . name = 'JsonSchemaError'
2026-07-06 23:29:08 +08:00
this . violations = violations
}
}
2026-07-21 01:11:55 +08:00
const CONSTRAINT_KEYWORDS = new Set ( [
'type' ,
'oneOf' ,
'properties' ,
'required' ,
'additionalProperties' ,
'items' ,
'enum' ,
'const' ,
] )
2026-07-06 23:29:08 +08:00
const ANNOTATION_KEYWORDS = new Set ( [ 'description' , 'title' , 'default' , 'examples' ] )
2026-07-21 01:11:55 +08:00
const SCHEMA_TYPES : readonly JsonSchemaType [ ] = [ 'object' , 'array' , 'string' , 'number' , 'integer' , 'boolean' , 'null' ]
2026-07-06 23:29:08 +08:00
2026-07-23 01:05:41 +08:00
/* 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' )
}
2026-07-07 21:06:14 +08:00
/**
2026-07-21 01:11:55 +08:00
* 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.
2026-07-07 21:06:14 +08:00
*/
2026-07-21 01:11:55 +08:00
export function isPlainJsonRecord ( value : unknown ) : value is Record < string , unknown > {
2026-07-07 21:06:14 +08:00
if ( typeof value !== 'object' || value === null || Array . isArray ( value ) ) return false
2026-07-23 01:05:41 +08:00
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
}
2026-07-06 23:29:08 +08:00
}
2026-07-21 01:11:55 +08:00
/** 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 )
2026-07-06 23:29:08 +08:00
}
2026-07-21 01:11:55 +08:00
/** 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' )
2026-07-06 23:29:08 +08:00
}
}
2026-07-22 20:51:13 +08:00
/** 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 {
2026-07-23 01:05:41 +08:00
const hasRequired = Object . hasOwn ( node , 'required' )
const required = hasRequired ? node.required : undefined
if ( hasRequired ) {
if ( ! isPlainJsonArray ( required ) || required . some ( entry = > typeof entry !== 'string' ) ) {
2026-07-22 20:51:13 +08:00
violations . push ( ` ${ path } .required must be an array of strings ` )
} else {
2026-07-23 01:05:41 +08:00
const declared = isJsonSchemaRecord ( properties ) ? properties : { }
2026-07-22 20:51:13 +08:00
for ( const key of required as string [ ] ) {
if ( ! Object . hasOwn ( declared , key ) ) violations . push ( ` ${ path } .required names " ${ key } " which is not in properties ` )
}
}
2026-07-06 23:29:08 +08:00
}
2026-07-22 20:51:13 +08:00
if ( Object . hasOwn ( node , 'additionalProperties' ) && typeof node . additionalProperties !== 'boolean' ) {
violations . push ( ` ${ path } .additionalProperties must be a boolean ` )
2026-07-06 23:29:08 +08:00
}
2026-07-22 20:51:13 +08:00
}
/** 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
2026-07-23 01:05:41 +08:00
if ( ! isJsonSchemaRecord ( node ) ) {
2026-07-22 20:51:13 +08:00
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 } )
2026-07-21 01:11:55 +08:00
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) ` )
}
2026-07-23 01:05:41 +08:00
if ( Object . hasOwn ( node , 'description' ) && typeof node . description !== 'string' ) {
2026-07-21 01:11:55 +08:00
violations . push ( ` ${ path } .description must be a string ` )
}
2026-07-23 01:05:41 +08:00
if ( Object . hasOwn ( node , 'title' ) && typeof node . title !== 'string' ) {
2026-07-21 01:11:55 +08:00
violations . push ( ` ${ path } .title must be a string ` )
}
2026-07-06 23:29:08 +08:00
2026-07-21 01:11:55 +08:00
const hasType = Object . hasOwn ( node , 'type' )
const hasOneOf = Object . hasOwn ( node , 'oneOf' )
if ( hasType && hasOneOf ) {
violations . push ( ` ${ path } cannot declare both type and oneOf ` )
2026-07-22 20:51:13 +08:00
continue
2026-07-21 01:11:55 +08:00
}
if ( ! hasType && ! hasOneOf ) {
2026-07-22 20:51:13 +08:00
for ( const key of ONE_OF_SIBLING_KEYWORDS ) {
2026-07-21 01:11:55 +08:00
if ( Object . hasOwn ( node , key ) ) violations . push ( ` ${ path } . ${ key } requires type or oneOf ` )
}
2026-07-22 20:51:13 +08:00
continue
2026-07-06 23:29:08 +08:00
}
2026-07-21 01:11:55 +08:00
if ( hasOneOf ) {
const oneOf = node . oneOf
2026-07-22 20:51:13 +08:00
tasks . push ( { kind : 'one-of-tail' , node , path } )
2026-07-23 01:05:41 +08:00
if ( ! isPlainJsonArray ( oneOf ) || oneOf . length < 2 ) {
2026-07-21 01:11:55 +08:00
violations . push ( ` ${ path } .oneOf must be an array of at least two schemas ` )
} else {
2026-07-22 20:51:13 +08:00
for ( let index = oneOf . length - 1 ; index >= 0 ; index -- ) {
tasks . push ( { kind : 'enter' , node : oneOf [ index ] , path : ` ${ path } .oneOf[ ${ index } ] ` } )
2026-07-21 01:11:55 +08:00
}
}
2026-07-22 20:51:13 +08:00
continue
2026-07-21 01:11:55 +08:00
}
2026-07-06 23:29:08 +08:00
2026-07-21 01:11:55 +08:00
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 ( '/' ) } ` )
2026-07-22 20:51:13 +08:00
continue
2026-07-21 01:11:55 +08:00
}
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 } " ` )
}
2026-07-06 23:29:08 +08:00
}
2026-07-21 01:11:55 +08:00
switch ( schemaType ) {
case 'object' : {
2026-07-23 01:05:41 +08:00
const properties = Object . hasOwn ( node , 'properties' ) ? node.properties : undefined
2026-07-22 20:51:13 +08:00
tasks . push ( { kind : 'object-tail' , node , path , properties } )
2026-07-21 01:11:55 +08:00
if ( Object . hasOwn ( node , 'properties' ) ) {
2026-07-23 01:05:41 +08:00
if ( ! isJsonSchemaRecord ( properties ) ) {
2026-07-21 01:11:55 +08:00
violations . push ( ` ${ path } .properties must be an object of schemas ` )
} else {
2026-07-22 20:51:13 +08:00
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 ] } ` } )
2026-07-21 01:11:55 +08:00
}
2026-07-06 23:29:08 +08:00
}
}
2026-07-21 01:11:55 +08:00
break
2026-07-06 23:29:08 +08:00
}
2026-07-21 01:11:55 +08:00
case 'array' : {
2026-07-22 20:51:13 +08:00
if ( Object . hasOwn ( node , 'items' ) ) tasks . push ( { kind : 'enter' , node : node.items , path : ` ${ path } .items ` } )
2026-07-21 01:11:55 +08:00
break
2026-07-06 23:29:08 +08:00
}
2026-07-21 01:11:55 +08:00
case 'string' :
case 'number' :
case 'integer' :
case 'boolean' :
case 'null' : {
2026-07-23 01:05:41 +08:00
const hasEnum = Object . hasOwn ( node , 'enum' )
const allowed = hasEnum ? node.enum : undefined
const enumValid = isPlainJsonArray ( allowed )
2026-07-21 17:44:46 +08:00
&& allowed . length > 0
&& allowed . every ( entry = > scalarMatches ( schemaType , entry ) )
2026-07-23 01:05:41 +08:00
if ( hasEnum && ! enumValid ) {
2026-07-22 20:51:13 +08:00
violations . push ( ` ${ path } .enum must be a non-empty array of ${ schemaType } values ` )
2026-07-06 23:29:08 +08:00
}
2026-07-23 01:05:41 +08:00
const hasConst = Object . hasOwn ( node , 'const' )
const declaredConst = hasConst ? node.const : undefined
const constValid = scalarMatches ( schemaType , declaredConst )
if ( hasConst ) {
2026-07-21 17:44:46 +08:00
if ( ! constValid ) {
violations . push ( ` ${ path } .const must be a ${ schemaType } value ` )
2026-07-23 01:05:41 +08:00
} else if ( enumValid && ! allowed . includes ( declaredConst ) ) {
2026-07-21 17:44:46 +08:00
violations . push ( ` ${ path } .const must be one of ${ path } .enum when both are declared ` )
}
2026-07-21 01:11:55 +08:00
}
break
2026-07-06 23:29:08 +08:00
}
2026-07-21 01:11:55 +08:00
/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */
default : assertNever ( schemaType , 'JsonSchemaType' )
2026-07-06 23:29:08 +08:00
}
}
2026-07-21 01:11:55 +08:00
}
2026-07-06 23:29:08 +08:00
2026-07-21 01:11:55 +08:00
/**
* 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 )
2026-07-06 23:29:08 +08:00
}
/**
2026-07-21 01:11:55 +08:00
* 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.
2026-07-06 23:29:08 +08:00
*/
2026-07-21 01:11:55 +08:00
export function assertObjectJsonSchema ( schema : unknown ) : asserts schema is ObjectJsonSchema {
2026-07-06 23:29:08 +08:00
const violations : string [ ] = [ ]
checkSchemaNode ( schema , 'schema' , violations , new Set ( ) )
2026-07-23 01:05:41 +08:00
if ( violations . length === 0
&& ( ! isJsonSchemaRecord ( schema ) || ! Object . hasOwn ( schema , 'type' ) || schema . type !== 'object' ) ) {
2026-07-06 23:29:08 +08:00
violations . push ( 'schema.type must be "object" (structured output is object-rooted)' )
}
2026-07-21 01:11:55 +08:00
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
}
2026-07-06 23:29:08 +08:00
}
2026-07-21 01:11:55 +08:00
/** 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 } `
}
2026-07-22 20:51:13 +08:00
/** 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 ,
2026-07-21 17:44:46 +08:00
}
2026-07-22 20:51:13 +08:00
}
/** Validate one scalar node after its primitive type check. */
function checkScalarValue ( node : JsonSchemaNode , value : unknown , path : string ) : string [ ] {
2026-07-23 01:05:41 +08:00
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 ) } ` ]
2026-07-21 17:44:46 +08:00
}
2026-07-22 20:51:13 +08:00
if ( Object . hasOwn ( node , 'const' ) && value !== node . const ) {
return [ ` " ${ diagnosticPath ( path ) } " must be ${ JSON . stringify ( node . const ) } ` ]
}
return [ ]
2026-07-21 17:44:46 +08:00
}
2026-07-22 20:51:13 +08:00
/** 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 )
}
2026-07-21 01:11:55 +08:00
}
2026-07-22 20:51:13 +08:00
const finish = ( result : string [ ] ) : void = > {
frames . pop ( )
receive ( result )
2026-07-21 01:11:55 +08:00
}
2026-07-22 20:51:13 +08:00
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
2026-07-06 23:29:08 +08:00
}
2026-07-22 20:51:13 +08:00
2026-07-23 01:05:41 +08:00
const nodeType = Object . hasOwn ( frame . node , 'type' ) ? frame.node.type : undefined
2026-07-22 20:51:13 +08:00
frame . catches = ! ( nodeType !== undefined && ! ( SCHEMA_TYPES as readonly unknown [ ] ) . includes ( nodeType ) )
2026-07-23 01:05:41 +08:00
const oneOf = Object . hasOwn ( frame . node , 'oneOf' ) ? frame.node.oneOf : undefined
2026-07-22 20:51:13 +08:00
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
2026-07-06 23:29:08 +08:00
}
2026-07-22 20:51:13 +08:00
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
}
2026-07-23 01:05:41 +08:00
const properties = Object . hasOwn ( frame . node , 'properties' ) ? frame . node . properties ? ? { } : { }
2026-07-22 20:51:13 +08:00
const violations : string [ ] = [ ]
2026-07-23 01:05:41 +08:00
const required = Object . hasOwn ( frame . node , 'required' ) ? frame . node . required ? ? [ ] : [ ]
for ( const key of required ) {
2026-07-22 20:51:13 +08:00
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 [ ] = [ ]
2026-07-23 01:05:41 +08:00
if ( Object . hasOwn ( frame . node , 'additionalProperties' ) && frame . node . additionalProperties === false ) {
2026-07-22 20:51:13 +08:00
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
}
2026-07-23 01:05:41 +08:00
const items = Object . hasOwn ( frame . node , 'items' ) ? frame.node.items : undefined
2026-07-22 20:51:13 +08:00
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
2026-07-06 23:29:08 +08:00
}
2026-07-22 20:51:13 +08:00
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' ) )
2026-07-06 23:29:08 +08:00
}
2026-07-22 20:51:13 +08:00
} catch ( error ) {
let failed = frames . pop ( )
while ( failed !== undefined && ! failed . catches ) failed = frames . pop ( )
if ( failed === undefined ) throw error
receive ( losslessValueViolation ( failed . path ) )
2026-07-06 23:29:08 +08:00
}
}
2026-07-22 20:51:13 +08:00
/* v8 ignore next -- every root frame finishes or throws. */
return rootResult ? ? losslessValueViolation ( path )
2026-07-06 23:29:08 +08:00
}
/**
2026-07-21 01:11:55 +08:00
* 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.
2026-07-06 23:29:08 +08:00
*/
2026-07-21 01:11:55 +08:00
export function validateJsonSchemaValue ( schema : JsonSchemaNode , value : unknown , path = 'value' ) : string [ ] {
return checkValue ( schema , value , path )
2026-07-06 23:29:08 +08:00
}