feat(util): inline local schema references in Gemini cleaners and preserve literal values

- Expand local `$ref` definitions before stripping schema definition containers to prevent nested field loss.
- Preserve schema-looking keys inside literal data keywords (`default`, `example`, `examples`, `const`, `enum`).
- Add comprehensive test coverage for schema inlining, literal preservation, and wrapped Antigravity tool schemas.

Closes: #6732
This commit is contained in:
Luis Pater
2026-10-18 22:37:34 +08:00
parent 29076c98f4
commit c558d3203e
3 changed files with 974 additions and 40 deletions

View File

@@ -134,10 +134,9 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
// Phase 0: Normalize malformed schemas (e.g. bare property maps and boolean required from MCP tools)
jsonStr = normalizeMalformedSchemaObjects(jsonStr, options.addMissingArrayItems)
// Phase 1: Convert and add hints
if options.antigravitySemantics {
jsonStr = inlineLocalRefs(jsonStr)
}
// Phase 1: Convert and add hints. Every cleaner removes $defs/definitions later, so local
// references must be expanded here for every route; otherwise nested fields are lost.
jsonStr = inlineLocalRefsForClean(jsonStr)
jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics)
jsonStr = convertConstToEnum(jsonStr)
jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType)
@@ -171,8 +170,8 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string {
jsonStr = removeKeywords(jsonStr, []string{"title"})
}
jsonStr = cleanupRequiredFields(jsonStr)
jsonStr = sanitizeArrayItems(jsonStr)
jsonStr = sanitizeObjectProperties(jsonStr)
jsonStr = sanitizeArrayItems(jsonStr, options.addMissingArrayItems)
// Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement)
if options.addPlaceholder {
jsonStr = addEmptySchemaPlaceholder(jsonStr)
@@ -206,10 +205,13 @@ func sanitizeObjectProperties(jsonStr string) string {
return jsonStr
}
// sanitizeArrayItems ensures that any schema node declaring "items" has "type": "array".
// sanitizeArrayItems ensures that any schema node declaring "items" has "type": "array", and
// (when addMissing is set) that array-typed nodes still missing "items" after reference
// inlining receive the placeholder Phase 0 adds. Phase 0 cannot add it before inlining: on a
// $ref node the placeholder would clobber the referenced definition's real items.
// Gemini's protobuf validator enforces a strict field predicate on items ($type == Type.ARRAY);
// if type is missing, it is inferred as array; if type is explicitly a non-array, items is removed.
func sanitizeArrayItems(jsonStr string) string {
func sanitizeArrayItems(jsonStr string, addMissing bool) string {
paths := findPaths(jsonStr, "items")
sortByDepth(paths)
for _, p := range paths {
@@ -226,6 +228,23 @@ func sanitizeArrayItems(jsonStr string) string {
jsonStr, _ = sjson.Delete(jsonStr, p)
}
}
if !addMissing {
return jsonStr
}
typePaths := findPaths(jsonStr, "type")
sortByDepth(typePaths)
for _, p := range typePaths {
parentPath := trimSuffix(p, ".type")
if isPropertyDefinition(parentPath) || !isArrayDeclaredType(gjson.Get(jsonStr, p).Value()) {
continue
}
if gjson.Get(jsonStr, joinPath(parentPath, "items")).Exists() || gjson.Get(jsonStr, joinPath(parentPath, "$ref")).Exists() {
continue
}
updated, _ := sjson.SetRawBytes([]byte(jsonStr), joinPath(parentPath, "items"), []byte(`{"type":"string"}`))
jsonStr = string(updated)
}
return jsonStr
}
@@ -533,8 +552,10 @@ func repairSchemaNode(node map[string]any, addMissingArrayItems bool) (map[strin
}
// Gemini and Antigravity reject tool array schemas without an items definition,
// and reject tool schemas with items whose type is not ARRAY.
if addMissingArrayItems {
// and reject tool schemas with items whose type is not ARRAY. A node still holding an
// unresolved $ref gets neither a placeholder items nor a synthesized type: both would become
// sibling keywords during inlining and clobber the referenced definition's real values.
if addMissingArrayItems && clone["$ref"] == nil {
if isArrayDeclaredType(clone["type"]) {
if _, hasItems := clone["items"]; !hasItems {
clone["items"] = map[string]any{"type": "string"}
@@ -734,17 +755,34 @@ func mergeStringSlices(existing, promoted []string) []string {
return res
}
// InlineLocalRefs resolves JSON Pointer references against the original schema before definition
// containers are stripped. Each expansion receives its own copy, sibling keywords override the
// InlineLocalRefs resolves JSON Pointer references against the original schema. Every reference
// receives its own copy as long as the expansion budget lasts, sibling keywords override the
// referenced definition, and cycles terminate as a typed hint instead of recursing forever.
func InlineLocalRefs(jsonStr string) string {
return inlineLocalRefs(jsonStr)
}
// inlineLocalRefs resolves JSON Pointer references against the original schema before definition
// containers are stripped. Each expansion receives its own copy, sibling keywords override the
// maxInlinedSchemaBytes bounds the total size of schema material copied into the output by local
// reference expansion. Shared definitions fan out once per reference, so a small input can expand
// exponentially; expansions past the budget degrade to a typed hint like cycles do.
const maxInlinedSchemaBytes = 256 * 1024
// maxFallbackDescriptionBytes bounds the target description copied into a degraded reference, so a
// large shared description cannot be repeated per reference and defeat the expansion budget.
const maxFallbackDescriptionBytes = 512
// inlineLocalRefs resolves JSON Pointer references against the original schema. Every reference
// receives its own copy as long as the expansion budget lasts, sibling keywords override the
// referenced definition, and cycles terminate as a typed hint instead of recursing forever.
func inlineLocalRefs(jsonStr string) string {
return inlineLocalRefsRooted(jsonStr, nil, false)
}
// inlineLocalRefsForClean inlines local references for schema cleaning. Cleaning sometimes
// receives the single-key {"schema": ...} carrier used to nest a schema one level down (see Phase
// 0); references inside it must then resolve against the inner schema, not the carrier, or every
// lookup misses. The carrier shape and root keys are otherwise preserved byte-for-byte.
func inlineLocalRefsForClean(jsonStr string) string {
if !strings.Contains(jsonStr, `"$ref"`) {
return jsonStr
}
@@ -756,7 +794,43 @@ func inlineLocalRefs(jsonStr string) string {
return jsonStr
}
resolved := resolveLocalRefs(root, root, make(map[string]bool))
resolveRoot := root
wrapped := false
if rootMap, ok := root.(map[string]any); ok && len(rootMap) == 1 {
if inner, okInner := rootMap["schema"].(map[string]any); okInner {
resolveRoot = inner
wrapped = true
}
}
if !wrapped {
return inlineLocalRefsRooted(jsonStr, root, true)
}
state := newRefResolveState()
resolved := resolveSchemaRoot(resolveRoot, state, true)
out, err := json.Marshal(map[string]any{"schema": resolved})
if err != nil {
return jsonStr
}
return string(out)
}
func inlineLocalRefsRooted(jsonStr string, parsedRoot any, forClean bool) string {
if !strings.Contains(jsonStr, `"$ref"`) {
return jsonStr
}
root := parsedRoot
if root == nil {
decoder := json.NewDecoder(strings.NewReader(jsonStr))
decoder.UseNumber()
if err := decoder.Decode(&root); err != nil {
return jsonStr
}
}
state := newRefResolveState()
resolved := resolveSchemaRoot(root, state, forClean)
out, err := json.Marshal(resolved)
if err != nil {
return jsonStr
@@ -764,43 +838,366 @@ func inlineLocalRefs(jsonStr string) string {
return string(out)
}
func resolveLocalRefs(root, value any, active map[string]bool) any {
// refResolveState carries the remaining expansion budget of one inlining pass. Every reference
// charges the size of the target it copies (nested references charge their own targets), so
// shared-definition fan-out stops once the budget is spent while ordinary reuse still expands.
type refResolveState struct {
budget int
}
func newRefResolveState() *refResolveState {
return &refResolveState{budget: maxInlinedSchemaBytes}
}
// sortedKeys returns map keys in a deterministic order, so budget allocation never depends on
// Go map iteration order.
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// resolveSchemaRoot resolves references in the schema body before resolving definition
// containers. Definitions only matter through references, so walking them first would spend the
// expansion budget on copies the cleaners delete next (and whose map order is arbitrary), while
// the fields that are actually kept degraded to hints. For the public InlineLocalRefs the
// containers are kept, so they are still resolved afterwards with whatever budget remains; when
// cleaning they are dropped here altogether (references still resolve against the original
// root), so their bytes never reach the intermediate JSON at all.
func resolveSchemaRoot(root any, state *refResolveState, forClean bool) any {
rootMap, ok := root.(map[string]any)
if !ok {
return resolveLocalRefs(root, root, make(map[string]bool), state, false, forClean)
}
out := make(map[string]any, len(rootMap))
for _, key := range sortedKeys(rootMap) {
item := rootMap[key]
switch key {
case "$defs", "definitions":
if !forClean {
out[key] = nil // placeholder, resolved after the schema body below
}
case "$ref":
out[key] = item
default:
out[key] = resolveSchemaEntry(rootMap, key, item, make(map[string]bool), state, forClean)
}
}
// A root-level reference expands into the whole referenced schema; its siblings were resolved
// above and must override the target, mirroring resolveLocalRefs.
if ref, hasRef := rootMap["$ref"].(string); hasRef && strings.HasPrefix(ref, "#/") {
if target, okTarget := resolveJSONPointer(rootMap, ref); okTarget {
if size := targetExpansionSize(target, forClean); size <= state.budget {
state.budget -= size
active := map[string]bool{ref: true}
if targetMap, okMap := resolveLocalRefs(rootMap, target, active, state, false, forClean).(map[string]any); okMap {
merged := make(map[string]any, len(targetMap)+len(out))
for _, key := range sortedKeys(targetMap) {
merged[key] = targetMap[key]
}
for _, key := range sortedKeys(out) {
if key != "$ref" {
merged[key] = out[key]
}
}
out = merged
} else {
out = resolveRefFallback(rootMap, out, target, ref, make(map[string]bool), state, forClean)
}
} else {
out = resolveRefFallback(rootMap, out, target, ref, make(map[string]bool), state, forClean)
}
delete(out, "$ref")
}
}
if !forClean {
for _, key := range []string{"$defs", "definitions"} {
if item, exists := rootMap[key]; exists {
out[key] = resolveSchemaEntry(rootMap, key, item, make(map[string]bool), state, false)
}
}
}
return out
}
// targetExpansionSize estimates what copying a reference target into the output costs. When
// cleaning, definitions nested inside the target are deleted later and never copied, so they are
// excluded; otherwise a small target carrying a large unused definitions block would be degraded
// purely because of material that is about to be thrown away. Name-map and literal context is
// threaded so properties named like a container keyword, and data inside literal values, still
// count in full.
func targetExpansionSize(value any, forClean bool) int {
if !forClean {
return jsonValueSize(value)
}
return cleanExpansionSize(value, false)
}
func cleanExpansionSize(value any, inNameMap bool) int {
switch node := value.(type) {
case nil:
return 4
case bool:
return 5
case json.Number:
return len(node.String())
case string:
return jsonStringEncodedSize(node)
case []any:
size := 2
for _, item := range node {
size += cleanExpansionSize(item, false) + 1
if size > maxInlinedSchemaBytes+1 {
return maxInlinedSchemaBytes + 1
}
}
return size
case map[string]any:
size := 2
for key, item := range node {
if !inNameMap && (key == "$defs" || key == "definitions") {
continue
}
child := item
childInNameMap := false
if !inNameMap {
if isLiteralValueKeyword(key) {
size += jsonStringEncodedSize(key) + 1 + jsonValueSize(child) + 1
if size > maxInlinedSchemaBytes+1 {
return maxInlinedSchemaBytes + 1
}
continue
}
if _, isNameMap := schemaNameMapKeywords[key]; isNameMap {
childInNameMap = true
}
}
size += jsonStringEncodedSize(key) + 1 + cleanExpansionSize(child, childInNameMap) + 1
if size > maxInlinedSchemaBytes+1 {
return maxInlinedSchemaBytes + 1
}
}
return size
default:
return 16
}
}
// jsonValueSize estimates the encoded size of a decoded JSON value as encoding/json will emit
// it, including string escaping (notably the six-byte \u00XX forms for <, >, & and control
// characters). It is used to bound ref expansion, so it must not under-count what serialization
// will actually produce.
func jsonValueSize(value any) int {
switch node := value.(type) {
case nil:
return 4
case bool:
return 5
case json.Number:
return len(node.String())
case string:
return jsonStringEncodedSize(node)
case []any:
size := 2
for _, item := range node {
size += jsonValueSize(item) + 1
}
return size
case map[string]any:
size := 2
for key, item := range node {
size += jsonStringEncodedSize(key) + 1 + jsonValueSize(item) + 1
}
return size
default:
return 16
}
}
// jsonStringEncodedSize returns the encoded length of one JSON string including quotes,
// mirroring encoding/json's default HTML escaping.
func jsonStringEncodedSize(s string) int {
size := 2
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c < 0x20:
if c == '\n' || c == '\r' || c == '\t' {
size += 2
} else {
size += 6
}
case c == '"' || c == '\\':
size += 2
case c == '<' || c == '>' || c == '&':
size += 6
case c < 0x80:
size++
case c == 0xe2 && i+2 < len(s) && s[i+1] == 0x80 && (s[i+2] == 0xa8 || s[i+2] == 0xa9):
size += 6
i += 2
default:
size++
}
}
return size
}
// literalValueKeywords carry client data rather than subschemas. Cleaning and ref inlining must
// copy their values verbatim: keys inside them that happen to look like schema keywords are data.
var literalValueKeywords = map[string]struct{}{
"default": {},
"example": {},
"examples": {},
"const": {},
"enum": {},
}
func isLiteralValueKeyword(key string) bool {
_, ok := literalValueKeywords[key]
return ok
}
func resolveLocalRefs(root, value any, active map[string]bool, state *refResolveState, inNameMap, forClean bool) any {
switch node := value.(type) {
case []any:
out := make([]any, len(node))
for i, item := range node {
out[i] = resolveLocalRefs(root, item, active)
out[i] = resolveLocalRefs(root, item, active, state, false, forClean)
}
return out
case map[string]any:
ref, hasRef := node["$ref"].(string)
if hasRef && strings.HasPrefix(ref, "#/") {
if target, ok := resolveJSONPointer(root, ref); ok {
if active[ref] {
return cyclicRefFallback(node, target, ref)
}
active[ref] = true
resolvedTarget := resolveLocalRefs(root, target, active)
delete(active, ref)
if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget {
out := make(map[string]any, len(targetMap)+len(node))
for key, item := range targetMap {
out[key] = item
if !inNameMap {
ref, hasRef := node["$ref"].(string)
if hasRef && strings.HasPrefix(ref, "#/") {
if target, ok := resolveJSONPointer(root, ref); ok {
if active[ref] {
return resolveRefFallback(root, node, target, ref, active, state, forClean)
}
for key, item := range node {
if key == "$ref" {
continue
// Charge the stored target size and let nested references charge their own
// costs during recursion. The total copied material stays bounded, while an
// over-budget chain still expands its affordable outer levels and only the
// deepest references degrade to hints.
if size := targetExpansionSize(target, forClean); size > state.budget {
return resolveRefFallback(root, node, target, ref, active, state, forClean)
} else {
state.budget -= size
}
active[ref] = true
resolvedTarget := resolveLocalRefs(root, target, active, state, false, forClean)
delete(active, ref)
if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget {
out := make(map[string]any, len(targetMap)+len(node))
for _, key := range sortedKeys(targetMap) {
if forClean && (key == "$defs" || key == "definitions") {
continue // dropped with every other definitions container when cleaning
}
out[key] = targetMap[key]
}
out[key] = resolveLocalRefs(root, item, active)
for _, key := range sortedKeys(node) {
if key == "$ref" {
continue
}
if forClean && (key == "$defs" || key == "definitions") {
continue
}
out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean)
}
return out
}
return out
}
}
}
out := make(map[string]any, len(node))
for _, key := range sortedKeys(node) {
if forClean && !inNameMap && (key == "$defs" || key == "definitions") {
continue // dropped with every other definitions container when cleaning
}
out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean)
}
return out
default:
return value
}
}
// resolveRefFallback degrades a reference that cannot be expanded (cycle or exhausted budget) to
// a typed hint, but still resolves the sibling subschemas on the same node so their own references
// are not left dangling behind the degraded one. Literal values stay verbatim through
// resolveSchemaEntry. The description assembled by cyclicRefFallback is kept afterwards so
// resolved siblings cannot clobber the hint.
func resolveRefFallback(root any, node map[string]any, target any, ref string, active map[string]bool, state *refResolveState, forClean bool) map[string]any {
out := cyclicRefFallback(node, target, ref)
// The target description copied into the fallback is expansion output like any other; charge
// it to the budget. Once the budget is spent, fallbacks carry only the short hint, so a large
// number of degraded references cannot multiply copied descriptions without bound. Cyclic
// fallbacks omit over-long descriptions entirely, and omitted content is not charged.
if targetMap, okMap := target.(map[string]any); okMap {
if description, okDescription := targetMap["description"].(string); okDescription && description != "" && len(description) <= maxFallbackDescriptionBytes {
if _, hasOwn := node["description"]; !hasOwn {
cost := jsonStringEncodedSize(description)
if cost > state.budget {
out["description"] = "See: " + refName(ref)
} else {
state.budget -= cost
}
}
}
}
for _, key := range sortedKeys(node) {
if key == "$ref" || key == "description" {
continue
}
if forClean && (key == "$defs" || key == "definitions") {
delete(out, key)
continue
}
out[key] = resolveSchemaEntry(root, key, node[key], active, state, forClean)
}
return out
}
// resolveSchemaEntry resolves one map entry during ref inlining. A literal-value keyword's value
// is client data and is deep-copied without resolving refs inside it. Under a name map
// ("properties" and friends), however, a key spelled like a literal keyword is an author-chosen
// property name whose value is a subschema; name-map entries are therefore resolved directly
// here so such property names keep their schema semantics. Nested definition containers outside
// the root still resolve through this path; when cleaning, callers skip them entirely instead.
func resolveSchemaEntry(root any, key string, item any, active map[string]bool, state *refResolveState, forClean bool) any {
if isLiteralValueKeyword(key) {
return deepCopyJSONValue(item)
}
if _, isNameMap := schemaNameMapKeywords[key]; isNameMap {
if nameMap, ok := item.(map[string]any); ok {
out := make(map[string]any, len(nameMap))
for _, name := range sortedKeys(nameMap) {
out[name] = resolveLocalRefs(root, nameMap[name], active, state, false, forClean)
}
return out
}
}
return resolveLocalRefs(root, item, active, state, false, forClean)
}
func deepCopyJSONValue(value any) any {
switch node := value.(type) {
case []any:
out := make([]any, len(node))
for i, item := range node {
out[i] = deepCopyJSONValue(item)
}
return out
case map[string]any:
out := make(map[string]any, len(node))
for key, item := range node {
out[key] = resolveLocalRefs(root, item, active)
out[key] = deepCopyJSONValue(item)
}
return out
default:
@@ -837,6 +1234,11 @@ func cyclicRefFallback(node map[string]any, target any, ref string) map[string]a
if targetMap, ok := target.(map[string]any); ok {
for _, key := range []string{"type", "nullable", "description"} {
if value, exists := targetMap[key]; exists {
if key == "description" {
if description, okDescription := value.(string); okDescription && len(description) > maxFallbackDescriptionBytes {
continue
}
}
out[key] = value
}
}
@@ -870,10 +1272,12 @@ func convertRefsToHints(jsonStr string, preserveSiblings bool) string {
sortByDepth(paths)
for _, p := range paths {
parentPath := trimSuffix(p, ".$ref")
if isPropertyDefinition(parentPath) {
continue // a property literally named "$ref", not a reference keyword
}
refVal := gjson.Get(jsonStr, p).String()
defName := refName(refVal)
parentPath := trimSuffix(p, ".$ref")
hint := fmt.Sprintf("See: %s", defName)
if !preserveSiblings {
if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" {
@@ -1582,6 +1986,11 @@ func walkForExtensions(value gjson.Result, path string, paths *[]string) {
return true
}
// Literal values are client data; x-* keys inside them are not schema extensions.
if isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) {
return true
}
walkForExtensions(val, childPath, paths)
return true
})
@@ -1698,7 +2107,7 @@ func addEmptySchemaPlaceholder(jsonStr string) string {
func findPaths(jsonStr, field string) []string {
var paths []string
Walk(gjson.Parse(jsonStr), "", field, &paths)
WalkSchema(gjson.Parse(jsonStr), "", field, &paths)
return paths
}
@@ -1730,6 +2139,12 @@ func walkForFields(value gjson.Result, path string, fields map[string]struct{},
paths[keyStr] = append(paths[keyStr], childPath)
}
// Literal values are client data; schema-looking keys inside them are not keywords.
// Under a name map the key is a property name instead, so keep descending there.
if isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) {
return true
}
walkForFields(val, childPath, fields, paths)
return true
})
@@ -1767,11 +2182,14 @@ func setRawAt(jsonStr, path, value string) string {
}
// schemaNameMapKeywords are the schema keywords whose value maps author-chosen names to
// subschemas. A key directly under one of them is a name, never a schema keyword.
// subschemas. A key directly under one of them is a name, never a schema keyword. The Draft-07
// "dependencies" container qualifies for object (schema) values; array values are property-name
// lists and are never walked as maps by the checks that use this set.
var schemaNameMapKeywords = map[string]struct{}{
"properties": {},
"patternProperties": {},
"dependentSchemas": {},
"dependencies": {},
"$defs": {},
"definitions": {},
}

View File

@@ -3,6 +3,7 @@ package util
import (
"encoding/json"
"reflect"
"strconv"
"strings"
"testing"
@@ -3793,3 +3794,502 @@ func TestCleanJSONSchema_RemovesContentMediaTypeKeyword_Issue6699(t *testing.T)
})
}
}
// TestIssue6732_GeminiCleanersInlineLocalDefs verifies that the Gemini cleaners expand local
// $defs references before the definition container is stripped, so nested fields survive.
func TestIssue6732_GeminiCleanersInlineLocalDefs(t *testing.T) {
input := `{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"required":["person"],"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}`
for name, clean := range map[string]func(string) string{
"gemini": CleanJSONSchemaForGemini,
"geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
} {
got := clean(input)
parsed := gjson.Parse(got)
if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" {
t.Errorf("%s: inlined properties.person.properties.name.type = %q, want %q: %s", name, gotType, "string", got)
}
if parsed.Get("$defs").Exists() {
t.Errorf("%s: $defs should be removed after inlining: %s", name, got)
}
if strings.Contains(got, "See: Person") {
t.Errorf("%s: resolvable local ref degraded to a hint: %s", name, got)
}
}
}
// TestIssue6732_GeminiCleanersInlinePropertyReuse verifies that a Zod-style #/properties/...
// reference is expanded by the Gemini cleaners instead of becoming a description hint.
func TestIssue6732_GeminiCleanersInlinePropertyReuse(t *testing.T) {
input := `{"type":"object","properties":{"billing":{"type":"object","properties":{"zip":{"type":"string"}}},"shipping":{"$ref":"#/properties/billing"}}}`
for name, clean := range map[string]func(string) string{
"gemini": CleanJSONSchemaForGemini,
"geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
} {
got := clean(input)
parsed := gjson.Parse(got)
if gotType := parsed.Get("properties.shipping.properties.zip.type").String(); gotType != "string" {
t.Errorf("%s: properties.shipping.properties.zip.type = %q, want %q: %s", name, gotType, "string", got)
}
}
}
// TestIssue6732_GeminiCleanersReuseSameDefinitionTwice verifies that two properties referencing
// the same definition both keep the referenced fields; bounding expansion must not degrade
// ordinary reuse of a shared definition to a hint.
func TestIssue6732_GeminiCleanersReuseSameDefinitionTwice(t *testing.T) {
input := `{"type":"object","properties":{"billing":{"$ref":"#/$defs/Address"},"shipping":{"$ref":"#/$defs/Address"}},"$defs":{"Address":{"type":"object","properties":{"zip":{"type":"string"}}}}}`
for name, clean := range map[string]func(string) string{
"gemini": CleanJSONSchemaForGemini,
"geminiJSONSchema": CleanJSONSchemaForGeminiJSONSchema,
} {
got := clean(input)
parsed := gjson.Parse(got)
for _, field := range []string{"billing", "shipping"} {
if gotType := parsed.Get("properties." + field + ".properties.zip.type").String(); gotType != "string" {
t.Errorf("%s: properties.%s.properties.zip.type = %q, want %q: %s", name, field, gotType, "string", got)
}
}
}
}
// TestIssue6732_WrappedSchemaResolvesAgainstInnerRoot verifies that a schema wrapped in the
// single-key {"schema": ...} carrier used by the Antigravity executor resolves local refs
// against the inner schema rather than the carrier.
func TestIssue6732_WrappedSchemaResolvesAgainstInnerRoot(t *testing.T) {
inner := `{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"required":["person"],"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}`
wrapped := `{"schema":` + inner + `}`
got := CleanJSONSchemaForAntigravityTool(wrapped, false)
parsed := gjson.Parse(got)
if gotType := parsed.Get("schema.properties.person.properties.name.type").String(); gotType != "string" {
t.Errorf("wrapped schema.properties.person.properties.name.type = %q, want %q: %s", gotType, "string", got)
}
if parsed.Get("schema.$defs").Exists() {
t.Errorf("wrapped $defs should be removed after inlining: %s", got)
}
if strings.Contains(got, "See: Person") {
t.Errorf("wrapped resolvable local ref degraded to a hint: %s", got)
}
}
// TestIssue6732_DefaultLiteralPreservedOnJSONSchemaRoute verifies that the parametersJsonSchema
// route forwards default values verbatim even when they contain schema-looking keys.
func TestIssue6732_DefaultLiteralPreservedOnJSONSchemaRoute(t *testing.T) {
input := `{"type":"object","properties":{"report":{"type":"object","default":{"title":"Report","format":"pdf","id":7,"x-owner":"ops"}}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
def := gjson.Parse(got).Get("properties.report.default")
if !def.Exists() {
t.Fatalf("default literal missing: %s", got)
}
if def.Get("title").String() != "Report" || def.Get("format").String() != "pdf" || def.Get("id").Int() != 7 || def.Get("x-owner").String() != "ops" {
t.Errorf("default literal was rewritten, got %s: %s", def.Raw, got)
}
}
// TestIssue6732_ExampleLiteralsPreserved verifies that example/examples values keep
// schema-looking keys on the parametersJsonSchema route.
func TestIssue6732_ExampleLiteralsPreserved(t *testing.T) {
input := `{"type":"object","properties":{"a":{"type":"string","example":{"title":"Hi","x-owner":"ops","id":3},"examples":[{"title":"Hi2","x-owner":"ops","id":4}]}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
parsed := gjson.Parse(got)
example := parsed.Get("properties.a.example")
if example.Get("title").String() != "Hi" || example.Get("x-owner").String() != "ops" || example.Get("id").Int() != 3 {
t.Errorf("example literal was rewritten, got %s: %s", example.Raw, got)
}
exampleItem := parsed.Get("properties.a.examples.0")
if exampleItem.Get("title").String() != "Hi2" || exampleItem.Get("x-owner").String() != "ops" || exampleItem.Get("id").Int() != 4 {
t.Errorf("examples literal was rewritten, got %s: %s", exampleItem.Raw, got)
}
}
// TestIssue6732_InlineLocalRefsLeavesLiteralRefs verifies that the inliner does not resolve
// {"$ref": ...} objects that appear as client data inside a default value.
func TestIssue6732_InlineLocalRefsLeavesLiteralRefs(t *testing.T) {
input := `{"type":"object","properties":{"a":{"type":"object","default":{"$ref":"#/$defs/Person"}}},"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}}}}}`
got := InlineLocalRefs(input)
parsed := gjson.Parse(got)
if ref := parsed.Get("properties.a.default.$ref").String(); ref != "#/$defs/Person" {
t.Errorf("literal $ref inside default = %q, want it preserved verbatim: %s", ref, got)
}
if parsed.Get("properties.a.default.properties").Exists() {
t.Errorf("literal $ref inside default was expanded: %s", got)
}
if !parsed.Get("$defs.Person.properties.name").Exists() {
t.Errorf("InlineLocalRefs must not strip definitions: %s", got)
}
}
// TestIssue6732_PropertiesNamedDefaultAndTypeRemainSchemas verifies that properties literally
// named "default" and "type" keep their schema semantics: their own keywords are still cleaned
// and their own $ref is still inlined.
func TestIssue6732_PropertiesNamedDefaultAndTypeRemainSchemas(t *testing.T) {
input := `{"type":"object","properties":{"default":{"$ref":"#/$defs/Person"},"type":{"type":"string","title":"Kind"}},"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}}}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
parsed := gjson.Parse(got)
if gotType := parsed.Get("properties.default.properties.name.type").String(); gotType != "string" {
t.Errorf("property named \"default\" ref was not inlined, name type = %q: %s", gotType, got)
}
if parsed.Get("properties.type.title").Exists() {
t.Errorf("property named \"type\" schema title should still be cleaned: %s", got)
}
}
// TestIssue6732_ExpansionBudgetBoundsSharedDefs verifies that inlining a schema whose shared
// definitions fan out exponentially stays bounded instead of expanding without limit, while the
// affordable outer levels are still expanded.
func TestIssue6732_ExpansionBudgetBoundsSharedDefs(t *testing.T) {
input := geminiSchemaDiamondInput()
got := CleanJSONSchemaForGeminiJSONSchema(input)
if len(got) >= 512*1024 {
t.Errorf("diamond schema output = %d bytes, want < %d (input %d bytes)", len(got), 512*1024, len(input))
}
parsed := gjson.Parse(got)
if !parsed.Get("properties.leaf").Exists() {
t.Errorf("diamond schema lost the top-level property: %s", got)
}
if !parsed.Get("properties.leaf.properties.a.properties.a").Exists() {
t.Errorf("diamond schema outer levels should still be expanded: %s", got)
}
if parsed.Get("$defs").Exists() {
t.Errorf("diamond schema should not retain $defs: %s", got)
}
}
// TestIssue6732_UnusedDefinitionsDoNotConsumeExpansionBudget verifies that definitions the
// cleaners are about to delete anyway neither consume the expansion budget nor make the kept
// fields depend on map iteration order.
func TestIssue6732_UnusedDefinitionsDoNotConsumeExpansionBudget(t *testing.T) {
var sb strings.Builder
sb.WriteString(`{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"required":["person"],"$defs":{"leaf0":{"type":"string","description":"leaf leaf leaf leaf leaf leaf leaf leaf leaf leaf"},"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}`)
for i := range 13 {
target := "leaf0"
if i > 0 {
target = "D" + strconv.Itoa(i-1)
}
sb.WriteString(`,"D` + strconv.Itoa(i) + `":{"type":"object","properties":{"a":{"$ref":"#/$defs/` + target + `"},"b":{"$ref":"#/$defs/` + target + `"}}}`)
}
sb.WriteString(`}}`)
input := sb.String()
first := CleanJSONSchemaForGeminiJSONSchema(input)
for run := range 30 {
got := CleanJSONSchemaForGeminiJSONSchema(input)
if got != first {
t.Fatalf("run %d: output is not deterministic:\nfirst: %s\ngot: %s", run, first, got)
}
parsed := gjson.Parse(got)
if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" {
t.Fatalf("run %d: used definition was not inlined, name type = %q: %s", run, gotType, got)
}
if parsed.Get("$defs").Exists() {
t.Fatalf("run %d: $defs should be removed: %s", run, got)
}
}
}
// TestIssue6732_BudgetFallbackDoesNotCopyLargeDescriptions verifies that references degraded by
// the expansion budget do not each copy a large target description, which would defeat the bound.
func TestIssue6732_BudgetFallbackDoesNotCopyLargeDescriptions(t *testing.T) {
var sb strings.Builder
sb.WriteString(`{"type":"object","properties":{`)
for i := range 200 {
if i > 0 {
sb.WriteByte(',')
}
sb.WriteString(`"p` + strconv.Itoa(i) + `":{"$ref":"#/$defs/Big"}`)
}
sb.WriteString(`},"$defs":{"Big":{"type":"object","description":"` + strings.Repeat("x", 32*1024) + `","properties":{"name":{"type":"string"}}}}}`)
got := CleanJSONSchemaForGeminiJSONSchema(sb.String())
if len(got) >= 512*1024 {
t.Errorf("shared large-description output = %d bytes, want < %d", len(got), 512*1024)
}
parsed := gjson.Parse(got)
if !parsed.Get("properties.p0").Exists() || !parsed.Get("properties.p199").Exists() {
t.Errorf("shared large-description schema lost properties: %s", got)
}
if gotType := parsed.Get("properties.p0.properties.name.type").String(); gotType != "string" {
t.Errorf("first affordable expansion should keep its fields, name type = %q: %s", gotType, got)
}
if parsed.Get("$defs").Exists() {
t.Errorf("$defs should be removed: %s", got)
}
}
// TestIssue6732_NestedUnusedDefinitionsGraphDoesNotConsumeBudget verifies that a self-contained
// reference graph inside a nested definitions container (which cleaning deletes at every level)
// neither spends the expansion budget nor starves kept fields.
func TestIssue6732_NestedUnusedDefinitionsGraphDoesNotConsumeBudget(t *testing.T) {
var sb strings.Builder
sb.WriteString(`{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"},"a":{"type":"object","$defs":{"Leaf":{"type":"string","description":"` + strings.Repeat("x", 1024) + `"}`)
for i := range 256 {
sb.WriteString(`,"U` + strconv.Itoa(i) + `":{"$ref":"#/properties/a/$defs/Leaf"}`)
}
sb.WriteString(`}}},"required":["person"],"$defs":{"Person":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}}`)
got := CleanJSONSchemaForGeminiJSONSchema(sb.String())
parsed := gjson.Parse(got)
if gotType := parsed.Get("properties.person.properties.name.type").String(); gotType != "string" {
t.Errorf("nested unused definitions starved the kept field, name type = %q: %s", gotType, got)
}
if parsed.Get("properties.a.$defs").Exists() || parsed.Get("$defs").Exists() {
t.Errorf("nested and root $defs should be removed: %s", got)
}
}
// TestIssue6732_RefSiblingNameMapReferencesStillExpand verifies that references inside a $ref
// node's own name-map siblings expand when the reference target itself expands.
func TestIssue6732_RefSiblingNameMapReferencesStillExpand(t *testing.T) {
input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/Base","properties":{"x":{"$ref":"#/$defs/S"}}}},"$defs":{"Base":{"type":"object"},"S":{"type":"string"}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
parsed := gjson.Parse(got)
if gotType := parsed.Get("properties.a.properties.x.type").String(); gotType != "string" {
t.Errorf("ref sibling name-map reference lost its type, got %q: %s", gotType, got)
}
}
// TestIssue6732_CleanInliningStripsDefinitionsFromIntermediate verifies that the cleaning
// inliner's intermediate output does not serialize definition containers (root or carried inside
// expanded targets), so repeated references to a target holding a huge unused definitions block
// cannot multiply that block in the intermediate JSON.
func TestIssue6732_CleanInliningStripsDefinitionsFromIntermediate(t *testing.T) {
person := `{"type":"object","properties":{"name":{"type":"string"}},"$defs":{"Unused":{"type":"string","description":"` + strings.Repeat("x", 280*1024) + `"}}}`
input := `{"type":"object","properties":{"p0":{"$ref":"#/$defs/Person"},"p1":{"$ref":"#/$defs/Person"}},"$defs":{"Person":` + person + `}}`
got := inlineLocalRefsForClean(input)
if len(got) >= 64*1024 {
t.Errorf("clean inlining intermediate = %d bytes, want < %d", len(got), 64*1024)
}
parsed := gjson.Parse(got)
if parsed.Get("$defs").Exists() {
t.Errorf("intermediate should not carry root $defs: %s", got)
}
if parsed.Get("properties.p0.$defs").Exists() {
t.Errorf("intermediate should not carry target-nested $defs: %s", got)
}
if gotType := parsed.Get("properties.p1.properties.name.type").String(); gotType != "string" {
t.Errorf("second expansion lost its fields, name type = %q: %s", gotType, got)
}
}
// TestIssue6732_ArrayRefWithExplicitTypeKeepsTargetItems verifies that the placeholder items
// Phase 0 adds to a typed but unresolved $ref node does not clobber the items of the referenced
// definition during inlining.
func TestIssue6732_ArrayRefWithExplicitTypeKeepsTargetItems(t *testing.T) {
input := `{"type":"object","properties":{"rows":{"type":"array","$ref":"#/$defs/Rows"}},"$defs":{"Rows":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"}}}}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
parsed := gjson.Parse(got)
if gotType := parsed.Get("properties.rows.items.properties.name.type").String(); gotType != "string" {
t.Errorf("array ref target items were clobbered, name type = %q: %s", gotType, got)
}
}
// TestIssue6732_BudgetFallbackStillResolvesSiblings verifies that when a reference target is too
// large to expand, sibling subschemas on the same node are still resolved instead of being copied
// verbatim with their own references left dangling.
func TestIssue6732_BudgetFallbackStillResolvesSiblings(t *testing.T) {
input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/Big","properties":{"x":{"$ref":"#/$defs/S"}}}},"$defs":{"Big":{"type":"object","description":"` + strings.Repeat("x", 280*1024) + `"},"S":{"type":"string"}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
parsed := gjson.Parse(got)
if gotType := parsed.Get("properties.a.properties.x.type").String(); gotType != "string" {
t.Errorf("sibling reference after budget fallback lost its type, got %q: %s", gotType, got)
}
if strings.Contains(got, `"$ref"`) {
t.Errorf("cleaned schema left a dangling reference: %s", got)
}
}
// TestIssue6732_InlineLocalRefsDefinitionNamesKeepSchemaSemantics verifies that definitions whose
// names look like schema keywords are treated as schemas by the public inliner: a definition named
// "properties" keeps its literal default intact, and a definition named "default" still expands.
func TestIssue6732_InlineLocalRefsDefinitionNamesKeepSchemaSemantics(t *testing.T) {
input := `{"$defs":{"S":{"type":"string"},"properties":{"type":"object","default":{"$ref":"#/$defs/S"}},"default":{"type":"object","properties":{"x":{"$ref":"#/$defs/S"}}}}}`
got := InlineLocalRefs(input)
parsed := gjson.Parse(got)
if ref := parsed.Get("$defs.properties.default.$ref").String(); ref != "#/$defs/S" {
t.Errorf("literal default inside definition named \"properties\" was expanded, $ref = %q: %s", ref, got)
}
if gotType := parsed.Get("$defs.default.properties.x.type").String(); gotType != "string" {
t.Errorf("definition named \"default\" was not resolved as a schema, x type = %q: %s", gotType, got)
}
}
// TestIssue6732_TargetNestedDefinitionsExemptFromBudget verifies that unused definitions nested
// inside a referenced target (which cleaning deletes) do not inflate the target's budget charge.
func TestIssue6732_TargetNestedDefinitionsExemptFromBudget(t *testing.T) {
person := `{"type":"object","properties":{"name":{"type":"string"}},"$defs":{"Unused":{"type":"string","description":"` + strings.Repeat("x", 280*1024) + `"}}}`
cases := map[string]string{
"propertyRef": `{"type":"object","properties":{"person":{"$ref":"#/$defs/Person"}},"$defs":{"Person":` + person + `}}`,
"rootRef": `{"$ref":"#/$defs/Person","$defs":{"Person":` + person + `}}`,
}
for caseName, input := range cases {
got := CleanJSONSchemaForGeminiJSONSchema(input)
parsed := gjson.Parse(got)
namePath := "properties.person.properties.name.type"
if caseName == "rootRef" {
namePath = "properties.name.type"
}
if gotType := parsed.Get(namePath).String(); gotType != "string" {
t.Errorf("%s: %s = %q, want %q: %s", caseName, namePath, gotType, "string", got)
}
if parsed.Get("$defs").Exists() {
t.Errorf("%s: $defs should be removed: %s", caseName, got)
}
}
}
// TestIssue6732_ArrayRefWithoutTargetItemsGetsPlaceholder verifies that a typed array whose
// referenced definition carries no items still ends up with the placeholder items Phase 0 would
// have added, and that cleaning twice is stable.
func TestIssue6732_ArrayRefWithoutTargetItemsGetsPlaceholder(t *testing.T) {
input := `{"type":"object","properties":{"rows":{"type":"array","$ref":"#/$defs/Common"}},"$defs":{"Common":{"description":"Rows"}}}`
first := CleanJSONSchemaForAntigravityTool(input, false)
parsed := gjson.Parse(first)
if gotType := parsed.Get("properties.rows.items.type").String(); gotType != "string" {
t.Errorf("array ref without target items missing placeholder items, got %q: %s", gotType, first)
}
if second := CleanJSONSchemaForAntigravityTool(first, false); second != first {
t.Errorf("cleaning is not idempotent for array refs:\nfirst: %s\nsecond: %s", first, second)
}
}
// TestIssue6732_SmallTargetFanOutStaysBounded verifies that thousands of references to a small
// target with a near-limit description cannot multiply the description without bound through a
// mix of expansions and fallback copies.
func TestIssue6732_SmallTargetFanOutStaysBounded(t *testing.T) {
var sb strings.Builder
sb.WriteString(`{"type":"object","properties":{`)
for i := range 2000 {
if i > 0 {
sb.WriteByte(',')
}
sb.WriteString(`"p` + strconv.Itoa(i) + `":{"$ref":"#/$defs/S"}`)
}
sb.WriteString(`},"$defs":{"S":{"type":"string","description":"` + strings.Repeat("x", 512) + `"}}}`)
got := CleanJSONSchemaForGeminiJSONSchema(sb.String())
if len(got) >= 512*1024 {
t.Errorf("small-target fan-out output = %d bytes, want < %d", len(got), 512*1024)
}
}
// TestIssue6732_EscapedDescriptionFanOutStaysBounded verifies that budget accounting covers
// HTML-escaped string growth during JSON encoding.
func TestIssue6732_EscapedDescriptionFanOutStaysBounded(t *testing.T) {
input := `{"type":"object","properties":{"p0":{"$ref":"#/$defs/Big"},"p1":{"$ref":"#/$defs/Big"},"p2":{"$ref":"#/$defs/Big"},"p3":{"$ref":"#/$defs/Big"},"p4":{"$ref":"#/$defs/Big"},"p5":{"$ref":"#/$defs/Big"},"p6":{"$ref":"#/$defs/Big"},"p7":{"$ref":"#/$defs/Big"}},"$defs":{"Big":{"type":"object","description":"` + strings.Repeat("<", 32*1024) + `","properties":{"name":{"type":"string"}}}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
if len(got) >= 512*1024 {
t.Errorf("escaped-description fan-out output = %d bytes, want < %d", len(got), 512*1024)
}
}
// TestIssue6732_DependenciesNameMapKeepsSchemaSemantics verifies that the Draft-07 dependencies
// container behaves as a name map: a dependency keyed "default" holds a schema whose references
// still expand in the public inliner.
func TestIssue6732_DependenciesNameMapKeepsSchemaSemantics(t *testing.T) {
input := `{"type":"object","dependencies":{"default":{"properties":{"x":{"$ref":"#/definitions/S"}}}},"definitions":{"S":{"type":"string"}}}`
got := InlineLocalRefs(input)
parsed := gjson.Parse(got)
if gotType := parsed.Get("dependencies.default.properties.x.type").String(); gotType != "string" {
t.Errorf("dependency schema named \"default\" was not resolved, x type = %q: %s", gotType, got)
}
if parsed.Get("dependencies.default.properties.x.$ref").Exists() {
t.Errorf("dependency schema reference was left dangling: %s", got)
}
}
// TestIssue6732_OmittedFallbackDescriptionNotCharged verifies that a target description the
// fallback omits (because it exceeds the copy limit) is not charged to the expansion budget, so
// a following small reference still expands.
func TestIssue6732_OmittedFallbackDescriptionNotCharged(t *testing.T) {
input := `{"type":"object","properties":{"a":{"$ref":"#/$defs/A"},"b":{"$ref":"#/$defs/B"}},"$defs":{"A":{"type":"string","description":"` + strings.Repeat("x", 262142) + `"},"B":{"type":"object","properties":{"name":{"type":"string"}}}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
parsed := gjson.Parse(got)
if gotType := parsed.Get("properties.b.properties.name.type").String(); gotType != "string" {
t.Errorf("small reference after omitted fallback description lost its fields, name type = %q: %s", gotType, got)
}
}
// TestIssue6732_SynthesizedTypeDoesNotOverrideRefTarget verifies that the type Phase 0 would
// infer from a ref node's sibling items does not clobber the referenced definition's object
// type, and that cleaning the result again is stable.
func TestIssue6732_SynthesizedTypeDoesNotOverrideRefTarget(t *testing.T) {
input := `{"$ref":"#/$defs/O","items":{"type":"integer"},"$defs":{"O":{"type":"object","properties":{"x":{"type":"string"}}}}}`
first := CleanJSONSchemaForGeminiJSONSchema(input)
parsed := gjson.Parse(first)
if gotType := parsed.Get("type").String(); gotType != "object" {
t.Errorf("root ref target type = %q, want %q: %s", gotType, "object", first)
}
if parsed.Get("items").Exists() {
t.Errorf("object schema should not carry items: %s", first)
}
if gotType := parsed.Get("properties.x.type").String(); gotType != "string" {
t.Errorf("target property lost its type, got %q: %s", gotType, first)
}
if second := CleanJSONSchemaForGeminiJSONSchema(first); second != first {
t.Errorf("cleaning is not idempotent:\nfirst: %s\nsecond: %s", first, second)
}
}
// TestIssue6732_PropertyNamedRefRemainsProperty verifies that a property literally named "$ref"
// is treated as a property, not as a reference keyword, by the cleaner.
func TestIssue6732_PropertyNamedRefRemainsProperty(t *testing.T) {
input := `{"type":"object","properties":{"$ref":{"type":"string"}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
parsed := gjson.Parse(got)
if gotType := parsed.Get(`properties.\$ref.type`).String(); gotType != "string" {
t.Errorf("property named $ref lost its schema, type = %q: %s", gotType, got)
}
if strings.Contains(got, "See:") {
t.Errorf("property named $ref was converted into a hint: %s", got)
}
}
// TestIssue6732_PropertyNamedConstRemainsProperty verifies that a property literally named
// "const" holding a schema object is not converted into an enum constraint on its parent.
func TestIssue6732_PropertyNamedConstRemainsProperty(t *testing.T) {
input := `{"type":"object","properties":{"const":{"type":"string"}}}`
got := CleanJSONSchemaForGeminiJSONSchema(input)
parsed := gjson.Parse(got)
if gotType := parsed.Get(`properties.const.type`).String(); gotType != "string" {
t.Errorf("property named const lost its schema, type = %q: %s", gotType, got)
}
if parsed.Get("enum").Exists() {
t.Errorf("property named const fabricates a root enum: %s", got)
}
}
// geminiSchemaDiamondInput builds a schema referencing D12, where each Di is an object with two
// properties referencing Di-1 and D0 references a leaf definition twice. Fully expanding it
// produces 2^13 leaf copies.
func geminiSchemaDiamondInput() string {
var sb strings.Builder
sb.WriteString(`{"type":"object","properties":{"leaf":{"$ref":"#/$defs/D12"}},"$defs":{"leaf0":{"type":"string","description":"leaf leaf leaf leaf leaf leaf leaf leaf leaf leaf"}`)
for i := range 13 {
target := "leaf0"
if i > 0 {
target = "D" + strconv.Itoa(i-1)
}
sb.WriteString(`,"D` + strconv.Itoa(i) + `":{"type":"object","properties":{"a":{"$ref":"#/$defs/` + target + `"},"b":{"$ref":"#/$defs/` + target + `"}}}`)
}
sb.WriteString(`}}`)
return sb.String()
}

View File

@@ -29,6 +29,19 @@ import (
// The function works recursively, building dot-notation paths to each occurrence
// of the specified field throughout the JSON structure.
func Walk(value gjson.Result, path, field string, paths *[]string) {
walk(value, path, field, paths, false)
}
// WalkSchema is Walk for JSON Schema documents: it records the field wherever a schema keyword
// may appear, but does not descend into literal values (default, example, examples, const, enum),
// whose contents are client data rather than subschemas. The literal keyword itself is still
// recorded when it is the searched field, and under a property-name map a key spelled like one is
// a property name whose subschema is still walked.
func WalkSchema(value gjson.Result, path, field string, paths *[]string) {
walk(value, path, field, paths, true)
}
func walk(value gjson.Result, path, field string, paths *[]string, schemaAware bool) {
switch value.Type {
case gjson.JSON:
// For JSON objects and arrays, iterate through each child
@@ -49,7 +62,10 @@ func Walk(value gjson.Result, path, field string, paths *[]string) {
if keyStr == field {
*paths = append(*paths, childPath)
}
Walk(val, childPath, field, paths)
if schemaAware && isLiteralValueKeyword(keyStr) && !isPropertyDefinition(path) {
return true
}
walk(val, childPath, field, paths, schemaAware)
return true
})
case gjson.String, gjson.Number, gjson.True, gjson.False, gjson.Null: