@@ -46,9 +46,13 @@ func generateSchemaBasedSuggestions(schemaJSON, errorMessage, jsonPath, frontmat
4646 if strings .Contains (strings .ToLower (errorMessage ), "value must be one of" ) {
4747 schemaSuggestionsLog .Print ("Detected enum constraint violation" )
4848 enumValues := extractEnumValuesFromError (errorMessage )
49- userValue := extractYAMLValueAtPath (frontmatterContent , jsonPath )
49+ // For oneOf errors, the path points to the container (e.g., "/permissions") but
50+ // the enum constraint is on a nested field (e.g., "/permissions/contents").
51+ // Try to extract the actual sub-path from the message.
52+ actualPath := extractEnumConstraintPath (errorMessage , jsonPath )
53+ userValue := extractYAMLValueAtPath (frontmatterContent , actualPath )
5054 if userValue != "" && len (enumValues ) > 0 {
51- closest := FindClosestMatches (userValue , enumValues , maxClosestMatches )
55+ closest := sliceutil . Deduplicate ( FindClosestMatches (userValue , enumValues , maxClosestMatches ) )
5256 if len (closest ) == 1 {
5357 return fmt .Sprintf ("Did you mean '%s'?" , closest [0 ])
5458 } else if len (closest ) > 1 {
@@ -468,38 +472,129 @@ func extractEnumValuesFromError(errorMessage string) []string {
468472 return values
469473}
470474
471- // extractYAMLValueAtPath extracts the scalar value at a simple top-level JSON path
472- // (e.g., "/engine ") from raw YAML frontmatter content .
473- // Only top-level paths are supported; nested paths return an empty string.
475+ // extractYAMLValueAtPath extracts the scalar value at a JSON path from raw YAML frontmatter.
476+ // Supports top-level paths ("/field ") and two-level nested paths ("/parent/child") .
477+ // Deeper paths return an empty string.
474478func extractYAMLValueAtPath (yamlContent , jsonPath string ) string {
475479 if yamlContent == "" || jsonPath == "" {
476480 return ""
477481 }
478- // Only handle simple top-level paths like "/engine" (one slash, one segment)
479- if strings .Count (jsonPath , "/" ) != 1 {
482+ segments := strings .SplitN (strings .TrimPrefix (jsonPath , "/" ), "/" , 3 )
483+ switch len (segments ) {
484+ case 1 :
485+ return extractTopLevelYAMLValue (yamlContent , segments [0 ])
486+ case 2 :
487+ return extractNestedYAMLValue (yamlContent , segments [0 ], segments [1 ])
488+ default :
480489 return ""
481490 }
482- fieldName := strings .TrimPrefix (jsonPath , "/" )
491+ }
492+
493+ // extractTopLevelYAMLValue extracts the scalar value of a top-level key from raw YAML.
494+ // Uses horizontal-only whitespace between the colon and value to avoid matching multi-line blocks.
495+ // Only keys at column 0 (no indentation) are matched, preventing false matches against
496+ // nested keys with the same name.
497+ func extractTopLevelYAMLValue (yamlContent , fieldName string ) string {
483498 escapedField := regexp .QuoteMeta (fieldName )
484499
485- // Try single-quoted value: field: 'value'
486- reSingle := regexp .MustCompile (`(?m)^\s* ` + escapedField + `\s*:\s *'([^'\n]+)'` )
500+ // Try single-quoted value: field: 'value' (anchored to column 0, no leading whitespace)
501+ reSingle := regexp .MustCompile (`(?m)^` + escapedField + `[ \t]*:[ \t] *'([^'\n]+)'` )
487502 if match := reSingle .FindStringSubmatch (yamlContent ); len (match ) >= 2 {
488503 return strings .TrimSpace (match [1 ])
489504 }
490505 // Try double-quoted value: field: "value"
491- reDouble := regexp .MustCompile (`(?m)^\s* ` + escapedField + `\s*:\s *"([^"\n]+)"` )
506+ reDouble := regexp .MustCompile (`(?m)^` + escapedField + `[ \t]*:[ \t] *"([^"\n]+)"` )
492507 if match := reDouble .FindStringSubmatch (yamlContent ); len (match ) >= 2 {
493508 return strings .TrimSpace (match [1 ])
494509 }
495510 // Try unquoted value: field: value
496- reUnquoted := regexp .MustCompile (`(?m)^\s* ` + escapedField + `\s*:\s *([^'"\n#][^\n#]*?)(?:\s *#.*)?$` )
511+ reUnquoted := regexp .MustCompile (`(?m)^` + escapedField + `[ \t]*:[ \t] *([^'"\n#][^\n#]*?)(?:[ \t] *#.*)?$` )
497512 if match := reUnquoted .FindStringSubmatch (yamlContent ); len (match ) >= 2 {
498513 return strings .TrimSpace (match [1 ])
499514 }
500515 return ""
501516}
502517
518+ // extractNestedYAMLValue extracts the scalar value of a direct child key under a parent key in raw YAML.
519+ // It finds the parent key's block (by indentation), determines the direct-child indent level from
520+ // the first non-blank line inside the block, and only matches keys at that exact indent level.
521+ // This prevents false matches against grandchildren that share the same key name.
522+ func extractNestedYAMLValue (yamlContent , parentKey , childKey string ) string {
523+ lines := strings .Split (yamlContent , "\n " )
524+
525+ escapedParent := regexp .QuoteMeta (parentKey )
526+ parentPattern := regexp .MustCompile (`^(\s*)` + escapedParent + `[ \t]*:` )
527+ escapedChild := regexp .QuoteMeta (childKey )
528+
529+ parentIndent := - 1
530+ childIndent := - 1 // indent of direct children (set on first non-blank line inside the block)
531+ inParentBlock := false
532+
533+ for _ , line := range lines {
534+ if ! inParentBlock {
535+ if match := parentPattern .FindStringSubmatch (line ); match != nil {
536+ parentIndent = len (match [1 ])
537+ inParentBlock = true
538+ }
539+ continue
540+ }
541+
542+ // Inside parent block: skip blank lines
543+ if strings .TrimSpace (line ) == "" {
544+ continue
545+ }
546+ lineIndent := len (line ) - len (strings .TrimLeft (line , " \t " ))
547+
548+ // Left parent block if indentation returned to parent level or less
549+ if lineIndent <= parentIndent {
550+ break
551+ }
552+
553+ // Establish the direct-child indentation from the first non-blank child line
554+ if childIndent == - 1 {
555+ childIndent = lineIndent
556+ }
557+
558+ // Only match keys at the direct-child indent level (not grandchildren deeper)
559+ if lineIndent != childIndent {
560+ continue
561+ }
562+
563+ // Try to match child key with its value (single-quoted, double-quoted, unquoted).
564+ childPrefix := `^\s+` + escapedChild + `[ \t]*:[ \t]*`
565+ reSingle := regexp .MustCompile (childPrefix + `'([^'\n]+)'` )
566+ if match := reSingle .FindStringSubmatch (line ); len (match ) >= 2 {
567+ return strings .TrimSpace (match [1 ])
568+ }
569+ reDouble := regexp .MustCompile (childPrefix + `"([^"\n]+)"` )
570+ if match := reDouble .FindStringSubmatch (line ); len (match ) >= 2 {
571+ return strings .TrimSpace (match [1 ])
572+ }
573+ reUnquoted := regexp .MustCompile (childPrefix + `([^'"\n#][^\n#]*?)(?:[ \t]*#.*)?$` )
574+ if match := reUnquoted .FindStringSubmatch (line ); len (match ) >= 2 {
575+ return strings .TrimSpace (match [1 ])
576+ }
577+ }
578+
579+ return ""
580+ }
581+
582+ // extractEnumConstraintPath finds the JSON path of an enum constraint violation in an error message.
583+ // For simple errors like "value must be one of 'a', 'b'", it returns the provided fallbackPath.
584+ // For oneOf errors that contain a nested sub-path such as:
585+ //
586+ // "- at '/permissions/contents': value must be one of 'read', 'write', 'none'"
587+ //
588+ // it extracts "/permissions/contents" as the actual constraint path.
589+ var enumConstraintPathPattern = regexp .MustCompile (`at '(/[^']+)':\s*value must be one of` )
590+
591+ func extractEnumConstraintPath (errorMessage , fallbackPath string ) string {
592+ if match := enumConstraintPathPattern .FindStringSubmatch (errorMessage ); len (match ) >= 2 {
593+ return match [1 ]
594+ }
595+ return fallbackPath
596+ }
597+
503598// collectSchemaPropertyPaths recursively collects all (fieldName, parentPath) pairs from a JSON schema document.
504599// It traverses properties, oneOf/anyOf/allOf, and items to build a complete picture of valid fields across the schema.
505600func collectSchemaPropertyPaths (schemaDoc any , currentPath string , depth int ) []schemaFieldLocation {
0 commit comments