json-schema.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. /**
  2. * Enforced JSON Schema subset shared by tool outputs, generated Code Mode
  3. * types, subagents, and workflows. The subset accepts any JSON root, an
  4. * annotation-only schema for unconstrained JSON, one scalar `type`, object
  5. * `properties`/`required`/boolean `additionalProperties`, array `items`,
  6. * type-correct scalar `enum`/`const`, and exact-one `oneOf`.
  7. *
  8. * Unsupported or misplaced keywords reject rather than being accepted without
  9. * enforcement. Consumers that require an object root apply
  10. * {@link assertObjectJsonSchema} at their own boundary.
  11. * @module dsh-tools/json-schema
  12. */
  13. import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
  14. import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
  15. /** Scalar JSON values supported by `enum` and `const`. */
  16. export type JsonSchemaScalar = string | number | boolean | null
  17. /** Single-type keywords accepted by the enforced subset. */
  18. export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
  19. /** Scalar-only schema types accepted by literal constraints. */
  20. type JsonSchemaScalarType = Exclude<JsonSchemaType, 'object' | 'array'>
  21. /**
  22. * One raw JSON Schema node in the enforced subset. The optional fields express
  23. * the external wire shape; {@link assertSupportedJsonSchema} rejects invalid
  24. * combinations before a caller treats the node as trusted.
  25. */
  26. export interface JsonSchemaNode {
  27. /** Omit with no constraints for any JSON value, or use `oneOf`. */
  28. type?: JsonSchemaType
  29. /** Exactly one branch must validate; at least two branches are required. */
  30. oneOf?: JsonSchemaNode[]
  31. /** Nested property schemas (`type: 'object'` only). */
  32. properties?: Record<string, JsonSchemaNode>
  33. /** Required property names; each must appear in `properties`. */
  34. required?: string[]
  35. /** `false` rejects undeclared keys; absent/`true` follows JSON Schema's open default. */
  36. additionalProperties?: boolean
  37. /** Item schema (`type: 'array'` only); absent accepts any JSON item. */
  38. items?: JsonSchemaNode
  39. /** Allowed values for a scalar node. */
  40. enum?: JsonSchemaScalar[]
  41. /** The single allowed value for a scalar node. */
  42. const?: JsonSchemaScalar
  43. /** Annotation, ignored for validation. */
  44. description?: string
  45. /** Annotation, ignored for validation. */
  46. title?: string
  47. /** Annotation, ignored for validation but required to be lossless JSON. */
  48. default?: JsonValue
  49. /** Annotation, ignored for validation but required to be lossless JSON. */
  50. examples?: JsonValue
  51. }
  52. /** A consumer-constrained object-rooted schema. */
  53. export type ObjectJsonSchema = JsonSchemaNode & { type: 'object' }
  54. /**
  55. * Thrown when a raw schema falls outside the enforced subset. `violations`
  56. * lists every offending path instead of stopping at the first author error.
  57. */
  58. export class JsonSchemaError extends HarnessError {
  59. /** Individual schema violations in walk order. */
  60. readonly violations: string[]
  61. constructor(violations: string[]) {
  62. super(`unsupported JSON schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
  63. this.name = 'JsonSchemaError'
  64. this.violations = violations
  65. }
  66. }
  67. const CONSTRAINT_KEYWORDS = new Set([
  68. 'type',
  69. 'oneOf',
  70. 'properties',
  71. 'required',
  72. 'additionalProperties',
  73. 'items',
  74. 'enum',
  75. 'const',
  76. ])
  77. const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
  78. const SCHEMA_TYPES: readonly JsonSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
  79. /* jscpd:ignore-start -- this realm boundary mirrors the session-owned lossless-JSON intrinsic test */
  80. /** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
  81. function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
  82. const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
  83. const constructor: unknown = descriptor?.value
  84. if (typeof constructor !== 'function') return false
  85. try {
  86. return constructor.name === name
  87. && constructor.prototype === prototype
  88. && Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
  89. } catch {
  90. return false
  91. }
  92. }
  93. /** Whether a candidate is one realm's intrinsic `Object.prototype`. */
  94. function isIntrinsicObjectPrototype(value: object): boolean {
  95. return Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, 'Object')
  96. }
  97. /**
  98. * Test for a realm-agnostic plain JSON record without accepting arrays or
  99. * exotic objects.
  100. * @param value - candidate record from any JavaScript realm.
  101. * @returns Whether the value has a plain-object prototype chain.
  102. */
  103. export function isPlainJsonRecord(value: unknown): value is Record<string, unknown> {
  104. if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
  105. try {
  106. const prototype: unknown = Object.getPrototypeOf(value)
  107. return prototype === null
  108. || typeof prototype === 'object' && isIntrinsicObjectPrototype(prototype)
  109. } catch {
  110. return false
  111. }
  112. }
  113. /** Whether an array uses one realm's intrinsic `Array.prototype`. */
  114. function hasPlainArrayPrototype(value: unknown[]): boolean {
  115. const prototype: unknown = Object.getPrototypeOf(value)
  116. if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
  117. const objectPrototype: unknown = Object.getPrototypeOf(prototype)
  118. return typeof objectPrototype === 'object'
  119. && objectPrototype !== null
  120. && isIntrinsicObjectPrototype(objectPrototype)
  121. }
  122. /* jscpd:ignore-end */
  123. /** Return whether a record contains only own enumerable string keys. */
  124. function hasOnlyEnumerableStringKeys(value: object): boolean {
  125. try {
  126. return Reflect.ownKeys(value)
  127. .every(key => typeof key === 'string' && Object.prototype.propertyIsEnumerable.call(value, key))
  128. } catch {
  129. return false
  130. }
  131. }
  132. /**
  133. * Test for an ordinary schema record whose keys survive JSON projection.
  134. * @param value - candidate record from any JavaScript realm.
  135. * @returns Whether the record has an intrinsic prototype and only own enumerable string keys.
  136. */
  137. export function isJsonSchemaRecord(value: unknown): value is Record<string, unknown> {
  138. return isPlainJsonRecord(value) && hasOnlyEnumerableStringKeys(value)
  139. }
  140. /**
  141. * Test for a dense ordinary array with no JSON-invisible decorations.
  142. * @param value - candidate array from any JavaScript realm.
  143. * @returns Whether the array is intrinsic, dense, and undecorated.
  144. */
  145. export function isPlainJsonArray(value: unknown): value is unknown[] {
  146. if (!Array.isArray(value)) return false
  147. try {
  148. if (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) return false
  149. for (let index = 0; index < value.length; index++) {
  150. if (!Object.hasOwn(value, index)) return false
  151. }
  152. return true
  153. } catch {
  154. return false
  155. }
  156. }
  157. /** Lossless finite JSON number, excluding negative zero. */
  158. function isJsonNumber(value: unknown): value is number {
  159. return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)
  160. }
  161. /** Whether a scalar is valid for one declared schema type. */
  162. function scalarMatches(type: JsonSchemaScalarType, value: unknown): value is JsonSchemaScalar {
  163. switch (type) {
  164. case 'string': return typeof value === 'string'
  165. case 'number': return isJsonNumber(value)
  166. case 'integer': return isJsonNumber(value) && Number.isInteger(value)
  167. case 'boolean': return typeof value === 'boolean'
  168. case 'null': return value === null
  169. /* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */
  170. default: return assertNever(type, 'JsonSchemaType')
  171. }
  172. }
  173. /** Deferred work for the stack-safe raw-schema walk. */
  174. type SchemaWalkTask =
  175. | { kind: 'enter'; node: unknown; path: string }
  176. | { kind: 'leave'; node: object }
  177. | { kind: 'one-of-tail'; node: Record<string, unknown>; path: string }
  178. | { kind: 'object-tail'; node: Record<string, unknown>; path: string; properties: unknown }
  179. /** Keywords that are invalid beside `oneOf`. */
  180. const ONE_OF_SIBLING_KEYWORDS = ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const'] as const
  181. /** Validate object-only fields after its property schemas have been visited. */
  182. function checkObjectSchemaTail(
  183. node: Record<string, unknown>,
  184. path: string,
  185. properties: unknown,
  186. violations: string[],
  187. ): void {
  188. const hasRequired = Object.hasOwn(node, 'required')
  189. const required = hasRequired ? node.required : undefined
  190. if (hasRequired) {
  191. if (!isPlainJsonArray(required) || required.some(entry => typeof entry !== 'string')) {
  192. violations.push(`${path}.required must be an array of strings`)
  193. } else {
  194. const declared = isJsonSchemaRecord(properties) ? properties : {}
  195. for (const key of required as string[]) {
  196. if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
  197. }
  198. }
  199. }
  200. if (Object.hasOwn(node, 'additionalProperties') && typeof node.additionalProperties !== 'boolean') {
  201. violations.push(`${path}.additionalProperties must be a boolean`)
  202. }
  203. }
  204. /** Collect every violation for one raw schema tree without using the JavaScript call stack. */
  205. function checkSchemaNode(root: unknown, rootPath: string, violations: string[], seen: Set<object>): void {
  206. const tasks: SchemaWalkTask[] = [{ kind: 'enter', node: root, path: rootPath }]
  207. for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
  208. if (task.kind === 'leave') {
  209. seen.delete(task.node)
  210. continue
  211. }
  212. if (task.kind === 'one-of-tail') {
  213. for (const key of ONE_OF_SIBLING_KEYWORDS) {
  214. if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`)
  215. }
  216. continue
  217. }
  218. if (task.kind === 'object-tail') {
  219. checkObjectSchemaTail(task.node, task.path, task.properties, violations)
  220. continue
  221. }
  222. const { node, path } = task
  223. if (!isJsonSchemaRecord(node)) {
  224. violations.push(`${path} must be a schema object`)
  225. continue
  226. }
  227. if (seen.has(node)) {
  228. violations.push(`${path} is circular`)
  229. continue
  230. }
  231. seen.add(node)
  232. tasks.push({ kind: 'leave', node })
  233. for (const key of Object.keys(node)) {
  234. if (CONSTRAINT_KEYWORDS.has(key)) continue
  235. if (ANNOTATION_KEYWORDS.has(key)) {
  236. try {
  237. if (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`)
  238. } catch {
  239. violations.push(`${path}.${key} annotation must be lossless JSON data`)
  240. }
  241. continue
  242. }
  243. violations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`)
  244. }
  245. if (Object.hasOwn(node, 'description') && typeof node.description !== 'string') {
  246. violations.push(`${path}.description must be a string`)
  247. }
  248. if (Object.hasOwn(node, 'title') && typeof node.title !== 'string') {
  249. violations.push(`${path}.title must be a string`)
  250. }
  251. const hasType = Object.hasOwn(node, 'type')
  252. const hasOneOf = Object.hasOwn(node, 'oneOf')
  253. if (hasType && hasOneOf) {
  254. violations.push(`${path} cannot declare both type and oneOf`)
  255. continue
  256. }
  257. if (!hasType && !hasOneOf) {
  258. for (const key of ONE_OF_SIBLING_KEYWORDS) {
  259. if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`)
  260. }
  261. continue
  262. }
  263. if (hasOneOf) {
  264. const oneOf = node.oneOf
  265. tasks.push({ kind: 'one-of-tail', node, path })
  266. if (!isPlainJsonArray(oneOf) || oneOf.length < 2) {
  267. violations.push(`${path}.oneOf must be an array of at least two schemas`)
  268. } else {
  269. for (let index = oneOf.length - 1; index >= 0; index--) {
  270. tasks.push({ kind: 'enter', node: oneOf[index], path: `${path}.oneOf[${index}]` })
  271. }
  272. }
  273. continue
  274. }
  275. const type = node.type
  276. if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) {
  277. violations.push(Array.isArray(type)
  278. ? `${path}.type must be a single type string (type arrays are not supported)`
  279. : `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
  280. continue
  281. }
  282. const schemaType = type as JsonSchemaType
  283. const allowedFor: Record<string, JsonSchemaType[]> = {
  284. properties: ['object'],
  285. required: ['object'],
  286. additionalProperties: ['object'],
  287. items: ['array'],
  288. enum: ['string', 'number', 'integer', 'boolean', 'null'],
  289. const: ['string', 'number', 'integer', 'boolean', 'null'],
  290. }
  291. for (const [key, types] of Object.entries(allowedFor)) {
  292. if (Object.hasOwn(node, key) && !types.includes(schemaType)) {
  293. violations.push(`${path}.${key} is not supported on type "${schemaType}"`)
  294. }
  295. }
  296. switch (schemaType) {
  297. case 'object': {
  298. const properties = Object.hasOwn(node, 'properties') ? node.properties : undefined
  299. tasks.push({ kind: 'object-tail', node, path, properties })
  300. if (Object.hasOwn(node, 'properties')) {
  301. if (!isJsonSchemaRecord(properties)) {
  302. violations.push(`${path}.properties must be an object of schemas`)
  303. } else {
  304. const entries = Object.entries(properties)
  305. for (let index = entries.length - 1; index >= 0; index--) {
  306. const entry = entries[index]
  307. /* v8 ignore next -- the loop is bounded by the captured entry count. */
  308. if (entry === undefined) continue
  309. tasks.push({ kind: 'enter', node: entry[1], path: `${path}.properties.${entry[0]}` })
  310. }
  311. }
  312. }
  313. break
  314. }
  315. case 'array': {
  316. if (Object.hasOwn(node, 'items')) tasks.push({ kind: 'enter', node: node.items, path: `${path}.items` })
  317. break
  318. }
  319. case 'string':
  320. case 'number':
  321. case 'integer':
  322. case 'boolean':
  323. case 'null': {
  324. const hasEnum = Object.hasOwn(node, 'enum')
  325. const allowed = hasEnum ? node.enum : undefined
  326. const enumValid = isPlainJsonArray(allowed)
  327. && allowed.length > 0
  328. && allowed.every(entry => scalarMatches(schemaType, entry))
  329. if (hasEnum && !enumValid) {
  330. violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
  331. }
  332. const hasConst = Object.hasOwn(node, 'const')
  333. const declaredConst = hasConst ? node.const : undefined
  334. const constValid = scalarMatches(schemaType, declaredConst)
  335. if (hasConst) {
  336. if (!constValid) {
  337. violations.push(`${path}.const must be a ${schemaType} value`)
  338. } else if (enumValid && !allowed.includes(declaredConst)) {
  339. violations.push(`${path}.const must be one of ${path}.enum when both are declared`)
  340. }
  341. }
  342. break
  343. }
  344. /* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */
  345. default: assertNever(schemaType, 'JsonSchemaType')
  346. }
  347. }
  348. }
  349. /**
  350. * Assert that an arbitrary raw schema uses only the enforced subset.
  351. * Annotation-only schemas are accepted as the standard unconstrained-JSON
  352. * form; callers that require an object root use {@link assertObjectJsonSchema}.
  353. * @param schema - untrusted raw JSON Schema.
  354. * @returns Assertion that the schema belongs to the supported subset.
  355. */
  356. export function assertSupportedJsonSchema(schema: unknown): asserts schema is JsonSchemaNode {
  357. const violations: string[] = []
  358. checkSchemaNode(schema, 'schema', violations, new Set())
  359. if (violations.length > 0) throw new JsonSchemaError(violations)
  360. }
  361. /**
  362. * Assert the enforced subset plus the object-root constraint retained by
  363. * subagent and workflow structured outputs.
  364. * @param schema - untrusted caller-supplied schema.
  365. * @returns Assertion that the schema belongs to the supported subset and has an object root.
  366. */
  367. export function assertObjectJsonSchema(schema: unknown): asserts schema is ObjectJsonSchema {
  368. const violations: string[] = []
  369. checkSchemaNode(schema, 'schema', violations, new Set())
  370. if (violations.length === 0
  371. && (!isJsonSchemaRecord(schema) || !Object.hasOwn(schema, 'type') || schema.type !== 'object')) {
  372. violations.push('schema.type must be "object" (structured output is object-rooted)')
  373. }
  374. if (violations.length > 0) throw new JsonSchemaError(violations)
  375. }
  376. /** Safely test the lossless JSON boundary when a getter may throw. */
  377. function safelyIsJsonValue(value: unknown): boolean {
  378. try {
  379. return isJsonValue(value)
  380. } catch {
  381. return false
  382. }
  383. }
  384. /** Root-aware diagnostic path for the parameter validator's empty sentinel. */
  385. function diagnosticPath(path: string): string {
  386. return path === '' ? 'arguments' : path
  387. }
  388. /** Append one object property without a leading dot at an implicit root. */
  389. function propertyPath(path: string, key: string): string {
  390. return path === '' ? key : `${path}.${key}`
  391. }
  392. /** One child evaluation deferred by a container or exact-one union frame. */
  393. interface ValueChild {
  394. readonly node: JsonSchemaNode
  395. readonly value: unknown
  396. readonly path: string
  397. }
  398. /** Explicit call frame for stack-safe schema-value validation. */
  399. interface ValueFrame {
  400. readonly node: JsonSchemaNode
  401. readonly value: unknown
  402. readonly path: string
  403. catches: boolean
  404. phase: 'start' | 'children'
  405. kind?: 'oneOf' | 'object' | 'array'
  406. children: ValueChild[]
  407. childIndex: number
  408. violations: string[]
  409. tailViolations: string[]
  410. matches: number
  411. }
  412. /** The generic exception-containment diagnostic owned by one valid schema node. */
  413. function losslessValueViolation(path: string): string[] {
  414. return [`"${diagnosticPath(path)}" must be a lossless JSON value`]
  415. }
  416. /** Append diagnostics without spreading a potentially wide child result as call arguments. */
  417. function appendViolations(target: string[], source: readonly string[]): void {
  418. for (const violation of source) target.push(violation)
  419. }
  420. /** Initialize one validation frame with empty aggregation state. */
  421. function valueFrame(node: JsonSchemaNode, value: unknown, path: string): ValueFrame {
  422. return {
  423. node,
  424. value,
  425. path,
  426. catches: false,
  427. phase: 'start',
  428. children: [],
  429. childIndex: 0,
  430. violations: [],
  431. tailViolations: [],
  432. matches: 0,
  433. }
  434. }
  435. /** Validate one scalar node after its primitive type check. */
  436. function checkScalarValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
  437. const allowed = Object.hasOwn(node, 'enum') ? node.enum : undefined
  438. if (allowed !== undefined && !allowed.includes(value as JsonSchemaScalar)) {
  439. return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(allowed)}`]
  440. }
  441. if (Object.hasOwn(node, 'const') && value !== node.const) {
  442. return [`"${diagnosticPath(path)}" must be ${JSON.stringify(node.const)}`]
  443. }
  444. return []
  445. }
  446. /** Validate one trusted schema/value pair with explicit frames rather than recursive calls. */
  447. function checkValue(schema: JsonSchemaNode, value: unknown, path: string): string[] {
  448. const frames: ValueFrame[] = [valueFrame(schema, value, path)]
  449. let rootResult: string[] | undefined
  450. const receive = (result: string[]): void => {
  451. const parent = frames.at(-1)
  452. if (parent === undefined) {
  453. rootResult = result
  454. return
  455. }
  456. if (parent.kind === 'oneOf') {
  457. if (result.length === 0) parent.matches++
  458. } else {
  459. appendViolations(parent.violations, result)
  460. }
  461. }
  462. const finish = (result: string[]): void => {
  463. frames.pop()
  464. receive(result)
  465. }
  466. while (frames.length > 0) {
  467. const frame = frames.at(-1)
  468. /* v8 ignore next -- the loop condition guarantees a current frame. */
  469. if (frame === undefined) break
  470. try {
  471. if (frame.phase === 'children') {
  472. if (frame.childIndex < frame.children.length) {
  473. const child = frame.children[frame.childIndex]
  474. /* v8 ignore next -- childIndex is bounded by children.length. */
  475. if (child === undefined) throw new Error('missing schema-value child frame')
  476. frame.childIndex++
  477. frames.push(valueFrame(child.node, child.value, child.path))
  478. continue
  479. }
  480. if (frame.kind === 'oneOf') {
  481. finish(frame.matches === 1 ? [] : [`"${diagnosticPath(frame.path)}" must match exactly one oneOf branch (matched ${frame.matches})`])
  482. continue
  483. }
  484. appendViolations(frame.violations, frame.tailViolations)
  485. if (frame.violations.length > 0) {
  486. finish(frame.violations)
  487. } else if (frame.kind === 'object') {
  488. finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a lossless JSON object`])
  489. } else {
  490. finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a dense lossless JSON array`])
  491. }
  492. continue
  493. }
  494. const nodeType = Object.hasOwn(frame.node, 'type') ? frame.node.type : undefined
  495. frame.catches = !(nodeType !== undefined && !(SCHEMA_TYPES as readonly unknown[]).includes(nodeType))
  496. const oneOf = Object.hasOwn(frame.node, 'oneOf') ? frame.node.oneOf : undefined
  497. if (oneOf !== undefined) {
  498. frame.kind = 'oneOf'
  499. frame.children = Array.from(oneOf, branch => ({ node: branch, value: frame.value, path: frame.path }))
  500. frame.childIndex = 0
  501. frame.matches = 0
  502. frame.phase = 'children'
  503. continue
  504. }
  505. if (nodeType === undefined) {
  506. finish(safelyIsJsonValue(frame.value) ? [] : losslessValueViolation(frame.path))
  507. continue
  508. }
  509. switch (nodeType) {
  510. case 'object': {
  511. if (!isPlainJsonRecord(frame.value)) {
  512. finish([`"${diagnosticPath(frame.path)}" must be an object`])
  513. break
  514. }
  515. const properties = Object.hasOwn(frame.node, 'properties') ? frame.node.properties ?? {} : {}
  516. const violations: string[] = []
  517. const required = Object.hasOwn(frame.node, 'required') ? frame.node.required ?? [] : []
  518. for (const key of required) {
  519. if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) {
  520. violations.push(`missing required property "${propertyPath(frame.path, key)}"`)
  521. }
  522. }
  523. const children: ValueChild[] = []
  524. for (const [key, child] of Object.entries(properties)) {
  525. if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) continue
  526. children.push({ node: child, value: frame.value[key], path: propertyPath(frame.path, key) })
  527. }
  528. const tailViolations: string[] = []
  529. if (Object.hasOwn(frame.node, 'additionalProperties') && frame.node.additionalProperties === false) {
  530. for (const key of Object.keys(frame.value)) {
  531. if (!Object.hasOwn(properties, key)) {
  532. tailViolations.push(`"${propertyPath(frame.path, key)}" is not a declared property (additionalProperties: false)`)
  533. }
  534. }
  535. }
  536. frame.kind = 'object'
  537. frame.children = children
  538. frame.childIndex = 0
  539. frame.violations = violations
  540. frame.tailViolations = tailViolations
  541. frame.phase = 'children'
  542. break
  543. }
  544. case 'array': {
  545. if (!Array.isArray(frame.value)) {
  546. finish([`"${diagnosticPath(frame.path)}" must be an array`])
  547. break
  548. }
  549. const items = Object.hasOwn(frame.node, 'items') ? frame.node.items : undefined
  550. const children = items === undefined
  551. ? []
  552. : frame.value.flatMap((entry, index): ValueChild[] => [{ node: items, value: entry, path: `${frame.path}[${index}]` }])
  553. frame.kind = 'array'
  554. frame.children = children
  555. frame.childIndex = 0
  556. frame.violations = []
  557. frame.phase = 'children'
  558. break
  559. }
  560. case 'string':
  561. finish(typeof frame.value === 'string'
  562. ? checkScalarValue(frame.node, frame.value, frame.path)
  563. : [`"${diagnosticPath(frame.path)}" must be a string`])
  564. break
  565. case 'number':
  566. finish(typeof frame.value !== 'number'
  567. ? [`"${diagnosticPath(frame.path)}" must be a number`]
  568. : !isJsonNumber(frame.value)
  569. ? [`"${diagnosticPath(frame.path)}" must be a finite JSON number`]
  570. : checkScalarValue(frame.node, frame.value, frame.path))
  571. break
  572. case 'integer':
  573. finish(!isJsonNumber(frame.value) || !Number.isInteger(frame.value)
  574. ? [`"${diagnosticPath(frame.path)}" must be an integer`]
  575. : checkScalarValue(frame.node, frame.value, frame.path))
  576. break
  577. case 'boolean':
  578. finish(typeof frame.value === 'boolean'
  579. ? checkScalarValue(frame.node, frame.value, frame.path)
  580. : [`"${diagnosticPath(frame.path)}" must be a boolean`])
  581. break
  582. case 'null':
  583. finish(frame.value === null
  584. ? checkScalarValue(frame.node, frame.value, frame.path)
  585. : [`"${diagnosticPath(frame.path)}" must be null`])
  586. break
  587. default:
  588. finish(assertNever(nodeType, 'JsonSchemaType'))
  589. }
  590. } catch (error) {
  591. let failed = frames.pop()
  592. while (failed !== undefined && !failed.catches) failed = frames.pop()
  593. if (failed === undefined) throw error
  594. receive(losslessValueViolation(failed.path))
  595. }
  596. }
  597. /* v8 ignore next -- every root frame finishes or throws. */
  598. return rootResult ?? losslessValueViolation(path)
  599. }
  600. /**
  601. * Validate a candidate value against an asserted raw schema. The function is
  602. * total for arbitrary values and returns path-qualified violations.
  603. * @param schema - a schema accepted by {@link assertSupportedJsonSchema}.
  604. * @param value - the candidate JSON value.
  605. * @param path - root label used in diagnostics.
  606. * @returns All violations in walk order; empty means valid.
  607. */
  608. export function validateJsonSchemaValue(schema: JsonSchemaNode, value: unknown, path = 'value'): string[] {
  609. return checkValue(schema, value, path)
  610. }