Skip to content

Commit e49b9fb

Browse files
authored
Improve compiler error messages for YAML syntax errors and permission scope validation (#22581)
1 parent 8055e56 commit e49b9fb

7 files changed

Lines changed: 474 additions & 69 deletions

File tree

pkg/parser/schema_errors.go

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -217,30 +217,109 @@ var knownFieldValidValues = map[string]string{
217217
"/permissions": "Valid permission scopes: actions, all, attestations, checks, contents, deployments, discussions, id-token, issues, metadata, models, organization-projects, packages, pages, pull-requests, repository-projects, security-events, statuses, vulnerability-alerts",
218218
}
219219

220-
// appendKnownFieldValidValuesHint appends a "Valid values: …" hint to message when the
221-
// jsonPath matches a well-known field and the message is an unknown-property error.
220+
// knownFieldScopes maps well-known JSON schema paths to a slice of valid scope names.
221+
// This enables spell-check ("Did you mean?") suggestions for unknown-property errors.
222+
//
223+
// The permissions scope list mirrors permissions.oneOf[1].properties in main_workflow_schema.json.
224+
// Update both when the schema changes.
225+
var knownFieldScopes = map[string][]string{
226+
"/permissions": {
227+
"actions", "all", "attestations", "checks", "contents", "deployments",
228+
"discussions", "id-token", "issues", "metadata", "models",
229+
"organization-projects", "packages", "pages", "pull-requests",
230+
"repository-projects", "security-events", "statuses", "vulnerability-alerts",
231+
},
232+
}
233+
234+
// knownFieldDocs maps well-known JSON schema paths to documentation URLs.
235+
var knownFieldDocs = map[string]string{
236+
"/permissions": "https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token",
237+
}
238+
239+
// unknownPropertyPattern extracts the property name(s) from a rewritten "Unknown property(ies):" message.
240+
var unknownPropertyPattern = regexp.MustCompile(`(?i)^Unknown propert(?:y|ies): (.+)$`)
241+
242+
// appendKnownFieldValidValuesHint appends a "Valid values: …" hint, "Did you mean?" suggestions,
243+
// and a documentation link to message when the jsonPath matches a well-known field and the
244+
// message is an unknown-property error.
222245
// It returns the message unchanged for unknown paths or non-additional-properties messages.
223246
func appendKnownFieldValidValuesHint(message string, jsonPath string) string {
224247
// Use truncated prefix "unknown propert" to match both singular ("Unknown property")
225248
// and plural ("Unknown properties") forms produced by rewriteAdditionalPropertiesError.
226249
if !strings.Contains(strings.ToLower(message), "unknown propert") {
227250
return message
228251
}
229-
hint, ok := knownFieldValidValues[jsonPath]
230-
if !ok {
231-
// Check if the path is nested under a known parent (e.g. /permissions/contents)
232-
for path, h := range knownFieldValidValues {
252+
253+
// Find the best matching known path: exact match first, then the longest matching parent.
254+
hint, hintOK := knownFieldValidValues[jsonPath]
255+
scopes := knownFieldScopes[jsonPath]
256+
docsURL := knownFieldDocs[jsonPath]
257+
if !hintOK {
258+
// Select the longest matching parent path deterministically to avoid
259+
// random map iteration order when multiple known paths share a common prefix.
260+
bestPath := ""
261+
bestLen := 0
262+
for path := range knownFieldValidValues {
233263
if strings.HasPrefix(jsonPath, path+"/") {
234-
hint = h
235-
ok = true
236-
break
264+
if l := len(path); l > bestLen {
265+
bestLen = l
266+
bestPath = path
267+
}
237268
}
238269
}
270+
if bestPath != "" {
271+
hint = knownFieldValidValues[bestPath]
272+
scopes = knownFieldScopes[bestPath]
273+
docsURL = knownFieldDocs[bestPath]
274+
hintOK = true
275+
}
239276
}
240-
if !ok {
277+
if !hintOK {
241278
return message
242279
}
243-
return message + " (" + hint + ")"
280+
281+
result := message + " (" + hint + ")"
282+
283+
// Add "Did you mean?" suggestions when the unknown property name is close to a valid scope.
284+
if len(scopes) > 0 {
285+
// unknownPropertyPattern has exactly one capture group, so a successful match
286+
// returns [fullMatch, captureGroup1], giving len(m) == 2.
287+
if m := unknownPropertyPattern.FindStringSubmatch(message); len(m) == 2 {
288+
unknownProps := strings.Split(m[1], ", ")
289+
var allSuggestions []string
290+
for _, prop := range unknownProps {
291+
prop = strings.TrimSpace(prop)
292+
if prop == "" {
293+
continue
294+
}
295+
// maxClosestMatches is defined in schema_suggestions.go in the same package.
296+
closest := FindClosestMatches(prop, scopes, maxClosestMatches)
297+
allSuggestions = append(allSuggestions, closest...)
298+
}
299+
// Deduplicate suggestions
300+
seen := make(map[string]bool)
301+
var unique []string
302+
for _, s := range allSuggestions {
303+
if !seen[s] {
304+
seen[s] = true
305+
unique = append(unique, s)
306+
}
307+
}
308+
if len(unique) == 1 {
309+
result = fmt.Sprintf("%s. Did you mean '%s'?", result, unique[0])
310+
} else if len(unique) > 1 {
311+
result = fmt.Sprintf("%s. Did you mean: %s?", result, strings.Join(unique, ", "))
312+
}
313+
}
314+
}
315+
316+
// Append documentation link on the same line to avoid breaking bullet-list formatting
317+
// when this message is embedded in "Multiple schema validation failures:" output.
318+
if docsURL != "" {
319+
result = fmt.Sprintf("%s See: %s", result, docsURL)
320+
}
321+
322+
return result
244323
}
245324

246325
// rewriteAdditionalPropertiesError rewrites "additional properties not allowed" errors to be more user-friendly

pkg/parser/schema_errors_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,3 +306,75 @@ func TestRewriteAdditionalPropertiesErrorOrdering(t *testing.T) {
306306
})
307307
}
308308
}
309+
310+
// TestAppendKnownFieldValidValuesHint tests that the hint function appends valid values,
311+
// "Did you mean?" suggestions, and documentation links for well-known schema paths.
312+
func TestAppendKnownFieldValidValuesHint(t *testing.T) {
313+
tests := []struct {
314+
name string
315+
message string
316+
jsonPath string
317+
contains []string // substrings that must appear
318+
excludes []string // substrings that must NOT appear
319+
}{
320+
{
321+
name: "no hint for non-unknown-property message",
322+
message: "value must be one of 'read', 'write', 'none'",
323+
jsonPath: "/permissions",
324+
contains: []string{"value must be one of"},
325+
excludes: []string{"Valid permission scopes", "See:"},
326+
},
327+
{
328+
name: "hint appended for permissions path",
329+
message: "Unknown property: issuess",
330+
jsonPath: "/permissions",
331+
contains: []string{"Valid permission scopes", "See: https://docs.github.com"},
332+
},
333+
{
334+
name: "did you mean suggestion for close typo",
335+
message: "Unknown property: issuess",
336+
jsonPath: "/permissions",
337+
contains: []string{"Did you mean 'issues'?"},
338+
},
339+
{
340+
name: "did you mean suggestion for another typo",
341+
message: "Unknown property: contnets",
342+
jsonPath: "/permissions",
343+
contains: []string{"Did you mean 'contents'?"},
344+
},
345+
{
346+
name: "no did you mean for unrelated word",
347+
message: "Unknown property: completely-unknown-xyz",
348+
jsonPath: "/permissions",
349+
excludes: []string{"Did you mean"},
350+
contains: []string{"Valid permission scopes"},
351+
},
352+
{
353+
name: "no hint for unknown path",
354+
message: "Unknown property: foo",
355+
jsonPath: "/some-unknown-path",
356+
contains: []string{"Unknown property: foo"},
357+
excludes: []string{"Valid permission scopes", "See:"},
358+
},
359+
{
360+
name: "hint works for nested permissions path",
361+
message: "Unknown property: issuess",
362+
jsonPath: "/permissions/issuess",
363+
contains: []string{"Valid permission scopes", "See: https://docs.github.com"},
364+
},
365+
}
366+
367+
for _, tt := range tests {
368+
t.Run(tt.name, func(t *testing.T) {
369+
result := appendKnownFieldValidValuesHint(tt.message, tt.jsonPath)
370+
for _, want := range tt.contains {
371+
assert.Contains(t, result, want,
372+
"appendKnownFieldValidValuesHint should contain %q\nResult: %s", want, result)
373+
}
374+
for _, exclude := range tt.excludes {
375+
assert.NotContains(t, result, exclude,
376+
"appendKnownFieldValidValuesHint should not contain %q\nResult: %s", exclude, result)
377+
}
378+
})
379+
}
380+
}

pkg/parser/yaml_error.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,117 @@ var (
1818
sourceLinePattern = regexp.MustCompile(`(?m)^(>?\s*)(\d+)(\s*\|)`)
1919
)
2020

21+
// yamlErrorTranslations maps common goccy/go-yaml internal error messages to
22+
// user-friendly plain-language descriptions with actionable fix guidance.
23+
// Each pattern is matched case-insensitively against the parser message text only
24+
// (not the surrounding source-context lines in yaml.FormatError() output).
25+
//
26+
// These translations are the single source of truth shared between the parser
27+
// and workflow packages. See TranslateYAMLMessage for public access.
28+
var yamlErrorTranslations = []struct {
29+
pattern string
30+
replacement string
31+
}{
32+
{
33+
"unexpected key name",
34+
"missing ':' after key — YAML mapping entries require 'key: value' format",
35+
},
36+
{
37+
"mapping value is not allowed in this context",
38+
"unexpected ':' — check indentation or if this key belongs in a mapping block",
39+
},
40+
{
41+
"mapping values are not allowed",
42+
"unexpected ':' — check indentation or if this key belongs in a mapping block",
43+
},
44+
{
45+
"string was used where mapping is expected",
46+
"expected a YAML mapping (key: value pairs) but got a plain string",
47+
},
48+
{
49+
"non-map value is specified",
50+
"expected a YAML mapping (key: value pairs) — did you forget a colon after the key?",
51+
},
52+
{
53+
"tab character cannot use as a map key directly",
54+
"tab character in key — YAML requires spaces for indentation, not tabs",
55+
},
56+
{
57+
"found character that cannot start any token",
58+
"invalid character — check indentation uses spaces, not tabs",
59+
},
60+
{
61+
"could not find expected ':'",
62+
"missing ':' in key-value pair",
63+
},
64+
{
65+
"did not find expected key",
66+
"incorrect indentation or missing key in mapping",
67+
},
68+
}
69+
70+
// TranslateYAMLMessage translates a raw goccy/go-yaml parser message to a user-friendly
71+
// description. It is the public entry point used by both the parser and workflow packages
72+
// so that both code paths share a single translation table.
73+
//
74+
// The function performs a case-insensitive substring replacement of the first matching
75+
// pattern, leaving any surrounding text intact. This is safe for ASCII patterns because
76+
// strings.ToLower preserves byte positions exactly for ASCII characters.
77+
func TranslateYAMLMessage(message string) string {
78+
lower := strings.ToLower(message)
79+
for _, t := range yamlErrorTranslations {
80+
if idx := strings.Index(lower, t.pattern); idx >= 0 {
81+
yamlErrorLog.Printf("Translating YAML message pattern %q", t.pattern)
82+
// Slice using idx from the lowercase string. Safe because all patterns are ASCII.
83+
return message[:idx] + t.replacement + message[idx+len(t.pattern):]
84+
}
85+
}
86+
return message
87+
}
88+
89+
// translateYAMLError translates cryptic goccy/go-yaml parser messages to user-friendly descriptions.
90+
// It operates on the full yaml.FormatError() output, which includes a header line and source context:
91+
//
92+
// [line:col] original parser message
93+
// > 1 | some: yaml
94+
// ^
95+
//
96+
// Only the parser message portion (the header line, after the "[line:col] " prefix) is translated.
97+
// Source-context lines are left untouched to avoid accidentally replacing text inside user YAML content.
98+
func translateYAMLError(formatted string) string {
99+
if formatted == "" {
100+
return formatted
101+
}
102+
103+
// Split into the header line (which contains the parser message) and the rest (source context).
104+
var header, rest string
105+
if nl := strings.IndexByte(formatted, '\n'); nl >= 0 {
106+
header = formatted[:nl]
107+
rest = formatted[nl:]
108+
} else {
109+
header = formatted
110+
rest = ""
111+
}
112+
113+
// Within the header, locate the parser message text after the "[line:col] " prefix.
114+
// If the prefix is absent (unusual), treat the entire header as the message.
115+
msgStart := strings.Index(header, "] ")
116+
var prefix, msg string
117+
if msgStart >= 0 {
118+
msgStart += len("] ")
119+
prefix = header[:msgStart]
120+
msg = header[msgStart:]
121+
} else {
122+
prefix = ""
123+
msg = header
124+
}
125+
126+
// Translate only the message portion, leaving prefix and source context intact.
127+
translated := TranslateYAMLMessage(msg)
128+
129+
return prefix + translated + rest
130+
}
131+
21132
// FormatYAMLError formats a YAML error with source code context using yaml.FormatError()
22133
// frontmatterLineOffset is the line number where the frontmatter content begins in the document (1-based)
23134
// Returns the formatted error string with line numbers adjusted for frontmatter position
@@ -28,6 +139,9 @@ func FormatYAMLError(err error, frontmatterLineOffset int, sourceYAML string) st
28139
// colored=false to avoid ANSI escape codes, inclSource=true to include source lines
29140
formatted := yaml.FormatError(err, false, true)
30141

142+
// Translate cryptic parser messages to user-friendly descriptions (header line only)
143+
formatted = translateYAMLError(formatted)
144+
31145
// Adjust line numbers in the formatted output to account for frontmatter position
32146
if frontmatterLineOffset > 1 {
33147
formatted = adjustLineNumbersInFormattedError(formatted, frontmatterLineOffset-1)

0 commit comments

Comments
 (0)