From c558d3203e897d6d62db7bb6d3735fbffb34233d Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 18 Oct 2026 22:37:34 +0800 Subject: [PATCH] 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 --- internal/util/gemini_schema.go | 496 ++++++++++++++++++++++++--- internal/util/gemini_schema_test.go | 500 ++++++++++++++++++++++++++++ internal/util/translator.go | 18 +- 3 files changed, 974 insertions(+), 40 deletions(-) diff --git a/internal/util/gemini_schema.go b/internal/util/gemini_schema.go index acfe332c92..c550eedcd0 100644 --- a/internal/util/gemini_schema.go +++ b/internal/util/gemini_schema.go @@ -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": {}, } diff --git a/internal/util/gemini_schema_test.go b/internal/util/gemini_schema_test.go index 2dd8721c50..4da2c6dd39 100644 --- a/internal/util/gemini_schema_test.go +++ b/internal/util/gemini_schema_test.go @@ -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() +} diff --git a/internal/util/translator.go b/internal/util/translator.go index ce4d350595..945c986756 100644 --- a/internal/util/translator.go +++ b/internal/util/translator.go @@ -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: