| @@ -2,6 +2,7 @@ · 1 unmodified line |
| 2 | package util | 2 | package util |
| 3 | | 3 | |
| 4 | import ( | 4 | import ( |
| | 5 | "encoding/json" |
| 5 | "fmt" | 6 | "fmt" |
| 6 | "sort" | 7 | "sort" |
| 7 | "strconv" | 8 | "strconv" |
| @@ -26,9 +27,13 @@ const placeholderReasonDescription = "Brief explanation of why you are calling t · 18 unmodified lines |
| 26 | | 27 | |
| 27 | type jsonSchemaCleanOptions struct { | 28 | type jsonSchemaCleanOptions struct { |
| 28 | addPlaceholder bool | 29 | addPlaceholder bool |
| | 30 | antigravitySemantics bool |
| | 31 | removeToolTitle bool |
| 29 | removeGeminiMetadata bool | 32 | removeGeminiMetadata bool |
| 30 | flattenUnions bool | 33 | flattenUnions bool |
| 31 | forceEnumStringType bool | 34 | forceEnumStringType bool |
| | 35 | dropAllEnums bool |
| | 36 | dropBooleanEnums bool |
| 32 | preserveAdditionalPropertiesFalse bool | 37 | preserveAdditionalPropertiesFalse bool |
| 33 | } | 38 | } |
| 34 | | 39 | |
| @@ -36,10 +41,20 @@ type jsonSchemaCleanOptions struct { · 1 unmodified line |
| 36 | // It handles unsupported keywords, type flattening, and schema simplification while preserving | 41 | // It handles unsupported keywords, type flattening, and schema simplification while preserving |
| 37 | // semantic information as description hints and adding placeholders required by VALIDATED mode. | 42 | // semantic information as description hints and adding placeholders required by VALIDATED mode. |
| 38 | func CleanJSONSchemaForAntigravity(jsonStr string) string { | 43 | func CleanJSONSchemaForAntigravity(jsonStr string) string { |
| | 44 | return CleanJSONSchemaForAntigravityTool(jsonStr, true) |
| | 45 | } |
| | 46 | |
| | 47 | // CleanJSONSchemaForAntigravityTool transforms an Antigravity function schema. The private |
| | 48 | // backend accepts enum members only as strings, but the declared type still controls the JSON |
| | 49 | // type of generated function arguments, so numeric and boolean types must not be rewritten. |
| | 50 | // requirePlaceholder is used only for Claude VALIDATED mode. |
| | 51 | func CleanJSONSchemaForAntigravityTool(jsonStr string, requirePlaceholder bool) string { |
| 39 | return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ | 52 | return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ |
| 40 | addPlaceholder: true, | 53 | addPlaceholder: requirePlaceholder, |
| 41 | flattenUnions: true, | 54 | antigravitySemantics: true, |
| 42 | forceEnumStringType: true, | 55 | removeToolTitle: !requirePlaceholder, |
| | 56 | flattenUnions: true, |
| | 57 | dropAllEnums: true, |
| 43 | }) | 58 | }) |
| 44 | } | 59 | } |
| 45 | | 60 | |
| @@ -47,16 +62,19 @@ func CleanJSONSchemaForAntigravity(jsonStr string) string { · 1 unmodified line |
| 47 | // compatibility rewrites that would alter the client's structured output contract. | 62 | // compatibility rewrites that would alter the client's structured output contract. |
| 48 | // | 63 | // |
| 49 | // Sanitization policy: | 64 | // Sanitization policy: |
| 50 | // - Passthrough: type, properties, items, required, description, enum, union structures (anyOf/oneOf), | 65 | // - Passthrough: type, properties, items, required, description, enum, nullable, and |
| 51 | // and additionalProperties: false (which Antigravity natively enforces as a closed-object constraint). | 66 | // additionalProperties: false (which Antigravity natively enforces for response schemas). |
| 52 | // - Description hints + deletion: unsupported constraints (minLength, maxLength, pattern, minItems, | 67 | // - Description hints + deletion: unsupported or accepted-but-ignored constraints. |
| 53 | // maxItems, uniqueItems, format, default, examples). | | |
| 54 | // - Flattened: allOf merged into properties/required. | 68 | // - Flattened: allOf merged into properties/required. |
| 55 | // - Dropped: $schema, $defs, definitions, const (converted to enum first), $ref (converted to hint), | 69 | // - Projected: anyOf/oneOf select the strongest branch; null branches become nullable:true. |
| 56 | // $id, propertyNames, patternProperties, conditional schemas (if/then/else), non-false additionalProperties, | 70 | // - Resolved: local $ref targets are inlined before $defs/definitions are removed. |
| 57 | // and x-* extensions. | 71 | // - Dropped: unresolved $ref (after a hint), metadata, unsupported object-key constraints, |
| | 72 | // conditional keywords (after non-conflicting properties are retained), and x-* extensions. |
| 58 | func CleanJSONSchemaForAntigravityResponse(jsonStr string) string { | 73 | func CleanJSONSchemaForAntigravityResponse(jsonStr string) string { |
| 59 | return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ | 74 | return cleanJSONSchema(jsonStr, jsonSchemaCleanOptions{ |
| | 75 | antigravitySemantics: true, |
| | 76 | flattenUnions: true, |
| | 77 | dropBooleanEnums: true, |
| 60 | preserveAdditionalPropertiesFalse: true, | 78 | preserveAdditionalPropertiesFalse: true, |
| 61 | }) | 79 | }) |
| 62 | } | 80 | } |
| @@ -74,14 +92,21 @@ func CleanJSONSchemaForGemini(jsonStr string) string { · 11 unmodified lines |
| 74 | // cleanJSONSchema performs the core cleaning operations on the JSON schema. | 92 | // cleanJSONSchema performs the core cleaning operations on the JSON schema. |
| 75 | func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { | 93 | func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { |
| 76 | // Phase 1: Convert and add hints | 94 | // Phase 1: Convert and add hints |
| 77 | jsonStr = convertRefsToHints(jsonStr) | 95 | if options.antigravitySemantics { |
| | 96 | jsonStr = inlineLocalRefs(jsonStr) |
| | 97 | } |
| | 98 | jsonStr = convertRefsToHints(jsonStr, options.antigravitySemantics) |
| 78 | jsonStr = convertConstToEnum(jsonStr) | 99 | jsonStr = convertConstToEnum(jsonStr) |
| 79 | jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType) | 100 | jsonStr = convertEnumValuesToStrings(jsonStr, options.forceEnumStringType) |
| 80 | jsonStr = addEnumHints(jsonStr) | 101 | jsonStr = addEnumHints(jsonStr) |
| | 102 | jsonStr = dropIgnoredEnumsToHints(jsonStr, options) |
| 81 | if !options.preserveAdditionalPropertiesFalse { | 103 | if !options.preserveAdditionalPropertiesFalse { |
| 82 | jsonStr = addAdditionalPropertiesHints(jsonStr) | 104 | jsonStr = addAdditionalPropertiesHints(jsonStr) |
| 83 | } | 105 | } |
| 84 | jsonStr = moveConstraintsToDescription(jsonStr) | 106 | jsonStr = moveConstraintsToDescription(jsonStr, options) |
| | 107 | if options.antigravitySemantics { |
| | 108 | jsonStr = moveNotToDescription(jsonStr) |
| | 109 | } |
| 85 | | 110 | |
| 86 | // Phase 2: Flatten complex structures | 111 | // Phase 2: Flatten complex structures |
| 87 | jsonStr = mergeConditionals(jsonStr) | 112 | jsonStr = mergeConditionals(jsonStr) |
| @@ -89,7 +114,7 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { · 1 unmodified line |
| 89 | if options.flattenUnions { | 114 | if options.flattenUnions { |
| 90 | jsonStr = flattenAnyOfOneOf(jsonStr) | 115 | jsonStr = flattenAnyOfOneOf(jsonStr) |
| 91 | } | 116 | } |
| 92 | jsonStr = flattenTypeArrays(jsonStr) | 117 | jsonStr = flattenTypeArrays(jsonStr, options.antigravitySemantics) |
| 93 | | 118 | |
| 94 | // Phase 3: Cleanup | 119 | // Phase 3: Cleanup |
| 95 | jsonStr = removeUnsupportedKeywords(jsonStr, options) | 120 | jsonStr = removeUnsupportedKeywords(jsonStr, options) |
| @@ -97,6 +122,10 @@ func cleanJSONSchema(jsonStr string, options jsonSchemaCleanOptions) string { · 1 unmodified line |
| 97 | // Gemini schema cleanup: remove nullable/title and placeholder-only fields. | 122 | // Gemini schema cleanup: remove nullable/title and placeholder-only fields. |
| 98 | jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"}) | 123 | jsonStr = removeKeywords(jsonStr, []string{"nullable", "title"}) |
| 99 | jsonStr = removePlaceholderFields(jsonStr) | 124 | jsonStr = removePlaceholderFields(jsonStr) |
| | 125 | } else if options.removeToolTitle { |
| | 126 | // Legacy non-VALIDATED Antigravity requests used the Gemini cleaner, which drops title. |
| | 127 | // Keep that harmless metadata policy without losing Antigravity's native nullable support. |
| | 128 | jsonStr = removeKeywords(jsonStr, []string{"title"}) |
| 100 | } | 129 | } |
| 101 | jsonStr = cleanupRequiredFields(jsonStr) | 130 | jsonStr = cleanupRequiredFields(jsonStr) |
| 102 | // Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement) | 131 | // Phase 4: Add placeholder for empty object schemas (Claude VALIDATED mode requirement) |
| @@ -193,28 +222,151 @@ func removePlaceholderFields(jsonStr string) string { · 90 unmodified lines |
| 193 | return jsonStr | 222 | return jsonStr |
| 194 | } | 223 | } |
| 195 | | 224 | |
| 196 | // convertRefsToHints converts $ref to description hints (Lazy Hint strategy). | 225 | // inlineLocalRefs resolves JSON Pointer references against the original schema before definition |
| 197 | func convertRefsToHints(jsonStr string) string { | 226 | // containers are stripped. Each expansion receives its own copy, sibling keywords override the |
| | 227 | // referenced definition, and cycles terminate as a typed hint instead of recursing forever. |
| | 228 | func inlineLocalRefs(jsonStr string) string { |
| | 229 | if !strings.Contains(jsonStr, `"$ref"`) { |
| | 230 | return jsonStr |
| | 231 | } |
| | 232 | |
| | 233 | decoder := json.NewDecoder(strings.NewReader(jsonStr)) |
| | 234 | decoder.UseNumber() |
| | 235 | var root any |
| | 236 | if err := decoder.Decode(&root); err != nil { |
| | 237 | return jsonStr |
| | 238 | } |
| | 239 | |
| | 240 | resolved := resolveLocalRefs(root, root, make(map[string]bool)) |
| | 241 | out, err := json.Marshal(resolved) |
| | 242 | if err != nil { |
| | 243 | return jsonStr |
| | 244 | } |
| | 245 | return string(out) |
| | 246 | } |
| | 247 | |
| | 248 | func resolveLocalRefs(root, value any, active map[string]bool) any { |
| | 249 | switch node := value.(type) { |
| | 250 | case []any: |
| | 251 | out := make([]any, len(node)) |
| | 252 | for i, item := range node { |
| | 253 | out[i] = resolveLocalRefs(root, item, active) |
| | 254 | } |
| | 255 | return out |
| | 256 | case map[string]any: |
| | 257 | ref, hasRef := node["$ref"].(string) |
| | 258 | if hasRef && strings.HasPrefix(ref, "#/") { |
| | 259 | if target, ok := resolveJSONPointer(root, ref); ok { |
| | 260 | if active[ref] { |
| | 261 | return cyclicRefFallback(node, target, ref) |
| | 262 | } |
| | 263 | active[ref] = true |
| | 264 | resolvedTarget := resolveLocalRefs(root, target, active) |
| | 265 | delete(active, ref) |
| | 266 | if targetMap, okTarget := resolvedTarget.(map[string]any); okTarget { |
| | 267 | out := make(map[string]any, len(targetMap)+len(node)) |
| | 268 | for key, item := range targetMap { |
| | 269 | out[key] = item |
| | 270 | } |
| | 271 | for key, item := range node { |
| | 272 | if key == "$ref" { |
| | 273 | continue |
| | 274 | } |
| | 275 | out[key] = resolveLocalRefs(root, item, active) |
| | 276 | } |
| | 277 | return out |
| | 278 | } |
| | 279 | } |
| | 280 | } |
| | 281 | |
| | 282 | out := make(map[string]any, len(node)) |
| | 283 | for key, item := range node { |
| | 284 | out[key] = resolveLocalRefs(root, item, active) |
| | 285 | } |
| | 286 | return out |
| | 287 | default: |
| | 288 | return value |
| | 289 | } |
| | 290 | } |
| | 291 | |
| | 292 | func resolveJSONPointer(root any, ref string) (any, bool) { |
| | 293 | current := root |
| | 294 | for _, rawPart := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") { |
| | 295 | part := strings.ReplaceAll(strings.ReplaceAll(rawPart, "~1", "/"), "~0", "~") |
| | 296 | switch node := current.(type) { |
| | 297 | case map[string]any: |
| | 298 | var ok bool |
| | 299 | current, ok = node[part] |
| | 300 | if !ok { |
| | 301 | return nil, false |
| | 302 | } |
| | 303 | case []any: |
| | 304 | index, err := strconv.Atoi(part) |
| | 305 | if err != nil || index < 0 || index >= len(node) { |
| | 306 | return nil, false |
| | 307 | } |
| | 308 | current = node[index] |
| | 309 | default: |
| | 310 | return nil, false |
| | 311 | } |
| | 312 | } |
| | 313 | return current, true |
| | 314 | } |
| | 315 | |
| | 316 | func cyclicRefFallback(node map[string]any, target any, ref string) map[string]any { |
| | 317 | out := make(map[string]any, len(node)+2) |
| | 318 | if targetMap, ok := target.(map[string]any); ok { |
| | 319 | for _, key := range []string{"type", "nullable", "description"} { |
| | 320 | if value, exists := targetMap[key]; exists { |
| | 321 | out[key] = value |
| | 322 | } |
| | 323 | } |
| | 324 | } |
| | 325 | for key, value := range node { |
| | 326 | if key != "$ref" { |
| | 327 | out[key] = value |
| | 328 | } |
| | 329 | } |
| | 330 | name := refName(ref) |
| | 331 | hint := "See: " + name |
| | 332 | if description, _ := out["description"].(string); description != "" { |
| | 333 | out["description"] = mergeHint(description, hint) |
| | 334 | } else { |
| | 335 | out["description"] = hint |
| | 336 | } |
| | 337 | return out |
| | 338 | } |
| | 339 | |
| | 340 | func refName(ref string) string { |
| | 341 | if index := strings.LastIndex(ref, "/"); index >= 0 && index+1 < len(ref) { |
| | 342 | return strings.ReplaceAll(strings.ReplaceAll(ref[index+1:], "~1", "/"), "~0", "~") |
| | 343 | } |
| | 344 | return ref |
| | 345 | } |
| | 346 | |
| | 347 | // convertRefsToHints retains sibling keywords and converts only unresolved or external references |
| | 348 | // to descriptions. Local references have already been expanded by inlineLocalRefs. |
| | 349 | func convertRefsToHints(jsonStr string, preserveSiblings bool) string { |
| 198 | paths := findPaths(jsonStr, "$ref") | 350 | paths := findPaths(jsonStr, "$ref") |
| 199 | sortByDepth(paths) | 351 | sortByDepth(paths) |
| 200 | | 352 | |
| 201 | for _, p := range paths { | 353 | for _, p := range paths { |
| 202 | refVal := gjson.Get(jsonStr, p).String() | 354 | refVal := gjson.Get(jsonStr, p).String() |
| 203 | defName := refVal | 355 | defName := refName(refVal) |
| 204 | if idx := strings.LastIndex(refVal, "/"); idx >= 0 { | | |
| 205 | defName = refVal[idx+1:] | | |
| 206 | } | | |
| 207 | | 356 | |
| 208 | parentPath := trimSuffix(p, ".$ref") | 357 | parentPath := trimSuffix(p, ".$ref") |
| 209 | hint := fmt.Sprintf("See: %s", defName) | 358 | hint := fmt.Sprintf("See: %s", defName) |
| 210 | if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" { | 359 | if !preserveSiblings { |
| 211 | hint = fmt.Sprintf("%s (%s)", existing, hint) | 360 | if existing := gjson.Get(jsonStr, descriptionPath(parentPath)).String(); existing != "" { |
| | 361 | hint = fmt.Sprintf("%s (%s)", existing, hint) |
| | 362 | } |
| | 363 | replacement := `{"type":"object","description":""}` |
| | 364 | replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint) |
| | 365 | jsonStr = setRawAt(jsonStr, parentPath, string(replacementBytes)) |
| | 366 | continue |
| 212 | } | 367 | } |
| 213 | | 368 | jsonStr, _ = sjson.Delete(jsonStr, p) |
| 214 | replacement := `{"type":"object","description":""}` | 369 | jsonStr = appendHint(jsonStr, parentPath, hint) |
| 215 | replacementBytes, _ := sjson.SetBytes([]byte(replacement), "description", hint) | | |
| 216 | replacement = string(replacementBytes) | | |
| 217 | jsonStr = setRawAt(jsonStr, parentPath, replacement) | | |
| 218 | } | 370 | } |
| 219 | return jsonStr | 371 | return jsonStr |
| 220 | } | 372 | } |
| @@ -235,8 +387,8 @@ func convertConstToEnum(jsonStr string) string { · 14 unmodified lines |
| 235 | } | 387 | } |
| 236 | | 388 | |
| 237 | // convertEnumValuesToStrings ensures all enum values use the string representation required by | 389 | // convertEnumValuesToStrings ensures all enum values use the string representation required by |
| 238 | // Gemini's proto schema. Tool schemas also require a string type, while response schemas preserve | 390 | // Gemini's proto schema. The declared type remains independent: Antigravity uses it to choose the |
| 239 | // their declared type because the upstream decoder uses it to select the emitted JSON value type. | 391 | // emitted JSON type on both response and function-argument paths. |
| 240 | func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string { | 392 | func convertEnumValuesToStrings(jsonStr string, forceStringType bool) string { |
| 241 | for _, p := range findPaths(jsonStr, "enum") { | 393 | for _, p := range findPaths(jsonStr, "enum") { |
| 242 | arr := gjson.Get(jsonStr, p) | 394 | arr := gjson.Get(jsonStr, p) |
| @@ -280,6 +432,25 @@ func addEnumHints(jsonStr string) string { · 37 unmodified lines |
| 280 | return jsonStr | 432 | return jsonStr |
| 281 | } | 433 | } |
| 282 | | 434 | |
| | 435 | // Antigravity does not enforce enum on function arguments and ignores boolean response enums. |
| | 436 | // Preserve the advisory values in description, but do not leave an unenforced constraint in the |
| | 437 | // schema contract. Response enums for string, number, and integer remain native constraints. |
| | 438 | func dropIgnoredEnumsToHints(jsonStr string, options jsonSchemaCleanOptions) string { |
| | 439 | for _, path := range findPaths(jsonStr, "enum") { |
| | 440 | parentPath := trimSuffix(path, ".enum") |
| | 441 | shouldDrop := options.dropAllEnums || (options.dropBooleanEnums && gjson.Get(jsonStr, joinPath(parentPath, "type")).String() == "boolean") |
| | 442 | if !shouldDrop { |
| | 443 | continue |
| | 444 | } |
| | 445 | enum := gjson.Get(jsonStr, path) |
| | 446 | if enum.IsArray() && len(enum.Array()) == 1 { |
| | 447 | jsonStr = appendHint(jsonStr, parentPath, "Allowed: "+enum.Array()[0].String()) |
| | 448 | } |
| | 449 | jsonStr, _ = sjson.Delete(jsonStr, path) |
| | 450 | } |
| | 451 | return jsonStr |
| | 452 | } |
| | 453 | |
| 283 | func addAdditionalPropertiesHints(jsonStr string) string { | 454 | func addAdditionalPropertiesHints(jsonStr string) string { |
| 284 | for _, p := range findPaths(jsonStr, "additionalProperties") { | 455 | for _, p := range findPaths(jsonStr, "additionalProperties") { |
| 285 | if gjson.Get(jsonStr, p).Type == gjson.False { | 456 | if gjson.Get(jsonStr, p).Type == gjson.False { |
| @@ -295,9 +466,18 @@ var unsupportedConstraints = []string{ · 9 unmodified lines |
| 295 | "default", "examples", // Claude rejects these in VALIDATED mode | 466 | "default", "examples", // Claude rejects these in VALIDATED mode |
| 296 | } | 467 | } |
| 297 | | 468 | |
| 298 | func moveConstraintsToDescription(jsonStr string) string { | 469 | func constraintKeywords(options jsonSchemaCleanOptions) []string { |
| 299 | pathsByField := findPathsByFields(jsonStr, unsupportedConstraints) | 470 | keywords := append([]string(nil), unsupportedConstraints...) |
| 300 | for _, key := range unsupportedConstraints { | 471 | if options.antigravitySemantics { |
| | 472 | keywords = append(keywords, "minimum", "maximum", "multipleOf") |
| | 473 | } |
| | 474 | return keywords |
| | 475 | } |
| | 476 | |
| | 477 | func moveConstraintsToDescription(jsonStr string, options jsonSchemaCleanOptions) string { |
| | 478 | constraints := constraintKeywords(options) |
| | 479 | pathsByField := findPathsByFields(jsonStr, constraints) |
| | 480 | for _, key := range constraints { |
| 301 | for _, p := range pathsByField[key] { | 481 | for _, p := range pathsByField[key] { |
| 302 | val := gjson.Get(jsonStr, p) | 482 | val := gjson.Get(jsonStr, p) |
| 303 | if !val.Exists() || val.IsObject() || val.IsArray() { | 483 | if !val.Exists() || val.IsObject() || val.IsArray() { |
| @@ -313,6 +493,17 @@ func moveConstraintsToDescription(jsonStr string) string { · 9 unmodified lines |
| 313 | return jsonStr | 493 | return jsonStr |
| 314 | } | 494 | } |
| 315 | | 495 | |
| | 496 | func moveNotToDescription(jsonStr string) string { |
| | 497 | for _, path := range findPaths(jsonStr, "not") { |
| | 498 | value := gjson.Get(jsonStr, path) |
| | 499 | if !value.Exists() || isPropertyDefinition(trimSuffix(path, ".not")) { |
| | 500 | continue |
| | 501 | } |
| | 502 | jsonStr = appendHint(jsonStr, trimSuffix(path, ".not"), "not: "+value.Raw) |
| | 503 | } |
| | 504 | return jsonStr |
| | 505 | } |
| | 506 | |
| 316 | func mergeConditionals(jsonStr string) string { | 507 | func mergeConditionals(jsonStr string) string { |
| 317 | pathsByField := findPathsByFields(jsonStr, []string{"then", "else"}) | 508 | pathsByField := findPathsByFields(jsonStr, []string{"then", "else"}) |
| 318 | var paths []string | 509 | var paths []string |
| @@ -367,31 +558,59 @@ func mergeAllOf(jsonStr string) string { · 48 unmodified lines |
| 367 | parentPath := trimSuffix(p, ".allOf") | 558 | parentPath := trimSuffix(p, ".allOf") |
| 368 | | 559 | |
| 369 | for _, item := range allOf.Array() { | 560 | for _, item := range allOf.Array() { |
| 370 | if props := item.Get("properties"); props.IsObject() { | 561 | if !item.IsObject() { |
| 371 | props.ForEach(func(key, value gjson.Result) bool { | 562 | continue |
| 372 | destPath := joinPath(parentPath, "properties."+escapeGJSONPathKey(key.String())) | 563 | } |
| 373 | updated, _ := sjson.SetRawBytes([]byte(jsonStr), destPath, []byte(value.Raw)) | 564 | item.ForEach(func(key, value gjson.Result) bool { |
| 374 | jsonStr = string(updated) | 565 | field := key.String() |
| 375 | return true | 566 | switch field { |
| 376 | }) | 567 | case "required": |
| 377 | } | 568 | if !value.IsArray() { |
| 378 | if req := item.Get("required"); req.IsArray() { | 569 | return true |
| 379 | reqPath := joinPath(parentPath, "required") | 570 | } |
| 380 | current := getStrings(jsonStr, reqPath) | 571 | reqPath := joinPath(parentPath, "required") |
| 381 | for _, r := range req.Array() { | 572 | current := getStrings(jsonStr, reqPath) |
| 382 | if s := r.String(); !contains(current, s) { | 573 | for _, required := range value.Array() { |
| 383 | current = append(current, s) | 574 | if name := required.String(); !contains(current, name) { |
| | 575 | current = append(current, name) |
| | 576 | } |
| 384 | } | 577 | } |
| | 578 | updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current) |
| | 579 | jsonStr = string(updated) |
| | 580 | case "if", "then", "else", "allOf": |
| | 581 | // Conditional applicability cannot be represented by the upstream schema. |
| | 582 | default: |
| | 583 | destination := joinPath(parentPath, escapeGJSONPathKey(field)) |
| | 584 | jsonStr = mergeMissingSchemaAtPath(jsonStr, destination, value) |
| 385 | } | 585 | } |
| 386 | updated, _ := sjson.SetBytes([]byte(jsonStr), reqPath, current) | 586 | return true |
| 387 | jsonStr = string(updated) | 587 | }) |
| 388 | } | | |
| 389 | } | 588 | } |
| 390 | jsonStr, _ = sjson.Delete(jsonStr, p) | 589 | jsonStr, _ = sjson.Delete(jsonStr, p) |
| 391 | } | 590 | } |
| 392 | return jsonStr | 591 | return jsonStr |
| 393 | } | 592 | } |
| 394 | | 593 | |
| | 594 | // mergeMissingSchemaAtPath recursively fills absent fields without replacing any existing |
| | 595 | // definition. A parent schema is the canonical definition; allOf and conditional branches may |
| | 596 | // enrich gaps in it, but can never replace it with a narrower branch shell. |
| | 597 | func mergeMissingSchemaAtPath(jsonStr, destination string, incoming gjson.Result) string { |
| | 598 | existing := gjson.Get(jsonStr, destination) |
| | 599 | if !existing.Exists() { |
| | 600 | updated, _ := sjson.SetRawBytes([]byte(jsonStr), destination, []byte(incoming.Raw)) |
| | 601 | return string(updated) |
| | 602 | } |
| | 603 | if !existing.IsObject() || !incoming.IsObject() { |
| | 604 | return jsonStr |
| | 605 | } |
| | 606 | incoming.ForEach(func(key, value gjson.Result) bool { |
| | 607 | child := joinPath(destination, escapeGJSONPathKey(key.String())) |
| | 608 | jsonStr = mergeMissingSchemaAtPath(jsonStr, child, value) |
| | 609 | return true |
| | 610 | }) |
| | 611 | return jsonStr |
| | 612 | } |
| | 613 | |
| 395 | func flattenAnyOfOneOf(jsonStr string) string { | 614 | func flattenAnyOfOneOf(jsonStr string) string { |
| 396 | for _, key := range []string{"anyOf", "oneOf"} { | 615 | for _, key := range []string{"anyOf", "oneOf"} { |
| 397 | paths := findPaths(jsonStr, key) | 616 | paths := findPaths(jsonStr, key) |
| @@ -409,6 +628,17 @@ func flattenAnyOfOneOf(jsonStr string) string { · 11 unmodified lines |
| 409 | items := arr.Array() | 628 | items := arr.Array() |
| 410 | bestIdx, allTypes := selectBest(items) | 629 | bestIdx, allTypes := selectBest(items) |
| 411 | selected := items[bestIdx].Raw | 630 | selected := items[bestIdx].Raw |
| | 631 | hasNull := false |
| | 632 | for _, item := range items { |
| | 633 | if item.Get("type").String() == "null" { |
| | 634 | hasNull = true |
| | 635 | break |
| | 636 | } |
| | 637 | } |
| | 638 | if hasNull && items[bestIdx].Get("type").String() != "null" { |
| | 639 | updated, _ := sjson.SetBytes([]byte(selected), "nullable", true) |
| | 640 | selected = string(updated) |
| | 641 | } |
| 412 | | 642 | |
| 413 | if parentDesc != "" { | 643 | if parentDesc != "" { |
| 414 | selected = mergeDescriptionRaw(selected, parentDesc) | 644 | selected = mergeDescriptionRaw(selected, parentDesc) |
| @@ -452,7 +682,7 @@ func selectBest(items []gjson.Result) (bestIdx int, types []string) { · 37 unmodified lines |
| 452 | return | 682 | return |
| 453 | } | 683 | } |
| 454 | | 684 | |
| 455 | func flattenTypeArrays(jsonStr string) string { | 685 | func flattenTypeArrays(jsonStr string, preserveNativeNullable bool) string { |
| 456 | paths := findPaths(jsonStr, "type") | 686 | paths := findPaths(jsonStr, "type") |
| 457 | sortByDepth(paths) | 687 | sortByDepth(paths) |
| 458 | | 688 | |
| @@ -490,15 +720,20 @@ func flattenTypeArrays(jsonStr string) string { · 31 unmodified lines |
| 490 | } | 720 | } |
| 491 | | 721 | |
| 492 | if hasNull { | 722 | if hasNull { |
| | 723 | if preserveNativeNullable { |
| | 724 | updated, _ = sjson.SetBytes([]byte(jsonStr), joinPath(parentPath, "nullable"), true) |
| | 725 | jsonStr = string(updated) |
| | 726 | jsonStr = appendHint(jsonStr, parentPath, "(nullable)") |
| | 727 | continue |
| | 728 | } |
| | 729 | |
| 493 | parts := splitGJSONPath(p) | 730 | parts := splitGJSONPath(p) |
| 494 | if len(parts) >= 3 && parts[len(parts)-3] == "properties" { | 731 | if len(parts) >= 3 && parts[len(parts)-3] == "properties" { |
| 495 | fieldNameEscaped := parts[len(parts)-2] | 732 | fieldNameEscaped := parts[len(parts)-2] |
| 496 | fieldName := unescapeGJSONPathKey(fieldNameEscaped) | 733 | fieldName := unescapeGJSONPathKey(fieldNameEscaped) |
| 497 | objectPath := strings.Join(parts[:len(parts)-3], ".") | 734 | objectPath := strings.Join(parts[:len(parts)-3], ".") |
| 498 | nullableFields[objectPath] = append(nullableFields[objectPath], fieldName) | 735 | nullableFields[objectPath] = append(nullableFields[objectPath], fieldName) |
| 499 | | 736 | jsonStr = appendHint(jsonStr, joinPath(objectPath, "properties."+fieldNameEscaped), "(nullable)") |
| 500 | propPath := joinPath(objectPath, "properties."+fieldNameEscaped) | | |
| 501 | jsonStr = appendHint(jsonStr, propPath, "(nullable)") | | |
| 502 | } | 737 | } |
| 503 | } | 738 | } |
| 504 | } | 739 | } |
| @@ -511,12 +746,11 @@ func flattenTypeArrays(jsonStr string) string { · 6 unmodified lines |
| 511 | } | 746 | } |
| 512 | | 747 | |
| 513 | var filtered []string | 748 | var filtered []string |
| 514 | for _, r := range req.Array() { | 749 | for _, required := range req.Array() { |
| 515 | if !contains(fields, r.String()) { | 750 | if !contains(fields, required.String()) { |
| 516 | filtered = append(filtered, r.String()) | 751 | filtered = append(filtered, required.String()) |
| 517 | } | 752 | } |
| 518 | } | 753 | } |
| 519 | | | |
| 520 | if len(filtered) == 0 { | 754 | if len(filtered) == 0 { |
| 521 | jsonStr, _ = sjson.Delete(jsonStr, reqPath) | 755 | jsonStr, _ = sjson.Delete(jsonStr, reqPath) |
| 522 | } else { | 756 | } else { |
| @@ -528,12 +762,15 @@ func flattenTypeArrays(jsonStr string) string { · 5 unmodified lines |
| 528 | } | 762 | } |
| 529 | | 763 | |
| 530 | func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) string { | 764 | func removeUnsupportedKeywords(jsonStr string, options jsonSchemaCleanOptions) string { |
| 531 | keywords := append(unsupportedConstraints, | 765 | keywords := append(constraintKeywords(options), |
| 532 | "$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties", | 766 | "$schema", "$defs", "definitions", "const", "$ref", "$id", "additionalProperties", |
| 533 | "propertyNames", "patternProperties", // Gemini doesn't support these schema keywords | 767 | "propertyNames", "patternProperties", // Gemini doesn't support these schema keywords |
| 534 | "if", "then", "else", | 768 | "if", "then", "else", |
| 535 | "$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", // Schema metadata fields unsupported by Gemini | 769 | "$comment", "enumDescriptions", "enumTitles", "prefill", "deprecated", // Schema metadata fields unsupported by Gemini |
| 536 | ) | 770 | ) |
| | 771 | if options.antigravitySemantics { |
| | 772 | keywords = append(keywords, "not") |
| | 773 | } |
| 537 | | 774 | |
| 538 | deletePaths := make([]string, 0) | 775 | deletePaths := make([]string, 0) |
| 539 | pathsByField := findPathsByFields(jsonStr, keywords) | 776 | pathsByField := findPathsByFields(jsonStr, keywords) |
| @@ -746,7 +983,9 @@ func walkForFields(value gjson.Result, path string, fields map[string]struct{}, · 206 unmodified lines |
| 746 | } | 983 | } |
| 747 | | 984 | |
| 748 | func sortByDepth(paths []string) { | 985 | func sortByDepth(paths []string) { |
| 749 | sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) | 986 | sort.SliceStable(paths, func(i, j int) bool { |
| | 987 | return len(splitGJSONPath(paths[i])) > len(splitGJSONPath(paths[j])) |
| | 988 | }) |
| 750 | } | 989 | } |
| 751 | | 990 | |
| 752 | func trimSuffix(path, suffix string) string { | 991 | func trimSuffix(path, suffix string) string { |