feat: unify JSON value schema DSL
This commit is contained in:
@@ -1,304 +1,326 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertSupportedOutputSchema,
|
||||
OutputSchemaError,
|
||||
validateStructuredValue,
|
||||
type StructuredOutputSchema,
|
||||
} from '../src/json-schema.ts'
|
||||
assertObjectJsonSchema,
|
||||
assertSupportedJsonSchema,
|
||||
JsonSchemaError,
|
||||
validateJsonSchemaValue,
|
||||
type JsonSchemaNode,
|
||||
type ObjectJsonSchema,
|
||||
} from '../src/index.ts'
|
||||
|
||||
/** Assert-and-narrow helper: the asserted schema, typed. */
|
||||
function asserted(schema: unknown): StructuredOutputSchema {
|
||||
assertSupportedOutputSchema(schema)
|
||||
function asserted(schema: unknown): JsonSchemaNode {
|
||||
assertSupportedJsonSchema(schema)
|
||||
return schema
|
||||
}
|
||||
|
||||
/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */
|
||||
function violationsOf(schema: unknown): string[] {
|
||||
try {
|
||||
assertSupportedOutputSchema(schema)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof OutputSchemaError) return error.violations
|
||||
throw error
|
||||
}
|
||||
throw new Error('expected the schema to be rejected')
|
||||
function assertedObject(schema: unknown): ObjectJsonSchema {
|
||||
assertObjectJsonSchema(schema)
|
||||
return schema
|
||||
}
|
||||
|
||||
describe('assertSupportedOutputSchema', () => {
|
||||
it('accepts a representative subset schema (all supported keywords)', () => {
|
||||
const schema = asserted({
|
||||
type: 'object',
|
||||
description: 'a finding',
|
||||
title: 'Finding',
|
||||
properties: {
|
||||
file: { type: 'string', description: 'path' },
|
||||
line: { type: 'integer' },
|
||||
severity: { type: 'string', enum: ['low', 'high'] },
|
||||
kind: { type: 'string', const: 'bug' },
|
||||
score: { type: 'number' },
|
||||
confirmed: { type: 'boolean' },
|
||||
parent: { type: 'null' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
nested: {
|
||||
type: 'object',
|
||||
properties: { x: { type: 'number', default: 3, examples: [1, 2] } },
|
||||
additionalProperties: false,
|
||||
function violationsOf(schema: unknown, objectRoot = false): string[] {
|
||||
try {
|
||||
if (objectRoot) assertObjectJsonSchema(schema)
|
||||
else assertSupportedJsonSchema(schema)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof JsonSchemaError) return error.violations
|
||||
throw error
|
||||
}
|
||||
throw new Error('expected schema rejection')
|
||||
}
|
||||
|
||||
describe('the enforced raw JSON Schema subset', () => {
|
||||
it('accepts every JSON root and every supported node', () => {
|
||||
for (const schema of [
|
||||
{ type: 'string' },
|
||||
{ type: 'number' },
|
||||
{ type: 'integer' },
|
||||
{ type: 'boolean' },
|
||||
{ type: 'null' },
|
||||
{ type: 'array', items: { type: 'string' } },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
nested: { type: 'object', properties: {}, additionalProperties: false },
|
||||
free: {},
|
||||
},
|
||||
anything: { type: 'array' },
|
||||
required: ['nested'],
|
||||
additionalProperties: true,
|
||||
},
|
||||
required: ['file', 'line'],
|
||||
additionalProperties: true,
|
||||
})
|
||||
expect(schema.type).toBe('object')
|
||||
{ oneOf: [{ type: 'string' }, { type: 'number' }] },
|
||||
{ description: 'any JSON', title: 'JSON', default: null, examples: [1, 'x'] },
|
||||
]) {
|
||||
expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a non-object root (scalar/array-rooted schemas)', () => {
|
||||
expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
|
||||
expect(violationsOf({ type: 'array', items: { type: 'string' } }))
|
||||
.toContain('schema.type must be "object" (structured output is object-rooted)')
|
||||
it('retains an object-root guard only at consumers that need it', () => {
|
||||
expect(assertedObject({ type: 'object' }).type).toBe('object')
|
||||
for (const schema of [{}, { type: 'string' }, { type: 'array' }, { oneOf: [{ type: 'string' }, { type: 'null' }] }]) {
|
||||
expect(violationsOf(schema, true)).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects non-object schema nodes and missing/unknown type', () => {
|
||||
expect(violationsOf('nope')).toEqual(['schema must be a schema object'])
|
||||
it('rejects non-schema nodes, unknown types, and type arrays', () => {
|
||||
expect(violationsOf(null)).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf([])).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null'])
|
||||
expect(violationsOf('no')).toEqual(['schema must be a schema object'])
|
||||
expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/)
|
||||
expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object'])
|
||||
})
|
||||
|
||||
it('rejects type ARRAYS with a dedicated message', () => {
|
||||
expect(violationsOf({ type: ['string', 'null'] }))
|
||||
.toEqual(['schema.type must be a single type string (type arrays are not supported)'])
|
||||
})
|
||||
|
||||
it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => {
|
||||
for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
|
||||
const bad = violationsOf({ type: 'object', [keyword]: [] })
|
||||
expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true)
|
||||
}
|
||||
it('enforces oneOf vocabulary and its minimum branch count', () => {
|
||||
expect(violationsOf({ oneOf: [] })).toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
expect(violationsOf({ oneOf: [{}] })).toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
expect(violationsOf({ oneOf: 'x' })).toEqual(['schema.oneOf must be an array of at least two schemas'])
|
||||
expect(violationsOf({ type: 'string', oneOf: [{}, {}] }))
|
||||
.toEqual(['schema cannot declare both type and oneOf'])
|
||||
expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'number' }], items: {} }))
|
||||
.toEqual(['schema.items is not supported beside oneOf'])
|
||||
expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'weird' }] })[0])
|
||||
.toContain('schema.oneOf[1].type')
|
||||
})
|
||||
|
||||
it('reports EVERY violation, not just the first', () => {
|
||||
const bad = violationsOf({
|
||||
it('rejects unknown and misplaced keywords without accepted-then-ignored behavior', () => {
|
||||
for (const keyword of ['anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
|
||||
expect(violationsOf({ type: 'object', [keyword]: [] })[0]).toContain(`schema.${keyword} is not a supported keyword`)
|
||||
}
|
||||
expect(violationsOf({ type: 'object', items: {} }))
|
||||
.toEqual(['schema.items is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'array', properties: {} }))
|
||||
.toEqual(['schema.properties is not supported on type "array"'])
|
||||
expect(violationsOf({ type: 'object', enum: ['x'] }))
|
||||
.toEqual(['schema.enum is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'array', const: null }))
|
||||
.toEqual(['schema.const is not supported on type "array"'])
|
||||
expect(violationsOf({ properties: {}, required: [], additionalProperties: true, items: {}, enum: [], const: null }))
|
||||
.toEqual([
|
||||
'schema.properties requires type or oneOf',
|
||||
'schema.required requires type or oneOf',
|
||||
'schema.additionalProperties requires type or oneOf',
|
||||
'schema.items requires type or oneOf',
|
||||
'schema.enum requires type or oneOf',
|
||||
'schema.const requires type or oneOf',
|
||||
])
|
||||
})
|
||||
|
||||
it('reports every independent schema violation', () => {
|
||||
expect(violationsOf({
|
||||
type: 'object',
|
||||
pattern: 'x',
|
||||
properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } },
|
||||
})
|
||||
expect(bad.length).toBe(3)
|
||||
})).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => {
|
||||
expect(violationsOf({ type: 'object', items: { type: 'string' } }))
|
||||
.toEqual(['schema.items is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } }))
|
||||
.toEqual(['schema.properties.a.properties is not supported on type "string"'])
|
||||
expect(violationsOf({ type: 'object', enum: [1] }))
|
||||
.toEqual(['schema.enum is not supported on type "object"'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } }))
|
||||
.toEqual(['schema.properties.a.const is not supported on type "array"'])
|
||||
})
|
||||
|
||||
it('validates required: must be string[] naming declared properties', () => {
|
||||
expect(violationsOf({ type: 'object', required: 'file' }))
|
||||
it('validates object properties, required names, and openness', () => {
|
||||
expect(violationsOf({ type: 'object', properties: [] }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: 'x' } }))
|
||||
.toEqual(['schema.properties.a must be a schema object'])
|
||||
expect(violationsOf({ type: 'object', required: 'a' }))
|
||||
.toEqual(['schema.required must be an array of strings'])
|
||||
expect(violationsOf({ type: 'object', required: [1] }))
|
||||
.toEqual(['schema.required must be an array of strings'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] }))
|
||||
.toEqual(['schema.required names "b" which is not in properties'])
|
||||
expect(violationsOf({ type: 'object', required: ['a'] }))
|
||||
.toEqual(['schema.required names "a" which is not in properties'])
|
||||
})
|
||||
|
||||
it('validates additionalProperties must be boolean and enum/const must be scalars', () => {
|
||||
expect(violationsOf({ type: 'object', additionalProperties: {} }))
|
||||
expect(violationsOf({ type: 'object', properties: {}, required: ['missing'] }))
|
||||
.toEqual(['schema.required names "missing" which is not in properties'])
|
||||
expect(violationsOf({ type: 'object', additionalProperties: 'yes' }))
|
||||
.toEqual(['schema.additionalProperties must be a boolean'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } }))
|
||||
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
|
||||
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } }))
|
||||
.toEqual(['schema.properties.a.const must be a scalar'])
|
||||
expect(violationsOf({ type: 'object', properties: undefined }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
expect(violationsOf({ type: 'object', properties: undefined, required: ['missing'] }))
|
||||
.toEqual([
|
||||
'schema.properties must be an object of schemas',
|
||||
'schema.required names "missing" which is not in properties',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects non-string description/title and non-JSON annotation payloads', () => {
|
||||
expect(violationsOf({ type: 'object', description: 7 }))
|
||||
.toEqual(['schema.description must be a string'])
|
||||
expect(violationsOf({ type: 'object', title: 7 }))
|
||||
.toEqual(['schema.title must be a string'])
|
||||
expect(violationsOf({ type: 'object', default: () => 1 }))
|
||||
.toEqual(['schema.default annotation must be JSON data'])
|
||||
expect(violationsOf({ type: 'object', examples: [undefined] }))
|
||||
.toEqual(['schema.examples annotation must be JSON data'])
|
||||
expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] }))
|
||||
.toEqual(['schema.examples annotation must be JSON data'])
|
||||
// A cyclic annotation payload is caught by the JSON-data walk.
|
||||
const cyclicAnnotation: Record<string, unknown> = {}
|
||||
cyclicAnnotation.self = cyclicAnnotation
|
||||
expect(violationsOf({ type: 'object', default: cyclicAnnotation }))
|
||||
.toEqual(['schema.default annotation must be JSON data'])
|
||||
// Object/array annotations that ARE JSON data pass.
|
||||
asserted({ type: 'object', default: { a: [1, 'x', null, true] } })
|
||||
it('requires type-correct scalar enum and const values', () => {
|
||||
for (const schema of [
|
||||
{ type: 'string', enum: ['a'], const: 'a' },
|
||||
{ type: 'number', enum: [1.5], const: 1.5 },
|
||||
{ type: 'integer', enum: [1], const: 1 },
|
||||
{ type: 'boolean', enum: [true], const: true },
|
||||
{ type: 'null', enum: [null], const: null },
|
||||
]) {
|
||||
expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow()
|
||||
}
|
||||
|
||||
expect(violationsOf({ type: 'string', enum: [] }))
|
||||
.toEqual(['schema.enum must be a non-empty array of string values'])
|
||||
expect(violationsOf({ type: 'number', enum: ['1'] }))
|
||||
.toEqual(['schema.enum must be a non-empty array of number values'])
|
||||
expect(violationsOf({ type: 'integer', enum: [1.5] }))
|
||||
.toEqual(['schema.enum must be a non-empty array of integer values'])
|
||||
expect(violationsOf({ type: 'number', enum: [Number.NaN] }))
|
||||
.toEqual(['schema.enum must be a non-empty array of number values'])
|
||||
expect(violationsOf({ type: 'number', const: -0 }))
|
||||
.toEqual(['schema.const must be a number value'])
|
||||
expect(violationsOf({ type: 'boolean', const: 1 }))
|
||||
.toEqual(['schema.const must be a boolean value'])
|
||||
expect(violationsOf({ type: 'string', enum: undefined }))
|
||||
.toEqual(['schema.enum must be a non-empty array of string values'])
|
||||
})
|
||||
|
||||
it('rejects a circular schema instead of recursing forever', () => {
|
||||
const node: Record<string, unknown> = { type: 'object' }
|
||||
node.properties = { self: node }
|
||||
expect(violationsOf(node)).toEqual(['schema.properties.self is circular'])
|
||||
it('validates annotation types and lossless JSON payloads', () => {
|
||||
expect(violationsOf({ description: 1 })).toEqual(['schema.description must be a string'])
|
||||
expect(violationsOf({ title: 1 })).toEqual(['schema.title must be a string'])
|
||||
for (const [key, value] of [
|
||||
['default', undefined],
|
||||
['examples', [undefined]],
|
||||
['default', Number.POSITIVE_INFINITY],
|
||||
['examples', new Date(0)],
|
||||
] as const) {
|
||||
expect(violationsOf({ [key]: value })).toEqual([`schema.${key} annotation must be lossless JSON data`])
|
||||
}
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
expect(violationsOf({ default: cyclic }))
|
||||
.toEqual(['schema.default annotation must be lossless JSON data'])
|
||||
|
||||
const explosive = new Proxy({}, {
|
||||
ownKeys() { throw new Error('annotation trap') },
|
||||
})
|
||||
expect(violationsOf({ examples: explosive }))
|
||||
.toEqual(['schema.examples annotation must be lossless JSON data'])
|
||||
})
|
||||
|
||||
it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => {
|
||||
it('rejects cyclic/exotic schema structure but permits sibling reuse', () => {
|
||||
const cyclic: Record<string, unknown> = { type: 'object' }
|
||||
cyclic.properties = { self: cyclic }
|
||||
expect(violationsOf(cyclic)).toEqual(['schema.properties.self is circular'])
|
||||
const leaf = { type: 'string' }
|
||||
asserted({ type: 'object', properties: { a: leaf, b: leaf } })
|
||||
})
|
||||
|
||||
it('required cannot be satisfied by INHERITED names — `toString` is not a declared property', () => {
|
||||
// `'toString' in {}` is true via Object.prototype; the declared-property
|
||||
// contract must be an own-property check.
|
||||
expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
|
||||
.toEqual(['schema.required names "toString" which is not in properties'])
|
||||
})
|
||||
|
||||
it('rejects exotic host objects where the subset expects plain JSON structure', () => {
|
||||
// A Map as `properties` has no own enumerable entries: structurally it
|
||||
// would read as "no properties" and serialize to {} — lossy, not loud.
|
||||
expect(() => { assertSupportedJsonSchema({ type: 'object', properties: { a: leaf, b: leaf } }) }).not.toThrow()
|
||||
expect(violationsOf({ type: 'object', properties: new Map() }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
// A Date node is not a schema object even though Object.values(date) is [].
|
||||
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
|
||||
.toEqual(['schema.properties.at must be a schema object'])
|
||||
})
|
||||
|
||||
it('rejects exotic annotation payloads that would serialize lossily', () => {
|
||||
expect(violationsOf({ type: 'object', default: new Date(0) }))
|
||||
.toEqual(['schema.default annotation must be JSON data'])
|
||||
expect(violationsOf({ type: 'object', examples: [new Map()] }))
|
||||
.toEqual(['schema.examples annotation must be JSON data'])
|
||||
it('uses own-property semantics for required declarations', () => {
|
||||
expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
|
||||
.toEqual(['schema.required names "toString" which is not in properties'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateStructuredValue', () => {
|
||||
const schema = asserted({
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string' },
|
||||
line: { type: 'integer' },
|
||||
score: { type: 'number' },
|
||||
confirmed: { type: 'boolean' },
|
||||
parent: { type: 'null' },
|
||||
severity: { type: 'string', enum: ['low', 'high'] },
|
||||
kind: { type: 'string', const: 'bug' },
|
||||
tags: { type: 'array', items: { type: 'string' } },
|
||||
free: { type: 'array' },
|
||||
nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false },
|
||||
},
|
||||
required: ['file'],
|
||||
describe('validateJsonSchemaValue', () => {
|
||||
it('validates scalar, array, object, and null roots', () => {
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'string' }), 'x')).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'number' }), 1.5)).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 2)).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), true)).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'null' }), null)).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'array', items: { type: 'string' } }), ['x'])).toEqual([])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: 1 })).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts a fully valid value (empty violations)', () => {
|
||||
expect(validateStructuredValue(schema, {
|
||||
file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null,
|
||||
severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 },
|
||||
})).toEqual([])
|
||||
it('rejects wrong scalar types and lossy numbers', () => {
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'string' }), 1)).toEqual(['"value" must be a string'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'number' }), '1')).toEqual(['"value" must be a number'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'number' }), Number.NaN)).toEqual(['"value" must be a finite JSON number'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'number' }), -0)).toEqual(['"value" must be a finite JSON number'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 1.5)).toEqual(['"value" must be an integer'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), 'true')).toEqual(['"value" must be a boolean'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'null' }), 0)).toEqual(['"value" must be null'])
|
||||
})
|
||||
|
||||
it('reports missing required and wrong root type', () => {
|
||||
expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"'])
|
||||
expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object'])
|
||||
expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object'])
|
||||
it('enforces scalar enum and const together', () => {
|
||||
const schema = asserted({ type: 'string', enum: ['a', 'b'], const: 'a' })
|
||||
expect(validateJsonSchemaValue(schema, 'a')).toEqual([])
|
||||
expect(validateJsonSchemaValue(schema, 'c')).toEqual(['"value" must be one of ["a","b"]'])
|
||||
expect(validateJsonSchemaValue(schema, 'b')).toEqual(['"value" must be "a"'])
|
||||
})
|
||||
|
||||
it('type-checks every scalar branch with path-qualified messages', () => {
|
||||
expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null'])
|
||||
it('validates object requiredness, nested values, and raw open defaults', () => {
|
||||
const open = asserted({
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string' },
|
||||
nested: {
|
||||
type: 'object',
|
||||
properties: { line: { type: 'integer' } },
|
||||
required: ['line'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ['file'],
|
||||
})
|
||||
expect(validateJsonSchemaValue(open, { file: 'a', extra: [1], nested: { line: 2 } })).toEqual([])
|
||||
expect(validateJsonSchemaValue(open, { nested: { line: 1 } }))
|
||||
.toEqual(['missing required property "value.file"'])
|
||||
expect(validateJsonSchemaValue(open, { file: 1, nested: {} })).toEqual([
|
||||
'"value.file" must be a string',
|
||||
'missing required property "value.nested.line"',
|
||||
])
|
||||
expect(validateJsonSchemaValue(open, { file: 'a', nested: { line: 1, extra: true } }))
|
||||
.toEqual(['"value.nested.extra" is not a declared property (additionalProperties: false)'])
|
||||
expect(validateJsonSchemaValue(open, 'x')).toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('enforces enum membership and const equality', () => {
|
||||
expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' }))
|
||||
.toEqual(['"value.severity" must be one of ["low","high"]'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' }))
|
||||
.toEqual(['"value.kind" must be "bug"'])
|
||||
it('treats present undefined as missing when required, then rejects other lossy objects', () => {
|
||||
const required = asserted({ type: 'object', properties: { x: {} }, required: ['x'] })
|
||||
expect(validateJsonSchemaValue(required, { x: undefined }))
|
||||
.toEqual(['missing required property "value.x"'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: undefined }))
|
||||
.toEqual(['"value" must be a lossless JSON object'])
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'object' }), new Date(0)))
|
||||
.toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('checks arrays per index; an items-less array accepts anything', () => {
|
||||
expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([])
|
||||
it('validates dense arrays per index and rejects lossy arrays', () => {
|
||||
const schema = asserted({ type: 'array', items: { type: 'integer' } })
|
||||
expect(validateJsonSchemaValue(schema, [1, 2])).toEqual([])
|
||||
expect(validateJsonSchemaValue(schema, [1, 1.5])).toEqual(['"value[1]" must be an integer'])
|
||||
expect(validateJsonSchemaValue(schema, 'x')).toEqual(['"value" must be an array'])
|
||||
const sparse: number[] = []
|
||||
sparse.length = 2
|
||||
sparse[0] = 1
|
||||
expect(validateJsonSchemaValue(schema, sparse)).toEqual(['"value" must be a dense lossless JSON array'])
|
||||
})
|
||||
|
||||
it('recurses into nested objects: required + additionalProperties: false', () => {
|
||||
expect(validateStructuredValue(schema, { file: 'a', nested: {} }))
|
||||
.toEqual(['missing required property "value.nested.x"'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } }))
|
||||
.toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)'])
|
||||
expect(validateStructuredValue(schema, { file: 'a', nested: 3 }))
|
||||
.toEqual(['"value.nested" must be an object'])
|
||||
it('validates exact-one oneOf semantics, including overlap', () => {
|
||||
const disjoint = asserted({ oneOf: [{ type: 'string' }, { type: 'number' }] })
|
||||
expect(validateJsonSchemaValue(disjoint, 'x')).toEqual([])
|
||||
expect(validateJsonSchemaValue(disjoint, null))
|
||||
.toEqual(['"value" must match exactly one oneOf branch (matched 0)'])
|
||||
const overlap = asserted({ oneOf: [{ type: 'number' }, { type: 'integer' }] })
|
||||
expect(validateJsonSchemaValue(overlap, 1))
|
||||
.toEqual(['"value" must match exactly one oneOf branch (matched 2)'])
|
||||
expect(validateJsonSchemaValue(overlap, 1.5)).toEqual([])
|
||||
})
|
||||
|
||||
it('a required key present-but-undefined counts as missing', () => {
|
||||
expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"'])
|
||||
it('an unconstrained schema accepts only lossless JSON values', () => {
|
||||
const anyJson = asserted({})
|
||||
for (const value of [null, true, 1, 'x', [1], { x: null }]) {
|
||||
expect(validateJsonSchemaValue(anyJson, value), JSON.stringify(value)).toEqual([])
|
||||
}
|
||||
for (const value of [undefined, () => 1, Number.POSITIVE_INFINITY, -0, new Map()]) {
|
||||
expect(validateJsonSchemaValue(anyJson, value)).toEqual(['"value" must be a lossless JSON value'])
|
||||
}
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
expect(validateJsonSchemaValue(anyJson, cyclic)).toEqual(['"value" must be a lossless JSON value'])
|
||||
const explosive = new Proxy({}, {
|
||||
ownKeys() { throw new Error('value trap') },
|
||||
})
|
||||
expect(validateJsonSchemaValue(anyJson, explosive)).toEqual(['"value" must be a lossless JSON value'])
|
||||
})
|
||||
|
||||
it('inherited properties satisfy nothing: required, additionalProperties, and recursion are own-property only', () => {
|
||||
// required: ['toString'] must NOT be satisfied by Object.prototype.toString.
|
||||
expect(validateStructuredValue(
|
||||
it('uses own properties for requiredness, recursion, and closed-object checks', () => {
|
||||
expect(validateJsonSchemaValue(
|
||||
asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }),
|
||||
{},
|
||||
)).toEqual(['missing required property "value.toString"'])
|
||||
// additionalProperties: false must flag an OWN `toString` key even though
|
||||
// `'toString' in properties` is true via the prototype.
|
||||
expect(validateStructuredValue(
|
||||
asserted({ type: 'object', additionalProperties: false }),
|
||||
{ toString: 1 },
|
||||
)).toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
|
||||
// A declared property the value does NOT carry must not be validated
|
||||
// against the value's INHERITED member (constructor is a function on
|
||||
// every plain object's prototype, not a carried property).
|
||||
expect(validateStructuredValue(
|
||||
expect(validateJsonSchemaValue(asserted({ type: 'object', additionalProperties: false }), { toString: 1 }))
|
||||
.toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
|
||||
expect(validateJsonSchemaValue(
|
||||
asserted({ type: 'object', properties: { constructor: { type: 'string' } } }),
|
||||
{},
|
||||
)).toEqual([])
|
||||
})
|
||||
|
||||
it('a non-plain object value is not an object in the JSON sense', () => {
|
||||
expect(validateStructuredValue(asserted({ type: 'object' }), new Date(0)))
|
||||
.toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('collects multiple violations across branches in one pass', () => {
|
||||
expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([
|
||||
'missing required property "value.file"',
|
||||
'"value.line" must be an integer',
|
||||
'"value.severity" must be one of ["low","high"]',
|
||||
])
|
||||
})
|
||||
|
||||
it('null-typed const/enum work through the scalar path', () => {
|
||||
const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } })
|
||||
expect(validateStructuredValue(nullish, { a: null })).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a non-object properties value in the schema walk', () => {
|
||||
expect(violationsOf({ type: 'object', properties: [] }))
|
||||
.toEqual(['schema.properties must be an object of schemas'])
|
||||
})
|
||||
|
||||
it('an object schema without properties/required only type-checks its value', () => {
|
||||
const bare = asserted({ type: 'object' })
|
||||
expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([])
|
||||
expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object'])
|
||||
})
|
||||
|
||||
it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => {
|
||||
const forged = { type: 'tuple' } as unknown as StructuredOutputSchema
|
||||
expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/)
|
||||
it('keeps assertNever as a forged-schema backstop', () => {
|
||||
const forged = { type: 'tuple' } as unknown as JsonSchemaNode
|
||||
expect(() => validateJsonSchemaValue(forged, 1)).toThrow(/tuple/)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user