-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathjson-schema.ts
More file actions
656 lines (610 loc) · 25.7 KB
/
Copy pathjson-schema.ts
File metadata and controls
656 lines (610 loc) · 25.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
/**
* Enforced JSON Schema subset shared by tool outputs, generated PTC 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} before accepting input.
* @module dsh-tools/json-schema
*/
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { assertNever, isJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
/** 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 schema; {@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)
}