Skip to content

Commit a359a3b

Browse files
authored
fix: "Did you mean?" suggestions for nested enum violations (e.g., permission level typos) (#19925)
1 parent dfe305a commit a359a3b

2 files changed

Lines changed: 213 additions & 14 deletions

File tree

pkg/parser/schema_suggestions.go

Lines changed: 107 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
474478
func 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.
505600
func collectSchemaPropertyPaths(schemaDoc any, currentPath string, depth int) []schemaFieldLocation {

pkg/parser/schema_suggestions_test.go

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@ func TestGenerateSchemaBasedSuggestions(t *testing.T) {
8989
frontmatterContent: "engine: xyz123\n",
9090
wantEmpty: true,
9191
},
92+
{
93+
// Full end-to-end: path is the oneOf container, message contains nested path,
94+
// frontmatter has a permission level typo.
95+
name: "nested oneOf enum violation extracts sub-path and suggests Did you mean",
96+
errorMessage: "'oneOf' failed, none matched\n - at '/permissions': got object, want string\n - at '/permissions/contents': value must be one of 'read', 'write', 'none'",
97+
jsonPath: "/permissions",
98+
frontmatterContent: "permissions:\n contents: raed\n",
99+
wantContains: []string{"Did you mean", "read"},
100+
},
92101
}
93102

94103
for _, tt := range tests {
@@ -409,11 +418,41 @@ func TestExtractYAMLValueAtPath(t *testing.T) {
409418
wantValue: "copilot",
410419
},
411420
{
412-
name: "nested path returns empty",
421+
name: "nested path - child not in yaml returns empty",
413422
yaml: "engine: copilot\n",
414423
path: "/permissions/issues",
415424
wantValue: "",
416425
},
426+
{
427+
name: "nested path - extracts value under parent key",
428+
yaml: "permissions:\n contents: raed\n issues: write\n",
429+
path: "/permissions/contents",
430+
wantValue: "raed",
431+
},
432+
{
433+
name: "nested path - second child key",
434+
yaml: "permissions:\n contents: read\n issues: neno\n",
435+
path: "/permissions/issues",
436+
wantValue: "neno",
437+
},
438+
{
439+
name: "nested path - single-quoted value",
440+
yaml: "permissions:\n contents: 'raed'\n",
441+
path: "/permissions/contents",
442+
wantValue: "raed",
443+
},
444+
{
445+
name: "nested path - double-quoted value",
446+
yaml: "permissions:\n contents: \"raed\"\n",
447+
path: "/permissions/contents",
448+
wantValue: "raed",
449+
},
450+
{
451+
name: "three-level path returns empty",
452+
yaml: "a:\n b:\n c: value\n",
453+
path: "/a/b/c",
454+
wantValue: "",
455+
},
417456
{
418457
name: "empty yaml returns empty",
419458
yaml: "",
@@ -426,6 +465,27 @@ func TestExtractYAMLValueAtPath(t *testing.T) {
426465
path: "/timeout-minutes",
427466
wantValue: "",
428467
},
468+
{
469+
name: "top-level key with only nested block returns empty - no inline scalar value",
470+
yaml: "permissions:\n contents: raed\n",
471+
path: "/permissions",
472+
wantValue: "",
473+
},
474+
{
475+
// Ensures column-0 anchoring: a nested key with the same name must not
476+
// satisfy a top-level path request.
477+
name: "indented key with same name does not match top-level path",
478+
yaml: "parent:\n engine: nested-value\nengine: top-value\n",
479+
path: "/engine",
480+
wantValue: "top-value",
481+
},
482+
{
483+
// Grandchild key must not be returned for a direct-child path.
484+
name: "nested path - grandchild key not returned for child path",
485+
yaml: "permissions:\n nested:\n contents: grandchild\n contents: direct\n",
486+
path: "/permissions/contents",
487+
wantValue: "direct",
488+
},
429489
}
430490

431491
for _, tt := range tests {
@@ -438,7 +498,51 @@ func TestExtractYAMLValueAtPath(t *testing.T) {
438498
}
439499
}
440500

441-
// TestGenerateExampleFromSchemaWithExamples tests that schema examples array is preferred over generic fallback
501+
// TestExtractEnumConstraintPath tests that the correct JSON path is extracted from
502+
// enum constraint messages, including nested paths embedded in oneOf error messages.
503+
func TestExtractEnumConstraintPath(t *testing.T) {
504+
tests := []struct {
505+
name string
506+
errorMessage string
507+
fallbackPath string
508+
wantPath string
509+
}{
510+
{
511+
name: "simple enum error uses fallback path",
512+
errorMessage: "value must be one of 'claude', 'copilot'",
513+
fallbackPath: "/engine",
514+
wantPath: "/engine",
515+
},
516+
{
517+
name: "nested enum constraint extracted from oneOf message",
518+
errorMessage: "'oneOf' failed, none matched\n - at '/permissions': got object, want string\n - at '/permissions/contents': value must be one of 'read', 'write', 'none'",
519+
fallbackPath: "/permissions",
520+
wantPath: "/permissions/contents",
521+
},
522+
{
523+
name: "nested path with issues scope",
524+
errorMessage: " - at '/permissions/issues': value must be one of 'read', 'write', 'none'",
525+
fallbackPath: "/permissions",
526+
wantPath: "/permissions/issues",
527+
},
528+
{
529+
name: "no enum path pattern uses fallback",
530+
errorMessage: "got object, want string",
531+
fallbackPath: "/engine",
532+
wantPath: "/engine",
533+
},
534+
}
535+
536+
for _, tt := range tests {
537+
t.Run(tt.name, func(t *testing.T) {
538+
result := extractEnumConstraintPath(tt.errorMessage, tt.fallbackPath)
539+
if result != tt.wantPath {
540+
t.Errorf("extractEnumConstraintPath() = %q, want %q", result, tt.wantPath)
541+
}
542+
})
543+
}
544+
}
545+
442546
func TestGenerateExampleFromSchemaWithExamples(t *testing.T) {
443547
tests := []struct {
444548
name string

0 commit comments

Comments
 (0)