feat: unify JSON value schema DSL

This commit is contained in:
Tianyi Cui
2026-07-21 01:11:55 +08:00
parent 9a5c81f9e5
commit 8500974fd4
62 changed files with 1929 additions and 1179 deletions

View File

@@ -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/)
})
})

View File

@@ -1,61 +1,91 @@
/**
* Property-based tests for the tool-schema DSL (the property-testing Agent Note), including
* the the property-testing ↔ runtime-validation composition composition: generated args that satisfy a SchemaSpec must
* the property-testing ↔ runtime-validation composition: generated args that satisfy a ParameterSchemaSpec must
* pass validateArgs, and targeted corruptions must be rejected. This closes the
* validator/InferArgs drift risk noted in the arg-validation Agent Note.
*/
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { schemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools'
import type { SchemaProp, SchemaSpec } from '@deepseek-ai/dsh-tools'
import { parameterSchemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools'
import type { ParameterPropertySpec, ParameterSchemaSpec, ValueSchemaSpec } from '@deepseek-ai/dsh-tools'
/** Remove parameter-only requiredness before nesting a schema as an array item. */
function asValueSchema(prop: ParameterPropertySpec): ValueSchemaSpec {
const { required: _required, ...schema } = prop
return schema
}
// A leaf prop arbitrary (no nesting) with optional required/enum.
function leafPropArb(): fc.Arbitrary<SchemaProp> {
function leafPropArb(): fc.Arbitrary<ParameterPropertySpec> {
return fc.oneof(
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'string', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'number', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'boolean', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'string', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'number', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'integer', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'boolean', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'null', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'json', ...required ? { required: true } : {} })),
fc.record({ values: fc.uniqueArray(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 3 }), required: fc.boolean() })
.map(({ values, required }): SchemaProp => ({ type: 'string', enum: values, ...required ? { required: true } : {} })),
.map(({ values, required }): ParameterPropertySpec => ({ type: 'string', enum: values, ...required ? { required: true } : {} })),
fc.record({ value: fc.string(), required: fc.boolean() })
.map(({ value, required }): ParameterPropertySpec => ({ type: 'string', const: value, ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() })
.map(({ required }): ParameterPropertySpec => ({
oneOf: [{ type: 'string' }, { type: 'null' }],
...required ? { required: true } : {},
})),
)
}
/** A prop arbitrary up to `depth` levels of nesting (objects and arrays). */
function propArb(depth: number): fc.Arbitrary<SchemaProp> {
function propArb(depth: number): fc.Arbitrary<ParameterPropertySpec> {
if (depth <= 0) return leafPropArb()
return fc.oneof(
{ weight: 3, arbitrary: leafPropArb() },
{
weight: 1,
arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean() })
.map(({ properties, required }): SchemaProp => ({ type: 'object', properties, ...required ? { required: true } : {} })),
arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean(), additionalProperties: fc.boolean() })
.map(({ properties, required, additionalProperties }): ParameterPropertySpec => ({
type: 'object',
additionalProperties,
properties,
...required ? { required: true } : {},
})),
},
{
weight: 1,
arbitrary: fc.record({ items: propArb(depth - 1), required: fc.boolean() })
.map(({ items, required }): SchemaProp => ({ type: 'array', items, ...required ? { required: true } : {} })),
.map(({ items, required }): ParameterPropertySpec => ({
type: 'array',
items: asValueSchema(items),
...required ? { required: true } : {},
})),
},
)
}
function specArb(depth: number): fc.Arbitrary<SchemaSpec> {
function specArb(depth: number): fc.Arbitrary<ParameterSchemaSpec> {
return fc.dictionary(fc.string({ minLength: 1, maxLength: 6 }), propArb(depth), { maxKeys: 4 })
}
/** Generate a value that satisfies a prop (used to build valid args). */
function valueForProp(prop: SchemaProp): fc.Arbitrary<unknown> {
function valueForProp(prop: ParameterPropertySpec): fc.Arbitrary<unknown> {
if ('oneOf' in prop) return fc.oneof(...prop.oneOf.map(valueForProp))
if ('const' in prop) return fc.constant(prop.const)
switch (prop.type) {
case 'string': return prop.enum ? fc.constantFrom(...prop.enum) : fc.string()
case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true })
case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true }).filter(value => !Object.is(value, -0))
case 'integer': return fc.integer()
case 'boolean': return fc.boolean()
case 'null': return fc.constant(null)
case 'object': return prop.properties ? validArgsForSpec(prop.properties) : fc.constant({})
case 'array': return prop.items ? fc.array(valueForProp(prop.items), { maxLength: 3 }) : fc.constant([])
case 'json': return fc.jsonValue()
}
}
/** Generate args satisfying every required key of a spec (optionals included randomly). */
function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary<Record<string, unknown>> {
function validArgsForSpec(spec: ParameterSchemaSpec): fc.Arbitrary<Record<string, unknown>> {
const entries = Object.entries(spec)
return fc.tuple(...entries.map(([key, prop]) =>
fc.tuple(
@@ -76,29 +106,29 @@ function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary<Record<string, unknown
}
/** Collect the `required: true` keys at the top level of a spec. */
function requiredKeys(spec: SchemaSpec): string[] {
function requiredKeys(spec: ParameterSchemaSpec): string[] {
return Object.entries(spec).filter(([, p]) => p.required === true).map(([k]) => k)
}
describe('schema DSL properties', () => {
it('JSON Schema `required` equals the required:true keys at every level', () => {
fc.assert(fc.property(specArb(2), (spec) => {
const checkLevel = (s: SchemaSpec, json: { required?: string[]; properties: Record<string, unknown> }) => {
const checkLevel = (s: ParameterSchemaSpec, json: { required?: string[]; properties: Record<string, unknown> }) => {
expect(new Set(json.required ?? [])).toEqual(new Set(requiredKeys(s)))
for (const [key, prop] of Object.entries(s)) {
const propJson = json.properties[key] as Record<string, unknown>
if (prop.type === 'object' && prop.properties) {
if ('type' in prop && prop.type === 'object' && prop.properties) {
checkLevel(prop.properties, propJson as { required?: string[]; properties: Record<string, unknown> })
}
}
}
checkLevel(spec, schemaSpecToJsonSchema(spec))
checkLevel(spec, parameterSchemaSpecToJsonSchema(spec))
}))
})
it('conversion is total (never throws) for any spec', () => {
fc.assert(fc.property(specArb(3), (spec) => {
expect(() => schemaSpecToJsonSchema(spec)).not.toThrow()
expect(() => parameterSchemaSpecToJsonSchema(spec)).not.toThrow()
}))
})

View File

@@ -0,0 +1,138 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import {
JsonSchemaError,
parameterSchemaSpecToJsonSchema,
valueSchemaSpecToJsonSchema,
type InferArgs,
type InferValue,
type JsonValue,
type ParameterSchemaSpec,
type ValueSchemaSpec,
} from '../src/index.ts'
describe('the unified author schema DSL', () => {
it('compiles every value root and the author-only json node', () => {
expect(valueSchemaSpecToJsonSchema({ type: 'string', enum: ['a', 'b'], const: 'a' }))
.toEqual({ type: 'string', enum: ['a', 'b'], const: 'a' })
expect(valueSchemaSpecToJsonSchema({ type: 'number' })).toEqual({ type: 'number' })
expect(valueSchemaSpecToJsonSchema({ type: 'integer' })).toEqual({ type: 'integer' })
expect(valueSchemaSpecToJsonSchema({ type: 'boolean' })).toEqual({ type: 'boolean' })
expect(valueSchemaSpecToJsonSchema({ type: 'null' })).toEqual({ type: 'null' })
expect(valueSchemaSpecToJsonSchema({ type: 'array', items: { type: 'json' } }))
.toEqual({ type: 'array', items: {} })
expect(valueSchemaSpecToJsonSchema({ type: 'object', additionalProperties: false, properties: {} }))
.toEqual({ type: 'object', additionalProperties: false, properties: {} })
expect(valueSchemaSpecToJsonSchema({
type: 'json',
description: 'anything',
title: 'Any JSON',
default: null,
examples: [{ nested: true }],
})).toEqual({ description: 'anything', title: 'Any JSON', default: null, examples: [{ nested: true }] })
expect(valueSchemaSpecToJsonSchema({ oneOf: [{ type: 'string' }, { type: 'null' }] }))
.toEqual({ oneOf: [{ type: 'string' }, { type: 'null' }] })
})
it('keeps the implicit parameter root open while preserving explicit object openness', () => {
expect(parameterSchemaSpecToJsonSchema({
closed: {
type: 'object',
additionalProperties: false,
required: true,
properties: { id: { type: 'integer', required: true } },
},
open: { type: 'object', additionalProperties: true },
})).toEqual({
type: 'object',
properties: {
closed: {
type: 'object',
additionalProperties: false,
properties: { id: { type: 'integer' } },
required: ['id'],
},
open: { type: 'object', additionalProperties: true },
},
required: ['closed'],
})
})
it('rejects runtime-forged author forms rather than compiling them lossily', () => {
for (const schema of [
{ type: 'object' },
{ oneOf: [{ type: 'string' }] },
{ type: 'number', enum: ['1'] },
{ type: 'integer', const: 1.5 },
{ type: 'json', default: undefined },
{ type: 'array', items: { type: 'string', required: true } },
{ type: 'array', items: 42 },
{ type: 'string', extra: true },
{ type: 'string', oneOf: [{ type: 'string' }, { type: 'null' }] },
{ oneOf: 'not-an-array' },
{ type: 'string', enum: 'a' },
null,
]) {
expect(() => valueSchemaSpecToJsonSchema(schema as ValueSchemaSpec), JSON.stringify(schema)).toThrow(JsonSchemaError)
}
expect(() => parameterSchemaSpecToJsonSchema({
value: { type: 'string', required: false },
} as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
expect(() => parameterSchemaSpecToJsonSchema(null as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
expect(() => parameterSchemaSpecToJsonSchema({ bad: 42 } as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
})
it('rejects cyclic author schemas', () => {
const schema: Record<string, unknown> = { type: 'array' }
schema.items = schema
expect(() => valueSchemaSpecToJsonSchema(schema as unknown as ValueSchemaSpec)).toThrow(/circular/)
const properties: Record<string, unknown> = {}
properties.self = { type: 'object', additionalProperties: true, properties }
expect(() => parameterSchemaSpecToJsonSchema(properties as ParameterSchemaSpec)).toThrow(/circular/)
})
it('infers scalar literals, arrays, objects, json, and exact-one unions', () => {
expectTypeOf<InferValue<{ type: 'string'; enum: readonly ['a', 'b'] }>>().toEqualTypeOf<'a' | 'b'>()
expectTypeOf<InferValue<{ type: 'number'; const: 1 }>>().toEqualTypeOf<1>()
expectTypeOf<InferValue<{ type: 'integer' }>>().toEqualTypeOf<number>()
expectTypeOf<InferValue<{ type: 'boolean'; enum: readonly [true] }>>().toEqualTypeOf<true>()
expectTypeOf<InferValue<{ type: 'null' }>>().toEqualTypeOf<null>()
expectTypeOf<InferValue<{ type: 'array'; items: { type: 'string' } }>>().toEqualTypeOf<string[]>()
expectTypeOf<InferValue<{ type: 'array' }>>().toEqualTypeOf<JsonValue[]>()
expectTypeOf<InferValue<{ type: 'json' }>>().toEqualTypeOf<JsonValue>()
expectTypeOf<InferValue<{ oneOf: readonly [{ type: 'string' }, { type: 'null' }] }>>()
.toEqualTypeOf<string | null>()
expectTypeOf<InferValue<{
type: 'object'
additionalProperties: false
properties: { id: { type: 'integer'; required: true }; label: { type: 'string' } }
}>>().toEqualTypeOf<{ id: number; label?: string }>()
expectTypeOf<InferValue<{
type: 'object'
additionalProperties: true
properties: { id: { type: 'integer'; required: true } }
}>>().toEqualTypeOf<{ id: number } & Record<string, JsonValue>>()
})
it('infers required and optional parameter keys', () => {
expectTypeOf<InferArgs<{
path: { type: 'string'; required: true }
offset: { type: 'integer' }
data: { type: 'json' }
}>>().toEqualTypeOf<{ path: string; offset?: number; data?: JsonValue }>()
})
it('makes invalid author forms compile-time errors', () => {
const invalidObjects = {
// @ts-expect-error explicit object schemas require an openness decision
object: { type: 'object' } satisfies ValueSchemaSpec,
// @ts-expect-error oneOf requires at least two branches
oneOf: { oneOf: [{ type: 'string' }] } satisfies ValueSchemaSpec,
// @ts-expect-error scalar enum values must match the node type
enum: { type: 'number', enum: ['1'] } satisfies ValueSchemaSpec,
// @ts-expect-error parameter requiredness is true-or-absent
required: { value: { type: 'string', required: false } } satisfies ParameterSchemaSpec,
}
expect(Object.keys(invalidObjects)).toHaveLength(4)
})
})

View File

@@ -5,8 +5,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { Agent } from '@deepseek-ai/dsh-agent'
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult,
} from '@deepseek-ai/dsh-tools'
@@ -775,13 +775,13 @@ describe('ToolRegistry', () => {
})
describe('defineTool / schema DSL', () => {
it('converts SchemaSpec to standard JSON Schema with required array', () => {
it('converts ParameterSchemaSpec to standard JSON Schema with required array', () => {
const spec = {
path: { type: 'string', required: true, description: 'Absolute path' },
offset: { type: 'number' },
limit: { type: 'number', description: 'Max lines' },
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema).toEqual({
type: 'object',
properties: {
@@ -794,7 +794,7 @@ describe('defineTool / schema DSL', () => {
})
it('handles empty spec (no properties, no required)', () => {
expect(schemaSpecToJsonSchema({})).toEqual({
expect(parameterSchemaSpecToJsonSchema({})).toEqual({
type: 'object',
properties: {},
})
@@ -804,19 +804,21 @@ describe('defineTool / schema DSL', () => {
const spec = {
config: {
type: 'object',
additionalProperties: true,
required: true,
properties: {
host: { type: 'string', required: true },
port: { type: 'number' },
},
},
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema).toEqual({
type: 'object',
properties: {
config: {
type: 'object',
additionalProperties: true,
properties: {
host: { type: 'string' },
port: { type: 'number' },
@@ -958,8 +960,8 @@ describe('schema DSL edge cases', () => {
it('emits enum values in JSON Schema property', () => {
const spec = {
color: { type: 'string', enum: ['red', 'green', 'blue'], description: 'Color choice' },
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema.properties['color']).toMatchObject({
type: 'string',
enum: ['red', 'green', 'blue'],
@@ -970,8 +972,8 @@ describe('schema DSL edge cases', () => {
it('emits default value in JSON Schema property', () => {
const spec = {
limit: { type: 'number', default: 25 },
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema.properties['limit']).toMatchObject({
type: 'number',
default: 25,
@@ -981,8 +983,8 @@ describe('schema DSL edge cases', () => {
it('handles array items without nested properties (plain type array)', () => {
const spec = {
tags: { type: 'array', items: { type: 'string' } },
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema.properties['tags']).toEqual({
type: 'array',
items: { type: 'string' },
@@ -992,8 +994,8 @@ describe('schema DSL edge cases', () => {
it('handles enum and default together in one property', () => {
const spec = {
level: { type: 'string', enum: ['low', 'high'], default: 'low' },
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema.properties['level']).toMatchObject({
type: 'string',
enum: ['low', 'high'],
@@ -1004,8 +1006,8 @@ describe('schema DSL edge cases', () => {
it('omits description, enum, default keys when not specified', () => {
const spec = {
bare: { type: 'string' },
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
const prop = jsonSchema.properties['bare'] as Record<string, unknown>
expect(prop).toEqual({ type: 'string' })
expect('description' in prop).toBe(false)
@@ -1016,8 +1018,8 @@ describe('schema DSL edge cases', () => {
it('handles array with no items (items omitted)', () => {
const spec = {
raw: { type: 'array' },
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema.properties['raw']).toEqual({
type: 'array',
})
@@ -1027,13 +1029,14 @@ describe('schema DSL edge cases', () => {
const spec = {
config: {
type: 'object',
additionalProperties: true,
properties: {
host: { type: 'string' },
port: { type: 'number' },
},
},
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema.properties['config']).toMatchObject({
type: 'object',
properties: {
@@ -1064,6 +1067,7 @@ describe('schema DSL optional and nested contracts', () => {
type: 'array'
items: {
type: 'object'
additionalProperties: true
properties: {
host: { type: 'string'; required: true }
port: { type: 'number' }
@@ -1073,7 +1077,7 @@ describe('schema DSL optional and nested contracts', () => {
}>
expectTypeOf<Args>().toEqualTypeOf<{
names: string[]
servers?: { host: string; port?: number }[]
servers?: ({ host: string; port?: number } & Record<string, JsonValue>)[]
}>()
})
@@ -1083,20 +1087,22 @@ describe('schema DSL optional and nested contracts', () => {
type: 'array',
items: {
type: 'object',
additionalProperties: true,
properties: {
host: { type: 'string', required: true },
port: { type: 'number' },
},
},
},
} satisfies SchemaSpec
expect(schemaSpecToJsonSchema(spec)).toEqual({
} satisfies ParameterSchemaSpec
expect(parameterSchemaSpecToJsonSchema(spec)).toEqual({
type: 'object',
properties: {
servers: {
type: 'array',
items: {
type: 'object',
additionalProperties: true,
properties: {
host: { type: 'string' },
port: { type: 'number' },
@@ -1178,7 +1184,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
const spec = {
path: { type: 'string', required: true },
limit: { type: 'number' },
} satisfies SchemaSpec
} satisfies ParameterSchemaSpec
expect(validateArgs(spec, { path: '/tmp' })).toEqual([])
expect(validateArgs(spec, { path: '/tmp', limit: 5 })).toEqual([])
// never throws regardless of shape
@@ -1188,18 +1194,18 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
})
it('flags a missing required key and a required key present as undefined', () => {
const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
const spec = { path: { type: 'string', required: true } } satisfies ParameterSchemaSpec
expect(validateArgs(spec, {})).toEqual(['missing required property "path"'])
expect(validateArgs(spec, { path: undefined })).toEqual(['missing required property "path"'])
})
it('allows extra keys (no additionalProperties:false) and omitted optionals', () => {
const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
const spec = { path: { type: 'string', required: true } } satisfies ParameterSchemaSpec
expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([])
})
it('does not apply defaults (validation only)', () => {
const spec = { limit: { type: 'number', default: 25 } } satisfies SchemaSpec
const spec = { limit: { type: 'number', default: 25 } } satisfies ParameterSchemaSpec
// absent optional is valid, and validation does not synthesize the default
expect(validateArgs(spec, {})).toEqual([])
})
@@ -1209,39 +1215,41 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
s: { type: 'string' },
n: { type: 'number' },
b: { type: 'boolean' },
} satisfies SchemaSpec
} satisfies ParameterSchemaSpec
expect(validateArgs(spec, { s: 1 })).toEqual(['"s" must be a string'])
expect(validateArgs(spec, { n: 'x' })).toEqual(['"n" must be a number'])
expect(validateArgs(spec, { b: 'x' })).toEqual(['"b" must be a boolean'])
})
it('checks enum membership', () => {
const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies SchemaSpec
const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies ParameterSchemaSpec
expect(validateArgs(spec, { color: 'red' })).toEqual([])
expect(validateArgs(spec, { color: 'blue' })).toEqual(['"color" must be one of ["red","green"]'])
})
it('checks enum uniformly with the converter (enum on a non-string prop)', () => {
// The converter emits `enum` regardless of type; the validator must agree.
// `enum` is string[], so a number value can never be a member.
const spec = { n: { type: 'number', enum: ['1', '2'] } } as unknown as SchemaSpec
expect(validateArgs(spec, { n: 1 })).toEqual(['"n" must be one of ["1","2"]'])
it('enforces type-correct scalar enum declarations', () => {
const spec = { n: { type: 'number', enum: [1, 2] } } satisfies ParameterSchemaSpec
expect(validateArgs(spec, { n: 1 })).toEqual([])
expect(validateArgs(spec, { n: 3 })).toEqual(['"n" must be one of [1,2]'])
const invalid = { n: { type: 'number', enum: ['1', '2'] } } as unknown as ParameterSchemaSpec
expect(() => validateArgs(invalid, { n: 1 })).toThrow(JsonSchemaError)
})
it('rejects an unknown SchemaType at runtime (assertNever guard)', () => {
const spec = { x: { type: 'weird' } } as unknown as SchemaSpec
expect(() => validateArgs(spec, { x: 1 })).toThrow(/unreachable variant.*validateArgs/)
it('rejects an unknown schema type at the author boundary', () => {
const spec = { x: { type: 'weird' } } as unknown as ParameterSchemaSpec
expect(() => validateArgs(spec, { x: 1 })).toThrow(JsonSchemaError)
})
it('recurses into nested objects (and an object without properties only type-checks)', () => {
const spec = {
config: {
type: 'object',
additionalProperties: true,
required: true,
properties: { host: { type: 'string', required: true }, port: { type: 'number' } },
},
bag: { type: 'object' },
} satisfies SchemaSpec
bag: { type: 'object', additionalProperties: true },
} satisfies ParameterSchemaSpec
expect(validateArgs(spec, { config: { host: 'h' }, bag: { anything: true } })).toEqual([])
expect(validateArgs(spec, { config: { port: 9 }, bag: 5 })).toEqual([
'missing required property "config.host"',
@@ -1253,7 +1261,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
const spec = {
tags: { type: 'array', items: { type: 'string' } },
raw: { type: 'array' },
} satisfies SchemaSpec
} satisfies ParameterSchemaSpec
expect(validateArgs(spec, { tags: ['a', 'b'], raw: [1, {}, 'x'] })).toEqual([])
expect(validateArgs(spec, { tags: ['a', 2] })).toEqual(['"tags[1]" must be a string'])
// a non-array value for an array-typed prop
@@ -1264,9 +1272,9 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
const spec = {
servers: {
type: 'array',
items: { type: 'object', properties: { host: { type: 'string', required: true } } },
items: { type: 'object', additionalProperties: true, properties: { host: { type: 'string', required: true } } },
},
} satisfies SchemaSpec
} satisfies ParameterSchemaSpec
expect(validateArgs(spec, { servers: [{ host: 'a' }, {}] })).toEqual([
'missing required property "servers[1].host"',
])

View File

@@ -1,20 +1,37 @@
import { describe, expect, it } from 'vitest'
import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
import { schemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
describe('jsonSchemaToTs', () => {
it('maps the defineTool DSL subset', () => {
it('maps every unified schema construct', () => {
const cases: [unknown, string][] = [
[{ type: 'string' }, 'string'],
[{ type: 'number' }, 'number'],
[{ type: 'integer' }, 'number'],
[{ type: 'boolean' }, 'boolean'],
[{ type: 'null' }, 'null'],
[{ type: 'string', enum: ['a', 'b'] }, '"a" | "b"'],
[{ type: 'number', enum: [1, 2] }, '1 | 2'],
[{ type: 'integer', const: 2 }, '2'],
[{ type: 'boolean', const: true }, 'true'],
[{ type: 'null', const: null }, 'null'],
[{ type: 'string', enum: ['a', 'b'], const: 'a' }, '"a"'],
[{ oneOf: [{ type: 'string' }, { type: 'null' }] }, 'string | null'],
[{ type: 'array', items: { type: 'number' } }, 'number[]'],
[{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, '("x" | "y")[]'],
[{ type: 'array' }, 'unknown[]'],
[{ type: 'object' }, 'Record<string, unknown>'],
[{ type: 'object', properties: {} }, 'Record<string, unknown>'],
[{ type: 'array' }, 'JsonValue[]'],
[{ type: 'object' }, 'Record<string, JsonValue>'],
[{ type: 'object', additionalProperties: false }, 'Record<string, never>'],
[{ type: 'object', properties: {} }, 'Record<string, JsonValue>'],
[{ type: 'object', properties: {}, additionalProperties: false }, 'Record<string, never>'],
[{
type: 'object',
additionalProperties: false,
properties: { id: { type: 'integer' }, label: { type: 'string' } },
required: ['id'],
}, ['{', ' id: number;', ' label?: string;', '}'].join('\n')],
[{}, 'JsonValue'],
]
for (const [schema, expected] of cases) {
expect(jsonSchemaToTs(schema), JSON.stringify(schema)).toBe(expected)
@@ -22,11 +39,12 @@ describe('jsonSchemaToTs', () => {
})
it('renders objects with required/optional keys, nested shapes, and per-property docs', () => {
const schema = schemaSpecToJsonSchema({
const schema = parameterSchemaSpecToJsonSchema({
path: { type: 'string', required: true, description: 'Absolute file path' },
limit: { type: 'number' },
opts: {
type: 'object',
additionalProperties: true,
properties: { deep: { type: 'boolean', required: true } },
},
})
@@ -37,8 +55,8 @@ describe('jsonSchemaToTs', () => {
' limit?: number;',
' opts?: {',
' deep: boolean;',
' };',
'}',
' } & Record<string, JsonValue>;',
'} & Record<string, JsonValue>',
].join('\n'))
})
@@ -48,9 +66,6 @@ describe('jsonSchemaToTs', () => {
null,
42,
'string-schema',
{},
{ type: 'integer' },
{ type: 'null' },
{ oneOf: [{ type: 'string' }] },
{ $ref: '#/defs/x' },
{ type: 'object', properties: 7 },
@@ -61,18 +76,13 @@ describe('jsonSchemaToTs', () => {
for (const schema of cases) {
expect(() => jsonSchemaToTs(schema), JSON.stringify(schema)).not.toThrow()
}
expect(jsonSchemaToTs({ type: 'integer' })).toBe('unknown')
expect(jsonSchemaToTs({ oneOf: [] })).toBe('unknown')
expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('Record<string, unknown>')
expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toContain('bad: unknown;')
// A non-string-only enum degrades to plain string; an empty one too.
expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('string')
expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('string')
// A hostile required list only accepts string members.
expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toContain('a?: string;')
// A property VALUE that is not an object degrades to unknown (and can
// carry no description).
expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toContain('weird?: unknown;')
expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('unknown')
expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toBe('unknown')
expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('unknown')
expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('unknown')
expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toBe('unknown')
expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toBe('unknown')
})
it('escapes a comment-closer inside a description so the generated JSDoc cannot end early', () => {
@@ -89,17 +99,18 @@ describe('renderToolsSdk', () => {
const bash: ToolSchema = {
name: 'bash',
description: 'Run a shell command.',
parameters: schemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
parameters: parameterSchemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
}
const exotic: ToolSchema = {
name: 'my-mcp.tool',
description: 'Exotic name.',
parameters: schemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
}
it('declares every tool in lexicographic order with quoted keys for exotic names', () => {
const text = renderToolsSdk([exotic, bash])
expect(text).toContain('declare const tools: {')
expect(text).toContain('type JsonValue = null | boolean | number | string')
expect(text.indexOf('bash(args:')).toBeGreaterThan(0)
expect(text).toContain('"my-mcp.tool"(args:')
expect(text.indexOf('bash(args:')).toBeLessThan(text.indexOf('"my-mcp.tool"(args:'))