fix(schema): align projected and enforced declarations

This commit is contained in:
Tianyi Cui
2026-07-23 01:05:41 +08:00
parent 065d20c249
commit e20dea1629
13 changed files with 336 additions and 95 deletions
+107 -27
View File
@@ -86,6 +86,26 @@ const CONSTRAINT_KEYWORDS = new Set([
const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
const SCHEMA_TYPES: readonly JsonSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
/* jscpd:ignore-start -- this realm boundary mirrors the session-owned lossless-JSON intrinsic test */
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
const constructor: unknown = descriptor?.value
if (typeof constructor !== 'function') return false
try {
return constructor.name === name
&& constructor.prototype === prototype
&& Function.prototype.toString.call(constructor) === `function ${name}() { [native code] }`
} catch {
return false
}
}
/** Whether a candidate is one realm's intrinsic `Object.prototype`. */
function isIntrinsicObjectPrototype(value: object): boolean {
return Object.getPrototypeOf(value) === null && hasIntrinsicConstructor(value, 'Object')
}
/**
* Test for a realm-agnostic plain JSON record without accepting arrays or
* exotic objects.
@@ -94,8 +114,61 @@ const SCHEMA_TYPES: readonly JsonSchemaType[] = ['object', 'array', 'string', 'n
*/
export function isPlainJsonRecord(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const proto: unknown = Object.getPrototypeOf(value)
return proto === null || Object.getPrototypeOf(proto) === null
try {
const prototype: unknown = Object.getPrototypeOf(value)
return prototype === null
|| typeof prototype === 'object' && isIntrinsicObjectPrototype(prototype)
} catch {
return false
}
}
/** Whether an array uses one realm's intrinsic `Array.prototype`. */
function hasPlainArrayPrototype(value: unknown[]): boolean {
const prototype: unknown = Object.getPrototypeOf(value)
if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
const objectPrototype: unknown = Object.getPrototypeOf(prototype)
return typeof objectPrototype === 'object'
&& objectPrototype !== null
&& isIntrinsicObjectPrototype(objectPrototype)
}
/* jscpd:ignore-end */
/** Return whether a record contains only own enumerable string keys. */
function hasOnlyEnumerableStringKeys(value: object): boolean {
try {
return Reflect.ownKeys(value)
.every(key => typeof key === 'string' && Object.prototype.propertyIsEnumerable.call(value, key))
} catch {
return false
}
}
/**
* Test for an ordinary schema record whose keys survive JSON projection.
* @param value - candidate record from any JavaScript realm.
* @returns Whether the record has an intrinsic prototype and only own enumerable string keys.
*/
export function isJsonSchemaRecord(value: unknown): value is Record<string, unknown> {
return isPlainJsonRecord(value) && hasOnlyEnumerableStringKeys(value)
}
/**
* Test for a dense ordinary array with no JSON-invisible decorations.
* @param value - candidate array from any JavaScript realm.
* @returns Whether the array is intrinsic, dense, and undecorated.
*/
export function isPlainJsonArray(value: unknown): value is unknown[] {
if (!Array.isArray(value)) return false
try {
if (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) return false
for (let index = 0; index < value.length; index++) {
if (!Object.hasOwn(value, index)) return false
}
return true
} catch {
return false
}
}
/** Lossless finite JSON number, excluding negative zero. */
@@ -133,12 +206,13 @@ function checkObjectSchemaTail(
properties: unknown,
violations: string[],
): void {
const required = node.required
if (Object.hasOwn(node, 'required')) {
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
const hasRequired = Object.hasOwn(node, 'required')
const required = hasRequired ? node.required : undefined
if (hasRequired) {
if (!isPlainJsonArray(required) || required.some(entry => typeof entry !== 'string')) {
violations.push(`${path}.required must be an array of strings`)
} else {
const declared = isPlainJsonRecord(properties) ? properties : {}
const declared = isJsonSchemaRecord(properties) ? properties : {}
for (const key of required as string[]) {
if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
}
@@ -169,7 +243,7 @@ function checkSchemaNode(root: unknown, rootPath: string, violations: string[],
}
const { node, path } = task
if (!isPlainJsonRecord(node)) {
if (!isJsonSchemaRecord(node)) {
violations.push(`${path} must be a schema object`)
continue
}
@@ -192,10 +266,10 @@ function checkSchemaNode(root: unknown, rootPath: string, violations: string[],
}
violations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`)
}
if (node.description !== undefined && typeof node.description !== 'string') {
if (Object.hasOwn(node, 'description') && typeof node.description !== 'string') {
violations.push(`${path}.description must be a string`)
}
if (node.title !== undefined && typeof node.title !== 'string') {
if (Object.hasOwn(node, 'title') && typeof node.title !== 'string') {
violations.push(`${path}.title must be a string`)
}
@@ -215,7 +289,7 @@ function checkSchemaNode(root: unknown, rootPath: string, violations: string[],
if (hasOneOf) {
const oneOf = node.oneOf
tasks.push({ kind: 'one-of-tail', node, path })
if (!Array.isArray(oneOf) || oneOf.length < 2) {
if (!isPlainJsonArray(oneOf) || oneOf.length < 2) {
violations.push(`${path}.oneOf must be an array of at least two schemas`)
} else {
for (let index = oneOf.length - 1; index >= 0; index--) {
@@ -249,10 +323,10 @@ function checkSchemaNode(root: unknown, rootPath: string, violations: string[],
switch (schemaType) {
case 'object': {
const properties = node.properties
const properties = Object.hasOwn(node, 'properties') ? node.properties : undefined
tasks.push({ kind: 'object-tail', node, path, properties })
if (Object.hasOwn(node, 'properties')) {
if (!isPlainJsonRecord(properties)) {
if (!isJsonSchemaRecord(properties)) {
violations.push(`${path}.properties must be an object of schemas`)
} else {
const entries = Object.entries(properties)
@@ -275,18 +349,21 @@ function checkSchemaNode(root: unknown, rootPath: string, violations: string[],
case 'integer':
case 'boolean':
case 'null': {
const allowed = node.enum
const enumValid = Array.isArray(allowed)
const hasEnum = Object.hasOwn(node, 'enum')
const allowed = hasEnum ? node.enum : undefined
const enumValid = isPlainJsonArray(allowed)
&& allowed.length > 0
&& allowed.every(entry => scalarMatches(schemaType, entry))
if (Object.hasOwn(node, 'enum') && !enumValid) {
if (hasEnum && !enumValid) {
violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
}
const constValid = scalarMatches(schemaType, node.const)
if (Object.hasOwn(node, 'const')) {
const hasConst = Object.hasOwn(node, 'const')
const declaredConst = hasConst ? node.const : undefined
const constValid = scalarMatches(schemaType, declaredConst)
if (hasConst) {
if (!constValid) {
violations.push(`${path}.const must be a ${schemaType} value`)
} else if (enumValid && !allowed.includes(node.const as JsonSchemaScalar)) {
} else if (enumValid && !allowed.includes(declaredConst)) {
violations.push(`${path}.const must be one of ${path}.enum when both are declared`)
}
}
@@ -320,7 +397,8 @@ export function assertSupportedJsonSchema(schema: unknown): asserts schema is Js
export function assertObjectJsonSchema(schema: unknown): asserts schema is ObjectJsonSchema {
const violations: string[] = []
checkSchemaNode(schema, 'schema', violations, new Set())
if (violations.length === 0 && (schema as JsonSchemaNode).type !== 'object') {
if (violations.length === 0
&& (!isJsonSchemaRecord(schema) || !Object.hasOwn(schema, 'type') || schema.type !== 'object')) {
violations.push('schema.type must be "object" (structured output is object-rooted)')
}
if (violations.length > 0) throw new JsonSchemaError(violations)
@@ -395,8 +473,9 @@ function valueFrame(node: JsonSchemaNode, value: unknown, path: string): ValueFr
/** Validate one scalar node after its primitive type check. */
function checkScalarValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
if (node.enum !== undefined && !node.enum.includes(value as JsonSchemaScalar)) {
return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(node.enum)}`]
const allowed = Object.hasOwn(node, 'enum') ? node.enum : undefined
if (allowed !== undefined && !allowed.includes(value as JsonSchemaScalar)) {
return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(allowed)}`]
}
if (Object.hasOwn(node, 'const') && value !== node.const) {
return [`"${diagnosticPath(path)}" must be ${JSON.stringify(node.const)}`]
@@ -455,9 +534,9 @@ function checkValue(schema: JsonSchemaNode, value: unknown, path: string): strin
continue
}
const nodeType = frame.node.type
const nodeType = Object.hasOwn(frame.node, 'type') ? frame.node.type : undefined
frame.catches = !(nodeType !== undefined && !(SCHEMA_TYPES as readonly unknown[]).includes(nodeType))
const oneOf = frame.node.oneOf
const oneOf = Object.hasOwn(frame.node, 'oneOf') ? frame.node.oneOf : undefined
if (oneOf !== undefined) {
frame.kind = 'oneOf'
frame.children = Array.from(oneOf, branch => ({ node: branch, value: frame.value, path: frame.path }))
@@ -477,9 +556,10 @@ function checkValue(schema: JsonSchemaNode, value: unknown, path: string): strin
finish([`"${diagnosticPath(frame.path)}" must be an object`])
break
}
const properties = frame.node.properties ?? {}
const properties = Object.hasOwn(frame.node, 'properties') ? frame.node.properties ?? {} : {}
const violations: string[] = []
for (const key of frame.node.required ?? []) {
const required = Object.hasOwn(frame.node, 'required') ? frame.node.required ?? [] : []
for (const key of required) {
if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) {
violations.push(`missing required property "${propertyPath(frame.path, key)}"`)
}
@@ -490,7 +570,7 @@ function checkValue(schema: JsonSchemaNode, value: unknown, path: string): strin
children.push({ node: child, value: frame.value[key], path: propertyPath(frame.path, key) })
}
const tailViolations: string[] = []
if (frame.node.additionalProperties === false) {
if (Object.hasOwn(frame.node, 'additionalProperties') && frame.node.additionalProperties === false) {
for (const key of Object.keys(frame.value)) {
if (!Object.hasOwn(properties, key)) {
tailViolations.push(`"${propertyPath(frame.path, key)}" is not a declared property (additionalProperties: false)`)
@@ -510,7 +590,7 @@ function checkValue(schema: JsonSchemaNode, value: unknown, path: string): strin
finish([`"${diagnosticPath(frame.path)}" must be an array`])
break
}
const items = frame.node.items
const items = Object.hasOwn(frame.node, 'items') ? frame.node.items : undefined
const children = items === undefined
? []
: frame.value.flatMap((entry, index): ValueChild[] => [{ node: items, value: entry, path: `${frame.path}[${index}]` }])