chore(opencode): add tool schema compatibility matrix

This commit is contained in:
starptech 2026-07-18 19:14:52 +02:00
commit 4d6f3f002c
4 changed files with 638 additions and 877 deletions

View file

@ -0,0 +1,285 @@
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import type { JSONSchema7 } from "@ai-sdk/provider"
import { Effect } from "effect"
import { jsonSchema, streamText, tool, type LanguageModel } from "ai"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef } from "@/effect/instance-ref"
import { Provider } from "@/provider/provider"
import { InstanceStore } from "@/project/instance-store"
import { MFJS } from "@/provider/mfjs"
// Usage:
// bun run script/tool-schema-compatibility-matrix.ts --models=model-a,model-b
// [--provider=opencode-go] [--projection=none|mfjs]
// [--cases=tuple items,...] [--concurrency=9] [--timeout=30000]
type JsonRecord = Record<string, unknown>
type Case = {
name: string
schema: JsonRecord
}
type Result = {
case: string
model: string
status: "accepted" | "rejected" | "rate-limited" | "error"
code?: number
error?: string
}
const projection = option("projection") ?? "none"
if (projection !== "none" && projection !== "mfjs") throw new Error(`Unsupported projection: ${projection}`)
const concurrency = Number(option("concurrency") ?? 9)
const timeout = Number(option("timeout") ?? 30_000)
const providerID = option("provider") ?? "opencode-go"
const models = option("models")?.split(",").filter(Boolean) ?? []
if (models.length === 0) throw new Error("--models must contain at least one model ID")
const selectedCases = new Set(option("cases")?.split(",").filter(Boolean) ?? [])
const matrix: Case[] = [
property("enum/type mismatch", { type: "object", enum: ["move", "copy"] }),
property("untyped enum", { enum: ["move", "copy"] }),
property("mixed untyped enum", { enum: ["move", 1, null, true] }),
property("const", { const: "move" }),
property("tuple items", { type: "array", items: [{ type: "string" }, { type: "number" }] }),
property("prefix items", { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] }),
property("typed anyOf", {
type: "string",
enum: ["move"],
anyOf: [{ type: "string" }, { type: "null" }],
}),
property("anyOf count limit", {
anyOf: Array.from({ length: 501 }, (_, index) => ({ const: `value_${index}` })),
}),
property("oneOf", { oneOf: [{ type: "string" }, { type: "integer" }] }),
property("allOf", {
allOf: [
{ type: "object", properties: { left: { type: "string" } } },
{ type: "object", properties: { right: { type: "number" } } },
],
}),
property("not", { type: "string", not: { enum: ["blocked"] } }),
property("if/then/else", {
type: "object",
properties: { mode: { type: "string" }, count: { type: "integer" } },
if: { properties: { mode: { enum: ["many"] } } },
then: { required: ["count"] },
else: { properties: { count: { maximum: 1 } } },
}),
property("contains", { type: "array", contains: { type: "string" } }),
objectCase("patternProperties", {
type: "object",
patternProperties: { "^extra_": { type: "number" } },
additionalProperties: false,
}),
objectCase("dependentSchemas", {
type: "object",
properties: { key: { type: "string" }, value: { type: "string" } },
dependentSchemas: { key: { required: ["value"] } },
}),
objectCase("propertyNames", {
type: "object",
propertyNames: { pattern: "^[a-z]+$" },
}),
property("uniqueItems", { type: "array", items: { type: "string" }, uniqueItems: true }),
property("boolean true schema", true),
property("boolean false schema", false),
objectCase("empty property name", {
type: "object",
properties: { "": { type: "string" } },
required: [""],
}),
objectCase("dangling required", {
type: "object",
properties: {},
required: ["missing"],
}),
objectCase("external ref", {
type: "object",
properties: { value: { $ref: "https://example.com/schema.json" } },
}),
objectCase("chained ref", {
type: "object",
properties: { value: { $ref: "#/$defs/A" } },
$defs: { A: { $ref: "#/$defs/B" }, B: { type: "string" } },
}),
objectCase("recursive ref", {
type: "object",
properties: { node: { $ref: "#/$defs/Node" } },
$defs: {
Node: {
type: "object",
properties: { value: { type: "string" }, next: { anyOf: [{ $ref: "#/$defs/Node" }, { type: "null" }] } },
required: ["value"],
},
},
}),
objectCase("reference depth limit", {
type: "object",
properties: { value: { $ref: "#/$defs/Value" } },
$defs: { Value: nested(30) },
}),
objectCase("schema size limit", {
type: "object",
description: "x".repeat(120_001),
properties: {},
}),
objectCase("schema depth limit", nested(35)),
objectCase("property count limit", {
type: "object",
properties: Object.fromEntries(
Array.from({ length: 3001 }, (_, index) => [`property_${index}`, { type: "string" }]),
),
}),
property("enum count limit", {
type: "string",
enum: Array.from({ length: 1001 }, (_, index) => `value_${index}`),
}),
]
const cases = selectedCases.size === 0 ? matrix : matrix.filter((item) => selectedCases.has(item.name))
const { store, ctx } = await AppRuntime.runPromise(
InstanceStore.Service.use((store) =>
store.load({ directory: process.cwd() }).pipe(Effect.map((ctx) => ({ store, ctx }))),
),
)
try {
const languages = await AppRuntime.runPromise(
Effect.gen(function* () {
const provider = yield* Provider.Service
return yield* Effect.forEach(
models,
Effect.fnUntraced(function* (model) {
const info = yield* provider.getModel(ProviderV2.ID.make(providerID), ModelV2.ID.make(model))
return [model, yield* provider.getLanguage(info)] as const
}),
{ concurrency: "unbounded" },
)
}).pipe(Effect.provideService(InstanceRef, ctx)),
)
const jobs = languages.flatMap(([model, language]) => cases.map((item) => () => run(language, model, item)))
const results = await parallel(jobs, concurrency)
print(results)
if (projection !== "none" && results.some((result) => result.status !== "accepted")) process.exitCode = 1
} finally {
await AppRuntime.runPromise(store.dispose(ctx))
}
process.exit(process.exitCode ?? 0)
async function run(language: LanguageModel, model: string, item: Case): Promise<Result> {
const schema = projection === "mfjs" ? MFJS.sanitize(item.schema) : item.schema
let providerError: unknown
try {
const response = streamText({
model: language,
prompt: "Reply with exactly OK without calling tools.",
maxOutputTokens: 16,
abortSignal: AbortSignal.timeout(timeout),
onError(event) {
providerError = event.error
},
tools: {
probe: tool({
description: `Tool schema probe: ${item.name}`,
inputSchema: jsonSchema(schema as JSONSchema7),
execute: async () => "ok",
}),
},
})
await response.text
if (providerError) throw providerError
console.error(`accepted: ${model} / ${item.name}`)
return { case: item.name, model, status: "accepted" }
} catch (error) {
const failure = providerError ?? error
const code = statusCode(failure)
const message = failure instanceof Error ? failure.message : String(failure)
const status = code === 429 || /rate limit/i.test(message) ? "rate-limited" : code === 400 ? "rejected" : "error"
console.error(`${status}: ${model} / ${item.name}`)
return {
case: item.name,
model,
status,
code,
error: message,
}
}
}
async function parallel<T>(jobs: Array<() => Promise<T>>, limit: number) {
const output = new Array<T>(jobs.length)
let next = 0
await Promise.all(
Array.from({ length: Math.min(limit, jobs.length) }, async () => {
while (true) {
const index = next++
const job = jobs[index]
if (!job) return
output[index] = await job()
}
}),
)
return output
}
function print(results: Result[]) {
const byCase = new Map<string, Map<string, Result>>()
results.forEach((result) => {
const row = byCase.get(result.case) ?? new Map<string, Result>()
row.set(result.model, result)
byCase.set(result.case, row)
})
console.log(`Projection: ${projection}`)
console.log(`Provider: ${providerID}`)
console.log(`| Case | ${models.join(" | ")} |`)
console.log(`| --- | ${models.map(() => "---").join(" | ")} |`)
cases.forEach((item) => {
const row = byCase.get(item.name)
const values = models.map((model) => {
const result = row?.get(model)
if (!result) return "missing"
return result.status === "accepted" ? "accepted" : `${result.status}${result.code ? ` (${result.code})` : ""}`
})
console.log(`| ${item.name} | ${values.join(" | ")} |`)
})
const rejected = results.filter((result) => result.status !== "accepted")
if (rejected.length > 0) {
console.log("\nRejected details:")
rejected.forEach((result) => console.log(`- ${result.model} / ${result.case}: ${result.error}`))
}
}
function property(name: string, schema: unknown): Case {
return objectCase(name, { type: "object", properties: { value: schema }, required: ["value"] })
}
function objectCase(name: string, schema: JsonRecord): Case {
return { name, schema }
}
function nested(depth: number): JsonRecord {
return Array.from({ length: depth }).reduce<JsonRecord>(
(schema) => ({ type: "object", properties: { next: schema }, required: ["next"] }),
{ type: "string" },
)
}
function option(name: string) {
const prefix = `--${name}=`
return process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length)
}
function statusCode(error: unknown, seen = new Set<object>()): number | undefined {
if (!isRecord(error) || seen.has(error)) return
seen.add(error)
if (typeof error.statusCode === "number") return error.statusCode
for (const value of Object.values(error)) {
const nested = statusCode(value, seen)
if (nested !== undefined) return nested
}
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

View file

@ -2,7 +2,28 @@ export * as MFJS from "./mfjs"
import type { JSONSchema7 } from "@ai-sdk/provider"
// MFJS specification and reference validator: https://github.com/MoonshotAI/walle
/**
* Kimi tool-schema compatibility projection.
*
* Principles:
* - Preserve schema features accepted by Kimi without rewriting them.
* - Apply model-agnostic adaptations only for reproduced provider rejections.
* - Keep explicit types authoritative; lossy fallbacks may widen but never narrow.
* - Leave the original tool schema as the execution-time validation authority.
*
* Adapted families include enum/type conflicts, untyped enums, tuple `items`,
* typed `anyOf`, boolean schemas, dangling `required`, references, and observed
* enum/union/property/size/depth limits. Accepted keywords such as `const`,
* `oneOf`, `allOf`, conditionals, `prefixItems`, and other constraints pass
* through recursively.
*
* `script/kimi-tool-schema-matrix.ts` compares raw and projected schemas across
* Kimi 3, Kimi 2.7 Code, and Kimi 2.6. Keep this projection evidence-driven as
* provider behavior evolves.
*
* MFJS specification and reference implementation:
* https://github.com/MoonshotAI/walle
*/
type JsonRecord = Record<string, unknown>
type Context = {
@ -10,416 +31,200 @@ type Context = {
definitions: JsonRecord
properties: number
}
type Position = {
depth: number
definition: boolean
root: boolean
}
const TYPES = new Set(["string", "number", "boolean", "integer", "object", "array", "null"])
const RESERVED_PROPERTIES = new Set(["$defs", "$ref", "anyOf", "required", "additionalProperties"])
const SCHEMA_MAPS = new Set(["patternProperties", "dependentSchemas"])
const SCHEMA_NODES = new Set([
"additionalProperties",
"not",
"if",
"then",
"else",
"contains",
"propertyNames",
"unevaluatedProperties",
])
const SCHEMA_LISTS = new Set(["oneOf", "allOf", "prefixItems"])
const MAX_ANY_OF = 500
const MAX_ENUM = 1000
const MAX_DEPTH = 30
const MAX_ENUM = 1000
const MAX_PROPERTIES = 3000
const MAX_SCHEMA_SIZE = 120_000
// Moonshot accepts MFJS, a strict and intentionally smaller JSON Schema dialect.
// Lower broader schemas here so one incompatible tool cannot reject the whole request.
/**
* Projects tool schemas only where Kimi rejects otherwise valid requests.
* Accepted keywords are preserved, explicit types remain authoritative, and
* lossy fallbacks only widen what the model may emit.
*/
export function sanitize(value: unknown): JSONSchema7 {
const root = isPlainObject(value) ? value : {}
const definitions = collectDefinitions(root)
const context = { root, definitions: referencedDefinitions(root, definitions), properties: 0 }
const lowered = sanitizeNode(root, context, { depth: 0, definition: false, root: true })
const rooted = forceObjectRoot(lowered, context)
const referenced = pruneInvalidRefs(rooted, rooted)
const terminating = ensureTermination(forceObjectRoot(referenced, context))
const depthBounded =
schemaDepth(terminating, terminating, new Set()) <= MAX_DEPTH ? terminating : emptyRoot(terminating)
return fitSchemaSize(forceObjectRoot(depthBounded, context)) as JSONSchema7
}
function forceObjectRoot(result: JsonRecord, context: Context) {
if (result.type === "object") return result
if (typeof result.$ref === "string" && refIsObject(result.$ref, context)) return result
if (Array.isArray(result.anyOf)) {
const branches = result.anyOf.filter(isPlainObject).map((branch) => {
if (branch.type === "object" || typeof branch.$ref !== "string" || !refIsObject(branch.$ref, context)) {
return branch
}
const target = resolveRef(branch.$ref, context)
return isPlainObject(target)
? sanitizeNode(target, context, { depth: 0, definition: false, root: false })
: branch
})
if (branches.length > 0 && branches.every((branch) => branch.type === "object")) {
return withMetadata(mergeObjectUnion(branches), result)
}
}
return withMetadata({ type: "object", properties: {} }, result)
}
function sanitizeNode(value: unknown, context: Context, position: Position): JsonRecord {
if (position.depth >= MAX_DEPTH) return {}
if (value === true || value === false) return position.root ? { type: "object", properties: {} } : {}
if (!isPlainObject(value)) return position.root ? { type: "object", properties: {} } : {}
if (Object.keys(value).length === 0) return position.root ? { type: "object", properties: {} } : {}
const source = lowerAllOf(value, context)
const ref = rewriteRef(source.$ref)
if (ref && refExists(ref, context)) {
const siblings = Object.fromEntries(
Object.entries(source).filter(([key]) => !["$ref", "description", "title", "default"].includes(key)),
)
if (Object.keys(siblings).length === 0) return withRootMetadata({ $ref: ref }, source, context, position)
if (Object.keys(siblings).length === 1 && siblings.nullable === true) {
return withRootMetadata({ anyOf: [{ $ref: ref }, { type: "null" }] }, source, context, position)
}
const target = resolveRef(ref, context)
if (isPlainObject(target)) {
return sanitizeNode({ ...target, ...siblings }, context, position)
}
}
const typeList = Array.isArray(source.type)
? source.type.filter((item): item is string => typeof item === "string" && TYPES.has(item))
: []
const scalarType = typeof source.type === "string" && TYPES.has(source.type) ? source.type : undefined
const enumValues = enumValuesOf("const" in source ? [source.const] : source.enum)
const variants = Array.isArray(source.anyOf) ? source.anyOf : Array.isArray(source.oneOf) ? source.oneOf : undefined
if (variants || typeList.length > 1 || source.nullable === true) {
const unionBase = Object.fromEntries(
Object.entries(source).filter(
([key]) => !["anyOf", "oneOf", "allOf", "nullable", "$defs", "definitions", "$id"].includes(key),
),
)
const branches = variants
? variants
.slice(0, MAX_ANY_OF)
.filter(isPlainObject)
.map((branch) => intersect(unionBase, branch))
.filter((branch): branch is JsonRecord => branch !== undefined)
: (typeList.length > 0 ? typeList : [scalarType ?? inferType(source, groupEnum(enumValues))]).flatMap((type) => {
const base = Object.fromEntries(
Object.entries(unionBase).filter(([key]) => !["type", "enum", "const"].includes(key)),
)
const matching = enumValues.filter((item) => typeMatches(item, type))
if (enumValues.length > 0 && matching.length === 0) return []
return [{ ...base, type, ...(matching.length > 0 ? { enum: matching } : {}) }]
})
if (source.nullable === true && !branches.some((branch) => branch.type === "null")) branches.push({ type: "null" })
const sanitized = branches
.map((branch) => sanitizeNode(branch, context, child(position)))
.flatMap((branch) => (Object.keys(branch).length === 1 && Array.isArray(branch.anyOf) ? branch.anyOf : [branch]))
const unique = [...new Map(sanitized.map((branch) => [JSON.stringify(branch), branch])).values()]
const result =
unique.length === 1
? (unique[0] ?? {})
: unique.length > 1
? { anyOf: unique.slice(0, MAX_ANY_OF) }
: sanitizeNode(unionBase, context, position)
return withRootMetadata(result, source, context, position)
}
const explicitType =
typeof source.type === "string" && TYPES.has(source.type)
? source.type
: typeList.length === 1
? typeList[0]
: undefined
const matchingEnum = explicitType ? enumValues.filter((item) => typeMatches(item, explicitType)) : enumValues
const enumGroups = groupEnum(matchingEnum.length > 0 ? matchingEnum : enumValues)
if (enumGroups.length > 1 || (enumGroups.length === 1 && enumValues.length > 0 && matchingEnum.length === 0)) {
const base = Object.fromEntries(
Object.entries(source).filter(([key]) => !["type", "enum", "const", "$defs", "definitions", "$id"].includes(key)),
)
const branches = enumGroups.map((group) =>
sanitizeNode({ ...base, type: group.type, enum: group.values }, context, child(position)),
)
const result = branches.length === 1 ? (branches[0] ?? {}) : { anyOf: branches }
return withRootMetadata(result, source, context, position)
}
const type = explicitType ?? inferType(source, enumGroups)
const result: JsonRecord = { type }
if (typeof source.description === "string") result.description = source.description
if (typeof source.title === "string") result.title = source.title
if ("default" in source && !position.definition) result.default = source.default
if (type === "object") {
if (isPlainObject(source.properties)) {
const remaining = Math.max(0, MAX_PROPERTIES - context.properties)
const entries = Object.entries(source.properties)
.filter(
([name]) =>
name.length > 0 && !RESERVED_PROPERTIES.has(name) && (!position.definition || !name.includes("/")),
)
.slice(0, remaining)
context.properties += entries.length
result.properties = Object.fromEntries(
entries.map(([name, schema]) => [name, sanitizeNode(schema, context, child(position))]),
)
}
const properties = isPlainObject(result.properties) ? result.properties : undefined
if (Array.isArray(source.required) && properties) {
const required = [
...new Set(
source.required.filter(
(item): item is string => typeof item === "string" && item.length > 0 && Object.hasOwn(properties, item),
),
),
]
if (required.length > 0) result.required = required
}
if (typeof source.additionalProperties === "boolean") result.additionalProperties = source.additionalProperties
if (isPlainObject(source.additionalProperties)) {
result.additionalProperties =
Object.keys(source.additionalProperties).length === 0
? {}
: sanitizeNode(source.additionalProperties, context, child(position))
}
}
if (type === "array") {
const items = Array.isArray(source.items)
? source.items.filter(isPlainObject)
: isPlainObject(source.items)
? [source.items]
: Array.isArray(source.prefixItems)
? source.prefixItems.filter(isPlainObject)
: []
const sanitized = [
...new Map(
items.map((item) => sanitizeNode(item, context, child(position))).map((item) => [JSON.stringify(item), item]),
).values(),
]
if (sanitized.length === 1) result.items = sanitized[0]
if (sanitized.length > 1) result.items = { anyOf: sanitized.slice(0, MAX_ANY_OF) }
copyRange(result, source, "minItems", "maxItems", true, true)
}
if (type === "string") copyRange(result, source, "minLength", "maxLength", true, true)
if (type === "number" || type === "integer") {
const minimums = [
...(typeof source.minimum === "number" ? [source.minimum] : []),
...(type === "integer" && typeof source.exclusiveMinimum === "number"
? [Math.floor(source.exclusiveMinimum) + 1]
: []),
]
const maximums = [
...(typeof source.maximum === "number" ? [source.maximum] : []),
...(type === "integer" && typeof source.exclusiveMaximum === "number"
? [Math.ceil(source.exclusiveMaximum) - 1]
: []),
]
const minimum = minimums.length > 0 ? Math.max(...minimums) : undefined
const maximum = maximums.length > 0 ? Math.min(...maximums) : undefined
copyRange(result, { minimum, maximum }, "minimum", "maximum", type === "integer", false)
}
if (["string", "number", "integer", "boolean", "null"].includes(type)) {
const values = (matchingEnum.length > 0 ? matchingEnum : (enumGroups[0]?.values ?? [])).filter((item) =>
typeMatches(item, type),
)
if (values.length > 0) result.enum = values
}
return withRootMetadata(result, source, context, position)
}
function withRootMetadata(result: JsonRecord, source: JsonRecord, context: Context, position: Position) {
if (!position.root) return result
if (typeof source.$id === "string") result.$id = source.$id
const sanitized = Object.fromEntries(
const root = isRecord(value) ? value : {}
const context = { root, definitions: definitions(root), properties: 0 }
const projected = project(root, context, 0)
const defs = Object.fromEntries(
Object.entries(context.definitions)
.filter(([name, schema]) => name.length > 0 && !name.includes("/") && isPlainObject(schema))
.map(([name, schema]) => [name, sanitizeNode(schema, context, { depth: 0, definition: true, root: false })]),
.filter(([name, schema]) => name.length > 0 && !name.includes("/") && isRecord(schema))
.map(([name, schema]) => [name, project(schema, context, 0)]),
)
if (Object.keys(sanitized).length > 0) result.$defs = sanitized
return result
if (Object.keys(defs).length > 0) projected.$defs = defs
const bounded =
schemaDepth(projected, projected, new Set()) > MAX_DEPTH ? { type: "object", properties: {} } : projected
return fitSize(bounded) as JSONSchema7
}
function withMetadata(result: JsonRecord, source: JsonRecord) {
if (typeof source.$id === "string") result.$id = source.$id
if (isPlainObject(source.$defs)) result.$defs = source.$defs
return result
}
function project(value: unknown, context: Context, depth: number): JsonRecord {
if (depth >= MAX_DEPTH) return {}
if (value === true || value === false || !isRecord(value) || Object.keys(value).length === 0) return {}
function child(position: Position): Position {
return { depth: position.depth + 1, definition: position.definition, root: false }
}
function pruneInvalidRefs(schema: JsonRecord, root: JsonRecord): JsonRecord {
if (typeof schema.$ref === "string" && !resolveOutputRef(schema.$ref, root)) return {}
const result = { ...schema }
if (isPlainObject(schema.properties)) {
result.properties = Object.fromEntries(
Object.entries(schema.properties).map(([name, property]) => [
name,
isPlainObject(property) ? pruneInvalidRefs(property, root) : {},
]),
)
}
if (isPlainObject(schema.items)) result.items = pruneInvalidRefs(schema.items, root)
if (isPlainObject(schema.additionalProperties)) {
result.additionalProperties = pruneInvalidRefs(schema.additionalProperties, root)
}
if (Array.isArray(schema.anyOf)) {
const branches = schema.anyOf.filter(isPlainObject).map((branch) => pruneInvalidRefs(branch, root))
if (branches.length === 1) return withMetadata(branches[0] ?? {}, result)
result.anyOf = branches
}
if (isPlainObject(schema.$defs)) {
result.$defs = Object.fromEntries(
Object.entries(schema.$defs).map(([name, definition]) => [
name,
isPlainObject(definition) ? pruneInvalidRefs(definition, root) : {},
]),
)
}
return result
}
function resolveOutputRef(ref: string, root: JsonRecord): JsonRecord | undefined {
if (ref === "#") return root
if (!ref.startsWith("#/$defs/") || ref === "#/$defs/") return
const parts = ref
.slice("#/$defs/".length)
.split("/")
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
return parts.reduce<JsonRecord | undefined>((current, part, index) => {
const value = index === 0 ? (isPlainObject(root.$defs) ? root.$defs[part] : undefined) : current?.[part]
return isPlainObject(value) ? value : undefined
}, undefined)
}
function ensureTermination(schema: JsonRecord) {
if (terminates(schema, schema, new Set())) return schema
if (schema.type === "object") {
const { required: _, ...result } = schema
return result
}
if (schema.type === "array") {
const { items: _, ...result } = schema
return result
}
return emptyRoot(schema)
}
function emptyRoot(source: JsonRecord) {
return {
type: "object",
properties: {},
...(typeof source.$id === "string" ? { $id: source.$id } : {}),
}
}
function terminates(schema: JsonRecord, root: JsonRecord, refs: Set<string>): boolean {
const types = schemaTypes(schema.type)
if (types.some((type) => type !== "object" && type !== "array")) return true
if (types.includes("array")) {
if (!isPlainObject(schema.items) || Object.keys(schema.items).length === 0) return true
if (terminates(schema.items, root, new Set(refs))) return true
const ref = canonicalRef(value.$ref, context)
if (ref) {
if (value.nullable === true) return { anyOf: [{ $ref: ref }, { type: "null" }] }
return { $ref: ref }
}
if (types.includes("object")) {
const required = Array.isArray(schema.required)
? schema.required.filter((item): item is string => typeof item === "string")
: []
const properties = isPlainObject(schema.properties) ? schema.properties : undefined
if (required.length === 0 || !properties || Object.keys(properties).length === 0) return true
if (
required.some((name) => {
const property = properties[name]
return isPlainObject(property) && terminates(property, root, new Set(refs))
})
) {
return true
if (Array.isArray(value.anyOf) && schemaTypes(value.type).length > 0) {
return projectTypedAnyOf(value, context, depth)
}
const result: JsonRecord = {}
let truncatedProperties = false
for (const [key, item] of Object.entries(value)) {
if (key === "$defs" || key === "definitions") continue
if (key === "$ref") continue
if (key === "items" && Array.isArray(item)) continue
if (key === "anyOf" && Array.isArray(item)) {
if (item.length <= MAX_ANY_OF) result.anyOf = item.map((branch) => project(branch, context, depth + 1))
continue
}
if (SCHEMA_LISTS.has(key) && Array.isArray(item)) {
result[key] = item.map((schema) => project(schema, context, depth + 1))
continue
}
if (key === "properties" && isRecord(item)) {
const remaining = Math.max(0, MAX_PROPERTIES - context.properties)
const entries = Object.entries(item).slice(0, remaining)
context.properties += entries.length
truncatedProperties = entries.length !== Object.keys(item).length
result.properties = Object.fromEntries(
entries.map(([name, schema]) => [name, project(schema, context, depth + 1)]),
)
continue
}
if (SCHEMA_MAPS.has(key) && isRecord(item)) {
result[key] = Object.fromEntries(
Object.entries(item).map(([name, schema]) => [name, project(schema, context, depth + 1)]),
)
continue
}
if ((key === "items" || SCHEMA_NODES.has(key)) && isRecord(item)) {
result[key] = project(item, context, depth + 1)
continue
}
result[key] = item
}
if (Array.isArray(schema.anyOf)) {
if (schema.anyOf.some((branch) => isPlainObject(branch) && terminates(branch, root, new Set(refs)))) return true
}
if (typeof schema.$ref === "string") {
if (refs.has(schema.$ref)) return false
const target = resolveOutputRef(schema.$ref, root)
if (!target) return false
const next = new Set(refs)
next.add(schema.$ref)
return terminates(target, root, next)
}
return Object.keys(schema).length === 0
projectRequired(result, context)
if (truncatedProperties) delete result.additionalProperties
return projectEnum(result)
}
function fitSchemaSize(schema: JsonRecord) {
function projectTypedAnyOf(source: JsonRecord, context: Context, depth: number) {
const base = omit(source, ["anyOf", "type", "enum", "const", "$defs", "definitions"])
const parentTypes = schemaTypes(source.type)
const parentEnum = enumValues("const" in source ? [source.const] : source.enum)
const variants = Array.isArray(source.anyOf) ? source.anyOf : []
if (variants.length > MAX_ANY_OF) return project(omit(source, ["anyOf"]), context, depth)
const branches = variants.filter(isRecord).flatMap((branch) => {
const types = intersectTypes(parentTypes, schemaTypes(branch.type))
if (types.length === 0) return []
const branchEnum = enumValues("const" in branch ? [branch.const] : branch.enum)
const values = intersectEnums(parentEnum, branchEnum).filter((value) =>
types.some((type) => matchesType(value, type)),
)
if ((parentEnum.length > 0 || branchEnum.length > 0) && values.length === 0) return []
const merged = omit({ ...base, ...branch }, ["type", "enum", "const"])
return [
project(
{
...merged,
type: types.length === 1 ? types[0] : types,
...(values.length > 0 ? { enum: values } : {}),
},
context,
depth + 1,
),
]
})
return collapse(branches)
}
function projectEnum(source: JsonRecord) {
const result = { ...source }
const values = enumValues(result.enum)
if (values.length === 0) {
delete result.enum
return result
}
const types = schemaTypes(result.type)
if (types.length > 0) {
const compatible = values.filter((value) => types.some((type) => matchesType(value, type)))
if (compatible.length > 0) result.enum = compatible
else delete result.enum
return result
}
const groups = groupEnum(values)
if (groups.length === 1) {
result.type = groups[0]?.type
result.enum = groups[0]?.values
return result
}
const base = omit(result, ["enum", "type"])
return {
anyOf: groups.map((group) => ({ ...base, type: group.type, enum: group.values })),
}
}
function projectRequired(schema: JsonRecord, context: Context) {
if (schema.type !== "object" || !Array.isArray(schema.required)) return
const properties = isRecord(schema.properties) ? { ...schema.properties } : {}
const required = [...new Set(schema.required.filter((item): item is string => typeof item === "string"))].filter(
(name) => {
if (Object.hasOwn(properties, name)) return true
if (context.properties >= MAX_PROPERTIES) return false
context.properties++
properties[name] = {}
return true
},
)
schema.properties = properties
schema.required = required
}
function fitSize(schema: JsonRecord) {
if (schemaSize(schema) <= MAX_SCHEMA_SIZE) return schema
const compact = stripAnnotations(schema)
if (schemaSize(compact) <= MAX_SCHEMA_SIZE) return compact
return { type: "object", properties: {} }
}
function schemaDepth(schema: JsonRecord, root: JsonRecord, refs: Set<string>): number {
const properties = isPlainObject(schema.properties)
? Math.max(
0,
...Object.values(schema.properties).map((item) =>
isPlainObject(item) ? 1 + schemaDepth(item, root, refs) : 0,
),
)
: 0
const items = isPlainObject(schema.items) ? schemaDepth(schema.items, root, refs) : 0
const additionalProperties = isPlainObject(schema.additionalProperties)
? schemaDepth(schema.additionalProperties, root, refs)
: 0
const anyOf = Array.isArray(schema.anyOf)
? Math.max(0, ...schema.anyOf.map((item) => (isPlainObject(item) ? schemaDepth(item, root, refs) : 0)))
: 0
const definitions = isPlainObject(schema.$defs)
? Math.max(
0,
...Object.values(schema.$defs).map((item) => (isPlainObject(item) ? schemaDepth(item, root, refs) : 0)),
)
: 0
const ref = (() => {
if (typeof schema.$ref !== "string" || refs.has(schema.$ref)) return 0
const target = resolveOutputRef(schema.$ref, root)
if (!target) return 0
const next = new Set(refs)
next.add(schema.$ref)
return schemaDepth(target, root, next)
})()
return Math.max(properties, items, additionalProperties, anyOf, definitions, ref)
}
function stripAnnotations(value: JsonRecord): JsonRecord {
function stripAnnotations(schema: JsonRecord): JsonRecord {
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => {
if (["description", "title", "default"].includes(key)) return []
if ((key === "properties" || key === "$defs") && isPlainObject(item)) {
Object.entries(schema).flatMap(([key, value]) => {
if (["description", "title", "default", "examples", "$comment"].includes(key)) return []
if ((key === "properties" || key === "$defs") && isRecord(value)) {
return [
[
key,
Object.fromEntries(
Object.entries(item).map(([name, schema]) => [
name,
isPlainObject(schema) ? stripAnnotations(schema) : schema,
]),
Object.entries(value).map(([name, item]) => [name, isRecord(item) ? stripAnnotations(item) : item]),
),
],
]
}
if (Array.isArray(item)) {
return [[key, item.map((entry) => (isPlainObject(entry) ? stripAnnotations(entry) : entry))]]
if (Array.isArray(value)) {
return [[key, value.map((item) => (isRecord(item) ? stripAnnotations(item) : item))]]
}
return [[key, isPlainObject(item) ? stripAnnotations(item) : item]]
return [[key, isRecord(value) ? stripAnnotations(value) : value]]
}),
)
}
@ -431,161 +236,55 @@ function schemaSize(schema: JsonRecord) {
return new TextEncoder().encode(json).byteLength
}
function mergeObjectUnion(branches: JsonRecord[]) {
const propertyNames = new Set(
branches.flatMap((branch) => (isPlainObject(branch.properties) ? Object.keys(branch.properties) : [])),
function schemaDepth(schema: JsonRecord, root: JsonRecord, refs: Set<string>): number {
const properties = isRecord(schema.properties)
? Math.max(
0,
...Object.values(schema.properties).map((item) => (isRecord(item) ? 1 + schemaDepth(item, root, refs) : 0)),
)
: 0
const nodes = [schema.items, schema.additionalProperties].flatMap((item) =>
isRecord(item) ? [schemaDepth(item, root, refs)] : [],
)
const properties = Object.fromEntries(
[...propertyNames].map((name) => {
const schemas = [
...new Map(
branches
.flatMap((branch) =>
isPlainObject(branch.properties) && isPlainObject(branch.properties[name])
? [branch.properties[name]]
: [],
)
.map((schema) => [JSON.stringify(schema), schema]),
).values(),
]
return [name, schemas.length === 1 ? schemas[0] : { anyOf: schemas.slice(0, MAX_ANY_OF) }]
}),
const lists = [schema.anyOf, schema.oneOf, schema.allOf, schema.prefixItems].flatMap((items) =>
Array.isArray(items) ? items.flatMap((item) => (isRecord(item) ? [schemaDepth(item, root, refs)] : [])) : [],
)
const first = Array.isArray(branches[0]?.required)
? branches[0].required.filter((item): item is string => typeof item === "string")
const definitions = isRecord(schema.$defs)
? Object.values(schema.$defs).flatMap((item) => (isRecord(item) ? [schemaDepth(item, root, refs)] : []))
: []
const required = branches
.map(
(branch) =>
new Set(Array.isArray(branch.required) ? branch.required.filter((item) => typeof item === "string") : []),
)
.reduce((common, branch) => common.filter((name) => branch.has(name)), first)
return {
type: "object",
properties,
...(required.length > 0 ? { required } : {}),
...(branches.every((branch) => branch.additionalProperties === false) ? { additionalProperties: false } : {}),
}
const ref = (() => {
if (typeof schema.$ref !== "string" || refs.has(schema.$ref)) return 0
const target = resolveOutputRef(schema.$ref, root)
if (!target) return 0
const next = new Set(refs)
next.add(schema.$ref)
return schemaDepth(target, root, next)
})()
return Math.max(properties, ...nodes, ...lists, ...definitions, ref)
}
function lowerAllOf(source: JsonRecord, context: Context) {
if (!Array.isArray(source.allOf)) return source
const base = Object.fromEntries(Object.entries(source).filter(([key]) => key !== "allOf"))
return source.allOf.filter(isPlainObject).reduce((result, branch) => {
const ref = rewriteRef(branch.$ref)
const target = ref ? resolveRef(ref, context) : undefined
const next = isPlainObject(target) ? { ...target, ...branch, $ref: undefined } : branch
return intersect(result, next) ?? merge(result, next)
}, base)
function resolveOutputRef(ref: string, root: JsonRecord): JsonRecord | undefined {
if (ref === "#") return root
const defs = isRecord(root.$defs) ? root.$defs : undefined
if (!ref.startsWith("#/$defs/") || !defs) return
return ref
.slice("#/$defs/".length)
.split("/")
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce<JsonRecord | undefined>((current, part, index) => {
const value = index === 0 ? defs[part] : current?.[part]
return isRecord(value) ? value : undefined
}, undefined)
}
function intersect(left: JsonRecord, right: JsonRecord): JsonRecord | undefined {
const leftTypes = schemaTypes(left.type)
const rightTypes = schemaTypes(right.type)
const types =
leftTypes.length > 0 && rightTypes.length > 0
? [
...new Set([
...leftTypes.filter((type) => rightTypes.includes(type)),
...((leftTypes.includes("integer") && rightTypes.includes("number")) ||
(leftTypes.includes("number") && rightTypes.includes("integer"))
? ["integer"]
: []),
]),
]
: leftTypes.length > 0
? leftTypes
: rightTypes
if (leftTypes.length > 0 && rightTypes.length > 0 && types.length === 0) return
const result = merge(left, right)
const leftEnum = enumValuesOf("const" in left ? [left.const] : left.enum)
const rightEnum = enumValuesOf("const" in right ? [right.const] : right.enum)
if (leftEnum.length > 0 && rightEnum.length > 0) {
const intersection = leftEnum.filter((item) => rightEnum.some((other) => Object.is(item, other)))
if (intersection.length === 0) return
delete result.const
result.enum = intersection
}
if (types.length === 1) result.type = types[0]
if (types.length > 1) result.type = types
if (Array.isArray(left.enum) && Array.isArray(right.enum) && Array.isArray(result.enum) && result.enum.length === 0) {
return
}
for (const [minimumKey, maximumKey] of [
["minimum", "maximum"],
["minLength", "maxLength"],
["minItems", "maxItems"],
] as const) {
const minimums = [left[minimumKey], right[minimumKey]].filter(
(item): item is number => typeof item === "number" && Number.isFinite(item),
)
const maximums = [left[maximumKey], right[maximumKey]].filter(
(item): item is number => typeof item === "number" && Number.isFinite(item),
)
if (minimums.length > 0) result[minimumKey] = Math.max(...minimums)
if (maximums.length > 0) result[maximumKey] = Math.min(...maximums)
if (
typeof result[minimumKey] === "number" &&
typeof result[maximumKey] === "number" &&
result[minimumKey] > result[maximumKey]
) {
return
}
}
return result
}
function merge(left: JsonRecord, right: JsonRecord): JsonRecord {
const result = { ...left, ...right }
if (isPlainObject(left.properties) || isPlainObject(right.properties)) {
const names = new Set([
...Object.keys(isPlainObject(left.properties) ? left.properties : {}),
...Object.keys(isPlainObject(right.properties) ? right.properties : {}),
])
result.properties = Object.fromEntries(
[...names].map((name) => {
const a =
isPlainObject(left.properties) && isPlainObject(left.properties[name]) ? left.properties[name] : undefined
const b =
isPlainObject(right.properties) && isPlainObject(right.properties[name]) ? right.properties[name] : undefined
return [name, a && b ? (intersect(a, b) ?? b) : (b ?? a)]
}),
)
}
if (Array.isArray(left.required) || Array.isArray(right.required)) {
result.required = [
...new Set([
...(Array.isArray(left.required) ? left.required : []),
...(Array.isArray(right.required) ? right.required : []),
]),
]
}
if (left.additionalProperties === false || right.additionalProperties === false) result.additionalProperties = false
if (Array.isArray(left.enum) && Array.isArray(right.enum)) {
const rightEnum = right.enum
result.enum = left.enum.filter((item) => rightEnum.some((other) => Object.is(item, other)))
}
return result
}
function rewriteRef(value: unknown) {
function canonicalRef(value: unknown, context: Context) {
if (typeof value !== "string") return
const ref = value.replace("#/definitions/", "#/$defs/")
if (ref === "#" || ref.startsWith("#/$defs/")) return ref
}
function refExists(ref: string, context: Context) {
if (ref === "#") return true
return ref !== "#/$defs/" && isPlainObject(resolveRef(ref, context))
}
function refIsObject(ref: string, context: Context) {
const target = resolveRef(ref, context)
if (!isPlainObject(target)) return false
const lowered = lowerAllOf(target, context)
return lowered.type === "object" || isPlainObject(lowered.properties)
if (ref === "#") return ref
if (!ref.startsWith("#/$defs/") || ref === "#/$defs/") return
const name = ref.slice("#/$defs/".length).split("/", 1)[0]?.replaceAll("~1", "/").replaceAll("~0", "~")
if (!name || name.includes("/")) return
return isRecord(resolveRef(ref, context)) ? ref : undefined
}
function resolveRef(ref: string, context: Context): unknown {
@ -596,54 +295,46 @@ function resolveRef(ref: string, context: Context): unknown {
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce<unknown>((current, part, index) => {
if (index === 0) return context.definitions[part]
if (!isPlainObject(current)) return
return current[part]
return isRecord(current) ? current[part] : undefined
}, undefined)
}
function collectDefinitions(root: JsonRecord) {
function definitions(root: JsonRecord) {
return {
...(isPlainObject(root.definitions) ? root.definitions : {}),
...(isPlainObject(root.$defs) ? root.$defs : {}),
...(isRecord(root.definitions) ? root.definitions : {}),
...(isRecord(root.$defs) ? root.$defs : {}),
}
}
function referencedDefinitions(root: JsonRecord, definitions: JsonRecord) {
const names = new Set<string>()
const visit = (value: unknown) => {
if (Array.isArray(value)) {
value.forEach(visit)
return
}
if (!isPlainObject(value)) return
const ref = rewriteRef(value.$ref)
if (ref?.startsWith("#/$defs/")) {
const name = ref.slice("#/$defs/".length).split("/", 1)[0]?.replaceAll("~1", "/").replaceAll("~0", "~")
if (name && !names.has(name) && isPlainObject(definitions[name])) {
names.add(name)
visit(definitions[name])
}
}
Object.entries(value).forEach(([key, item]) => {
if (key !== "$defs" && key !== "definitions") visit(item)
})
}
visit(root)
return Object.fromEntries([...names].map((name) => [name, definitions[name]]))
}
function schemaTypes(value: unknown) {
if (typeof value === "string" && TYPES.has(value)) return [value]
if (!Array.isArray(value)) return []
return [...new Set(value.filter((item): item is string => typeof item === "string" && TYPES.has(item)))]
}
function enumValuesOf(value: unknown) {
if (!Array.isArray(value)) return []
function intersectTypes(parent: string[], child: string[]) {
if (child.length === 0) return parent
return [
...new Map(value.filter((item) => valueType(item)).map((item) => [JSON.stringify(item), item])).values(),
].slice(0, MAX_ENUM)
...new Set([
...parent.filter((type) => child.includes(type)),
...((parent.includes("number") && child.includes("integer")) ||
(parent.includes("integer") && child.includes("number"))
? ["integer"]
: []),
]),
]
}
function enumValues(value: unknown) {
if (!Array.isArray(value)) return []
const values = unique(value.filter((item) => valueType(item) !== undefined))
return values.length > MAX_ENUM ? [] : values
}
function intersectEnums(parent: unknown[], child: unknown[]) {
if (parent.length === 0) return child
if (child.length === 0) return parent
return parent.filter((value) => child.some((item) => Object.is(value, item)))
}
function groupEnum(values: unknown[]) {
@ -665,42 +356,28 @@ function valueType(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return Number.isInteger(value) ? "integer" : "number"
}
function typeMatches(value: unknown, type: string) {
function matchesType(value: unknown, type: string) {
const actual = valueType(value)
if (type === "number") return actual === "number" || actual === "integer"
return actual === type
}
function inferType(source: JsonRecord, enumGroups: { type: string; values: unknown[] }[]) {
if (["properties", "required", "additionalProperties"].some((key) => key in source)) return "object"
if (["items", "prefixItems", "minItems", "maxItems"].some((key) => key in source)) return "array"
if (["minLength", "maxLength", "format", "pattern"].some((key) => key in source)) return "string"
if (["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"].some((key) => key in source)) {
return "number"
}
return enumGroups[0]?.type ?? "string"
function collapse(branches: JsonRecord[]) {
const projected = unique(branches)
if (projected.length === 0) return {}
if (projected.length === 1) return projected[0] ?? {}
return { anyOf: projected }
}
function copyRange(
result: JsonRecord,
source: JsonRecord,
minimumKey: "minItems" | "minLength" | "minimum",
maximumKey: "maxItems" | "maxLength" | "maximum",
integer: boolean,
nonNegative: boolean,
) {
const valid = (value: unknown) =>
typeof value === "number" &&
Number.isFinite(value) &&
(!integer || Number.isInteger(value)) &&
(!nonNegative || value >= 0)
const minimum = valid(source[minimumKey]) ? source[minimumKey] : undefined
const maximum = valid(source[maximumKey]) ? source[maximumKey] : undefined
if (typeof minimum === "number" && typeof maximum === "number" && minimum > maximum) return
if (minimum !== undefined) result[minimumKey] = minimum
if (maximum !== undefined) result[maximumKey] = maximum
function unique<T>(values: T[]) {
return [...new Map(values.map((value) => [JSON.stringify(value), value])).values()]
}
function isPlainObject(value: unknown): value is JsonRecord {
function omit(source: JsonRecord, keys: string[]) {
const omitted = new Set(keys)
return Object.fromEntries(Object.entries(source).filter(([key]) => !omitted.has(key)))
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

View file

@ -1,101 +1,141 @@
import { describe, expect, test } from "bun:test"
import { MFJS } from "@/provider/mfjs"
function expectProjection(input: unknown, expected: unknown) {
expect(JSON.stringify(MFJS.sanitize(input))).toBe(JSON.stringify(expected))
}
function asObject(value: unknown) {
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record<string, unknown>
throw new Error("expected object")
}
describe("MFJS.sanitize", () => {
test("removes siblings from references while preserving definitions", () => {
test("removes reference siblings and normalizes draft-07 definitions", () => {
expect(
MFJS.sanitize({
type: "object",
properties: {
value: {
$ref: "#/$defs/Value",
description: "Moonshot rejects siblings after expanding a reference.",
},
value: { $ref: "#/definitions/Value", description: "drop me" },
},
$defs: {
Value: { type: "object", description: "The referenced description remains here." },
definitions: {
Value: { type: "object", description: "keep me" },
},
}),
).toEqual({
type: "object",
properties: { value: { $ref: "#/$defs/Value" } },
$defs: {
Value: { type: "object", description: "The referenced description remains here." },
},
$defs: { Value: { type: "object", description: "keep me" } },
})
})
test("repairs enum types that contradict their values", () => {
test("preserves nullable reference semantics", () => {
expect(
MFJS.sanitize({
type: "object",
properties: {
operation: { type: "object", enum: ["move", "copy"] },
},
properties: { value: { $ref: "#/$defs/Value", nullable: true } },
$defs: { Value: { type: "object" } },
}),
).toEqual({
type: "object",
properties: { value: { anyOf: [{ $ref: "#/$defs/Value" }, { type: "null" }] } },
$defs: { Value: { type: "object" } },
})
})
test("keeps explicit types and removes incompatible enum values", () => {
expect(
MFJS.sanitize({
type: "object",
properties: { operation: { type: "object", enum: ["move", "copy"] } },
required: ["operation"],
}),
).toEqual({
type: "object",
properties: {
operation: { type: "string", enum: ["move", "copy"] },
},
properties: { operation: { type: "object" } },
required: ["operation"],
})
})
test("lowers type arrays and nullable enums to anyOf", () => {
test("removes only enum values excluded by an explicit type", () => {
expect(
MFJS.sanitize({
type: "object",
properties: {
operation: { type: ["string", "null"], enum: ["move", null] },
},
properties: { operation: { type: "string", enum: ["move", null] } },
}),
).toEqual({
type: "object",
properties: { operation: { type: "string", enum: ["move"] } },
})
})
test("infers a type for homogeneous untyped enums", () => {
expect(
MFJS.sanitize({
type: "object",
properties: { operation: { enum: ["move", "copy"] } },
}),
).toEqual({
type: "object",
properties: { operation: { type: "string", enum: ["move", "copy"] } },
})
})
test("splits mixed untyped enums into homogeneous branches", () => {
expect(
MFJS.sanitize({
type: "object",
properties: { value: { enum: ["move", 1] } },
}),
).toEqual({
type: "object",
properties: {
operation: {
value: {
anyOf: [
{ type: "string", enum: ["move"] },
{ type: "null", enum: [null] },
{ type: "integer", enum: [1] },
],
},
},
})
})
test("normalizes single-value type arrays", () => {
test("drops tuple items instead of narrowing positional schemas", () => {
expect(
MFJS.sanitize({
type: "object",
properties: { value: { type: ["null"] } },
properties: {
values: {
type: "array",
items: [{ type: "string" }, { type: "number" }],
minItems: 2,
},
},
}),
).toEqual({
type: "object",
properties: { value: { type: "null" } },
properties: { values: { type: "array", minItems: 2 } },
})
})
test("preserves scalar types when lowering nullable schemas", () => {
expect(
MFJS.sanitize({
test("preserves prefixItems accepted by Kimi", () => {
expectProjection(
{
type: "object",
properties: { value: { type: "string", nullable: true } },
}),
).toEqual({
type: "object",
properties: {
value: { anyOf: [{ type: "string" }, { type: "null" }] },
properties: {
values: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] },
},
},
})
{
type: "object",
properties: {
values: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] },
},
},
)
})
test("intersects parent constraints into anyOf branches", () => {
test("moves parent types into compatible anyOf branches", () => {
expect(
MFJS.sanitize({
type: "object",
@ -113,378 +153,141 @@ describe("MFJS.sanitize", () => {
})
})
test("infers missing types and filters dangling required fields", () => {
expect(
MFJS.sanitize({
test("preserves type arrays and nullable accepted by Kimi", () => {
expectProjection(
{
type: "object",
properties: {
mode: { enum: ["fast", "safe"] },
options: { properties: { retries: { type: "integer" } } },
values: { items: { type: "number" } },
typed: { type: ["string", "null"], enum: ["move", null] },
nullable: { type: "string", nullable: true },
},
required: ["mode", "missing"],
}),
).toEqual({
type: "object",
properties: {
mode: { type: "string", enum: ["fast", "safe"] },
options: { type: "object", properties: { retries: { type: "integer" } } },
values: { type: "array", items: { type: "number" } },
},
required: ["mode"],
})
{
type: "object",
properties: {
typed: { type: ["string", "null"], enum: ["move", null] },
nullable: { type: "string", nullable: true },
},
},
)
})
test("converts oneOf and strips unsupported keywords", () => {
test("preserves oneOf, allOf, and other keywords accepted by Kimi", () => {
const value = {
oneOf: [
{ type: "string", format: "uri" },
{ type: "integer", multipleOf: 2 },
],
examples: ["https://example.com"],
}
const all = {
allOf: [
{ type: "object", properties: { left: { type: "string" } } },
{ type: "object", properties: { right: { type: "number" } } },
],
}
expectProjection(
{ type: "object", properties: { value, all }, unevaluatedProperties: false },
{ type: "object", properties: { value, all }, unevaluatedProperties: false },
)
})
test("preserves const accepted by Kimi", () => {
expect(
MFJS.sanitize({
type: "object",
$schema: "https://json-schema.org/draft/2020-12/schema",
properties: {
value: {
oneOf: [
{ type: "string", format: "uri" },
{ type: "integer", multipleOf: 2 },
],
examples: ["https://example.com"],
},
},
unevaluatedProperties: false,
properties: { operation: { const: "move" } },
}),
).toEqual({
type: "object",
properties: {
value: { anyOf: [{ type: "string" }, { type: "integer" }] },
},
properties: { operation: { const: "move" } },
})
})
test("widens heterogeneous tuples without narrowing item types", () => {
test("drops unresolved references", () => {
expect(
MFJS.sanitize({
type: "object",
properties: { value: { $ref: "#/definitions/Missing" } },
}),
).toEqual({
type: "object",
properties: { value: {} },
})
})
test("adds unconstrained schemas for dangling required properties", () => {
expect(
MFJS.sanitize({
type: "object",
properties: {},
required: ["missing"],
}),
).toEqual({
type: "object",
properties: { missing: {} },
required: ["missing"],
})
})
test("preserves unconstrained schemas", () => {
expect(
MFJS.sanitize({
type: "object",
properties: { empty: {}, truthy: true, falsy: false },
}),
).toEqual({
type: "object",
properties: { empty: {}, truthy: {}, falsy: {} },
})
})
test("widens schemas that exceed Kimi limits", () => {
const properties = Object.fromEntries(
Array.from({ length: 3001 }, (_, index) => [`property_${index}`, { type: "string" }]),
)
const limited = asObject(MFJS.sanitize({ type: "object", properties }))
expect(Object.keys(asObject(limited.properties))).toHaveLength(3000)
expect(
MFJS.sanitize({
type: "object",
properties: {
values: {
type: "array",
items: [{ type: "string" }, { type: "number" }],
},
},
}),
).toEqual({
type: "object",
properties: {
values: {
type: "array",
items: { anyOf: [{ type: "string" }, { type: "number" }] },
},
},
})
})
test("collapses homogeneous tuple items", () => {
expect(
MFJS.sanitize({
type: "object",
properties: {
values: {
type: "array",
items: [{ type: "number" }, { type: "number" }],
minItems: 2,
maxItems: 2,
},
},
}),
).toEqual({
type: "object",
properties: {
values: {
type: "array",
items: { type: "number" },
minItems: 2,
maxItems: 2,
},
},
})
})
test("forces mixed root unions to an object parameter schema", () => {
expect(
MFJS.sanitize({
anyOf: [{ type: "object", properties: { value: { type: "string" } } }, { type: "string" }],
}),
).toEqual({ type: "object", properties: {} })
})
test("flattens allOf object schemas", () => {
expect(
MFJS.sanitize({
allOf: [
{
type: "object",
properties: { source: { type: "string" } },
required: ["source"],
},
{
type: "object",
properties: { destination: { type: "string" } },
required: ["destination"],
additionalProperties: false,
},
],
}),
).toEqual({
type: "object",
properties: {
source: { type: "string" },
destination: { type: "string" },
},
required: ["source", "destination"],
additionalProperties: false,
})
})
test("normalizes draft-07 definitions and references", () => {
expect(
MFJS.sanitize({
type: "object",
properties: {
operation: {
$ref: "#/definitions/Operation",
description: "Moonshot rejects siblings next to refs.",
},
},
definitions: {
Operation: { enum: ["move", "copy"] },
},
}),
).toEqual({
type: "object",
properties: { operation: { $ref: "#/$defs/Operation" } },
$defs: {
Operation: { type: "string", enum: ["move", "copy"] },
},
})
})
test("omits unreferenced definitions", () => {
expect(
MFJS.sanitize({
type: "object",
properties: { value: { type: "string" } },
$defs: {
Unused: { type: "object", properties: { ignored: { type: "string" } } },
value: { type: "string", enum: Array.from({ length: 1001 }, (_, index) => `value_${index}`) },
},
}),
).toEqual({
type: "object",
properties: { value: { type: "string" } },
})
})
test("drops references to missing definitions", () => {
expect(
MFJS.sanitize({
type: "object",
properties: { operation: { $ref: "#/$defs/Missing" } },
}),
).toEqual({
type: "object",
properties: { operation: { type: "string" } },
})
})
test("keeps only valid MFJS ranges", () => {
expect(
MFJS.sanitize({
type: "object",
properties: {
score: { type: "number", exclusiveMinimum: -1, exclusiveMaximum: 1 },
count: { type: "integer", exclusiveMinimum: -1, exclusiveMaximum: 3 },
label: { type: "string", minLength: 5, maxLength: 2 },
list: { type: "array", minItems: -1, maxItems: 3 },
},
}),
).toEqual({
type: "object",
properties: {
score: { type: "number" },
count: { type: "integer", minimum: 0, maximum: 2 },
label: { type: "string" },
list: { type: "array", maxItems: 3 },
},
})
})
test("preserves unconstrained child schemas", () => {
expect(
MFJS.sanitize({
type: "object",
properties: {
empty: {},
truthy: true,
falsy: false,
},
}),
).toEqual({
type: "object",
properties: {
empty: {},
truthy: {},
falsy: {},
},
})
})
test("removes empty property names and references to filtered definitions", () => {
expect(
MFJS.sanitize({
type: "object",
properties: {
"": { type: "string" },
value: { $ref: "#/$defs/Bad~1Name" },
},
required: ["", "value"],
$defs: {
"Bad/Name": { type: "object" },
value: { anyOf: Array.from({ length: 501 }, (_, index) => ({ const: `value_${index}` })) },
},
}),
).toEqual({
type: "object",
properties: { value: {} },
required: ["value"],
})
})
test("makes required recursive roots terminable", () => {
expect(
MFJS.sanitize({
type: "object",
properties: { node: { $ref: "#/$defs/Node" } },
required: ["node"],
$defs: {
Node: {
type: "object",
properties: { next: { $ref: "#/$defs/Node" } },
required: ["next"],
},
},
}),
).toEqual({
type: "object",
properties: { node: { $ref: "#/$defs/Node" } },
$defs: {
Node: {
type: "object",
properties: { next: { $ref: "#/$defs/Node" } },
required: ["next"],
},
},
})
})
test("preserves nullable reference semantics", () => {
expect(
MFJS.sanitize({
type: "object",
properties: {
value: { $ref: "#/$defs/Value", nullable: true },
},
$defs: {
Value: { type: "object", properties: { label: { type: "string" } } },
},
}),
).toEqual({
type: "object",
properties: {
value: { anyOf: [{ $ref: "#/$defs/Value" }, { type: "null" }] },
},
$defs: {
Value: { type: "object", properties: { label: { type: "string" } } },
},
})
})
test("merges referenced object branches at the root", () => {
expect(
MFJS.sanitize({
anyOf: [{ $ref: "#/$defs/Left" }, { $ref: "#/$defs/Right" }],
$defs: {
Left: { type: "object", properties: { left: { type: "string" } }, required: ["left"] },
Right: { type: "object", properties: { right: { type: "number" } }, required: ["right"] },
},
}),
).toEqual({
type: "object",
properties: {
left: { type: "string" },
right: { type: "number" },
},
$defs: {
Left: { type: "object", properties: { left: { type: "string" } }, required: ["left"] },
Right: { type: "object", properties: { right: { type: "number" } }, required: ["right"] },
},
})
})
test("combines inclusive and exclusive integer bounds", () => {
expect(
MFJS.sanitize({
type: "object",
properties: {
value: {
type: "integer",
minimum: 0,
exclusiveMinimum: 5,
maximum: 10,
exclusiveMaximum: 8,
},
},
}),
).toEqual({
type: "object",
properties: { value: { type: "integer", minimum: 6, maximum: 7 } },
})
})
test("enforces Walle property and size limits", () => {
const properties = Object.fromEntries(
Array.from({ length: 3001 }, (_, index) => [`property_${index}`, { type: "string" }]),
)
const limited = asObject(MFJS.sanitize({ type: "object", properties }))
expect(Object.keys(asObject(limited.properties))).toHaveLength(3000)
expect(
MFJS.sanitize({
type: "object",
description: "<".repeat(20_000),
properties: {
description: { type: "string" },
default: { type: "string" },
},
}),
).toEqual({
type: "object",
properties: {
description: { type: "string" },
default: { type: "string" },
},
})
})
test("enforces Walle depth limits", () => {
const deep = Array.from({ length: 35 }).reduce<Record<string, unknown>>(
(schema) => ({ type: "object", properties: { next: schema }, required: ["next"] }),
{ type: "string" },
)
expect(JSON.stringify(MFJS.sanitize(deep)).match(/properties/g)?.length).toBe(30)
})
test("enforces depth limits across references", () => {
expect(MFJS.sanitize({ type: "object", description: "<".repeat(20_000), properties: {} })).toEqual({
type: "object",
properties: {},
})
const definition = Array.from({ length: 30 }).reduce<Record<string, unknown>>(
(schema) => ({ type: "object", properties: { next: schema } }),
{ type: "string" },
)
expect(
MFJS.sanitize({
type: "object",
@ -498,14 +301,10 @@ describe("MFJS.sanitize", () => {
const once = MFJS.sanitize({
type: "object",
properties: {
operation: { type: ["string", "null"], enum: ["move", null] },
operation: { type: "object", enum: ["move"] },
values: { type: "array", items: [{ type: "string" }, { type: "number" }] },
},
definitions: {
Metadata: { type: "object", properties: { label: { type: "string", format: "uri" } } },
},
})
expect(MFJS.sanitize(once)).toEqual(once)
})
})

View file

@ -1546,7 +1546,7 @@ describe("ProviderTransform.schema - MFJS selection", () => {
).toEqual({
type: "object",
properties: {
operation: { type: "string", enum: ["move", "copy"] },
operation: { type: "object" },
},
})
})