-
Notifications
You must be signed in to change notification settings - Fork 367
Expand file tree
/
Copy pathschema_errors.go
More file actions
455 lines (399 loc) · 16.7 KB
/
schema_errors.go
File metadata and controls
455 lines (399 loc) · 16.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
package parser
import (
"fmt"
"regexp"
"sort"
"strings"
)
// atPathPattern matches "- at '/path': " or "at '/path': " prefixes in error messages
var atPathPattern = regexp.MustCompile(`^-?\s*at '([^']*)': (.+)$`)
// minConstraintPattern matches "minimum: got X, want Y" messages from the jsonschema library
var minConstraintPattern = regexp.MustCompile(`^minimum: got (-?\d+(?:\.\d+)?), want (-?\d+(?:\.\d+)?)$`)
// maxConstraintPattern matches "maximum: got X, want Y" messages from the jsonschema library
var maxConstraintPattern = regexp.MustCompile(`^maximum: got (-?\d+(?:\.\d+)?), want (-?\d+(?:\.\d+)?)$`)
// translateSchemaConstraintMessage rewrites jsonschema range-constraint messages into plain English.
//
// Examples:
// - "minimum: got -45, want 1" → "must be at least 1 (got -45)"
// - "maximum: got 120, want 60" → "must be at most 60 (got 120)"
func translateSchemaConstraintMessage(message string) string {
if m := minConstraintPattern.FindStringSubmatch(message); len(m) == 3 {
log.Printf("Translating minimum constraint message: got=%s want=%s", m[1], m[2])
return fmt.Sprintf("must be at least %s (got %s)", m[2], m[1])
}
if m := maxConstraintPattern.FindStringSubmatch(message); len(m) == 3 {
log.Printf("Translating maximum constraint message: got=%s want=%s", m[1], m[2])
return fmt.Sprintf("must be at most %s (got %s)", m[2], m[1])
}
return message
}
// cleanJSONSchemaErrorMessage removes unhelpful prefixes from jsonschema validation errors
func cleanJSONSchemaErrorMessage(errorMsg string) string {
log.Printf("Cleaning JSON schema error message (%d chars)", len(errorMsg))
// Split the error message into lines
lines := strings.Split(errorMsg, "\n")
var cleanedLines []string
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip the "jsonschema validation failed" line entirely
if strings.HasPrefix(line, "jsonschema validation failed") {
continue
}
// Remove the unhelpful "- at '': " prefix from error descriptions
line = strings.TrimPrefix(line, "- at '': ")
// Keep non-empty lines that have actual content
if line != "" {
cleanedLines = append(cleanedLines, line)
}
}
// Join the cleaned lines back together
result := strings.Join(cleanedLines, "\n")
// If we have no meaningful content left, return a generic message
if strings.TrimSpace(result) == "" {
return "schema validation failed"
}
// Apply oneOf cleanup to the full cleaned message
return cleanOneOfMessage(result)
}
// cleanOneOfMessage simplifies 'oneOf failed, none matched' error messages by:
// 1. Removing "got X, want Y" type-mismatch lines (from the wrong branch of a oneOf)
// 2. Removing the "oneOf failed, none matched" wrapper line
// 3. Extracting the most meaningful sub-error (e.g., enum constraint violations)
//
// This converts confusing schema jargon like:
//
// "'oneOf' failed, none matched\n- at '/engine': value must be one of...\n- at '/engine': got string, want object"
//
// into plain language:
//
// "value must be one of 'claude', 'codex', 'copilot', 'gemini'"
func cleanOneOfMessage(message string) string {
if !strings.Contains(message, "'oneOf' failed") {
return message
}
log.Printf("Simplifying oneOf error message (%d lines)", len(strings.Split(message, "\n")))
lines := strings.Split(message, "\n")
var meaningful []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// Skip the "oneOf failed" wrapper line — it's schema jargon, not user guidance
if strings.Contains(trimmed, "'oneOf' failed, none matched") {
continue
}
// Skip "got X, want Y" type-mismatch lines from the wrong oneOf branch
if isTypeConflictLine(trimmed) {
continue
}
meaningful = append(meaningful, trimmed)
}
if len(meaningful) == 0 {
// All sub-errors were type conflicts — synthesize a plain-English message
// instead of returning raw JSON Schema jargon.
return synthesizeOneOfTypeConflictMessage(lines)
}
// Strip "- at '/path':" prefixes and format each remaining constraint
var cleaned []string
for _, line := range meaningful {
cleaned = append(cleaned, stripAtPathPrefix(line))
}
return strings.Join(cleaned, "; ")
}
// typeConflictGotWantPattern extracts "got X, want Y" components from type-conflict lines.
// Matches both bare "got X, want Y" and embedded "- at '/path': got X, want Y" forms.
var typeConflictGotWantPattern = regexp.MustCompile(`(?:^|: )got (\w+), want (\w+)$`)
// knownOneOfFieldHints provides field-specific guidance for oneOf type-conflict fallback
// messages. When all oneOf branches fail with type-mismatch errors (e.g., the user passes
// an integer where a string or object is expected), these hints are appended to the
// synthesized plain-English message to help the user fix the problem.
//
// The engine list mirrors the built-in engines in NewEngineCatalog.
// Update this list when built-in engines change.
var knownOneOfFieldHints = map[string]string{
"/engine": "Valid engine names: claude, codex, copilot, gemini.\n\nExample:\nengine: copilot",
}
// synthesizeOneOfTypeConflictMessage produces a plain-English error message when every
// sub-error of a oneOf constraint is a type conflict (e.g., "got number, want string"
// and "got number, want object"). It extracts the actual and expected types from the
// conflict lines and, for well-known fields, appends guidance with valid values.
func synthesizeOneOfTypeConflictMessage(lines []string) string {
var gotType string
var wantTypes []string
var path string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !isTypeConflictLine(trimmed) {
continue
}
// Extract path from "- at '/path': got X, want Y"
if match := atPathPattern.FindStringSubmatch(trimmed); match != nil {
if path == "" {
path = match[1]
}
}
// Extract got/want types
if match := typeConflictGotWantPattern.FindStringSubmatch(trimmed); match != nil {
if gotType == "" {
gotType = match[1]
}
wantTypes = append(wantTypes, match[2])
}
}
if gotType == "" || len(wantTypes) == 0 {
return "schema validation failed"
}
// Deduplicate expected types (e.g., multiple "object" branches in oneOf)
seen := make(map[string]bool)
var uniqueWantTypes []string
for _, t := range wantTypes {
if !seen[t] {
seen[t] = true
uniqueWantTypes = append(uniqueWantTypes, t)
}
}
result := fmt.Sprintf("expected %s, got %s", strings.Join(uniqueWantTypes, " or "), gotType)
// Add field-specific hints for known fields
if hint, ok := knownOneOfFieldHints[path]; ok {
result += ". " + hint
}
return result
}
// jsonTypeNames is the set of valid JSON Schema type names. Used to distinguish
// actual type conflicts ("got number, want string") from constraint violations
// ("minItems: got 0, want 1") in oneOf error messages.
var jsonTypeNames = map[string]bool{
"string": true, "object": true, "array": true, "number": true,
"integer": true, "boolean": true, "null": true,
}
// typeConflictPattern matches "got TYPE, want TYPE" where TYPE must be a JSON type name.
// This avoids false positives on constraint violations like "minItems: got 0, want 1".
var typeConflictPattern = regexp.MustCompile(`got (\w+), want (\w+)`)
// isTypeConflictLine returns true for "got X, want Y" lines that arise from the
// wrong branch of a oneOf constraint. These lines are generated when the user's value
// matches one branch's type but not the other, and they are confusing to display.
// Handles both bare "got X, want Y" and embedded "- at '/path': got X, want Y" forms.
//
// Only matches when both X and Y are JSON Schema type names (string, object, array,
// number, integer, boolean, null), to avoid misidentifying constraint violations
// (e.g., "minItems: got 0, want 1") as type conflicts.
func isTypeConflictLine(line string) bool {
// Fast-path: skip regex for lines that clearly aren't type conflicts
if !strings.Contains(line, "got ") || !strings.Contains(line, ", want ") {
return false
}
match := typeConflictPattern.FindStringSubmatch(line)
if match == nil {
return false
}
return jsonTypeNames[match[1]] && jsonTypeNames[match[2]]
}
// stripAtPathPrefix removes "- at '/path': " or "at '/path': " prefixes from schema error lines
// and formats nested path references to be more readable.
//
// Examples:
// - "- at '/engine': value must be one of..." → "value must be one of..."
// - "- at '/permissions/deployments': value must be..." → "'deployments': value must be..."
func stripAtPathPrefix(line string) string {
match := atPathPattern.FindStringSubmatch(line)
if match == nil {
return line
}
path := match[1]
msg := match[2]
// For nested paths (e.g., /permissions/deployments), keep the last component
// so users know which sub-field has the error
if idx := strings.LastIndex(path, "/"); idx > 0 {
subField := path[idx+1:]
return fmt.Sprintf("'%s': %s", subField, msg)
}
// For top-level field errors, just return the constraint message
return msg
}
// findFrontmatterBounds finds the start and end indices of frontmatter in file lines
// Returns: startIdx (-1 if not found), endIdx (-1 if not found), frontmatterContent
func findFrontmatterBounds(lines []string) (startIdx int, endIdx int, frontmatterContent string) {
log.Printf("Finding frontmatter bounds in %d lines", len(lines))
startIdx = -1
endIdx = -1
// Look for the opening "---"
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "---" {
startIdx = i
break
}
// Skip empty lines and comments at the beginning
if trimmed != "" && !strings.HasPrefix(trimmed, "#") {
// Found non-empty, non-comment line before "---" - no frontmatter
return -1, -1, ""
}
}
if startIdx == -1 {
log.Print("No frontmatter opening delimiter found")
return -1, -1, ""
}
// Look for the closing "---"
for i := startIdx + 1; i < len(lines); i++ {
trimmed := strings.TrimSpace(lines[i])
if trimmed == "---" {
endIdx = i
break
}
}
if endIdx == -1 {
// No closing "---" found
log.Print("No frontmatter closing delimiter found")
return -1, -1, ""
}
log.Printf("Found frontmatter bounds: start=%d end=%d", startIdx, endIdx)
// Extract frontmatter content between the markers
frontmatterLines := lines[startIdx+1 : endIdx]
frontmatterContent = strings.Join(frontmatterLines, "\n")
return startIdx, endIdx, frontmatterContent
}
// knownFieldValidValues maps well-known JSON schema paths to a human-readable description
// of the valid values / children for that field. Used to append helpful hints when an
// additionalProperties error occurs on these fields so users quickly know what is allowed.
//
// The permissions scope list mirrors the properties defined in main_workflow_schema.json
// under permissions.oneOf[1].properties. Update this list when the schema changes.
var knownFieldValidValues = map[string]string{
// This list mirrors permissions.oneOf[1].properties in main_workflow_schema.json.
// Update both when the schema changes.
"/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",
}
// knownFieldScopes maps well-known JSON schema paths to a slice of valid scope names.
// This enables spell-check ("Did you mean?") suggestions for unknown-property errors.
//
// The permissions scope list mirrors permissions.oneOf[1].properties in main_workflow_schema.json.
// Update both when the schema changes.
var knownFieldScopes = map[string][]string{
"/permissions": {
"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",
},
}
// knownFieldDocs maps well-known JSON schema paths to documentation URLs.
var knownFieldDocs = map[string]string{
"/permissions": "https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token",
}
// unknownPropertyPattern extracts the property name(s) from a rewritten "Unknown property(ies):" message.
var unknownPropertyPattern = regexp.MustCompile(`(?i)^Unknown propert(?:y|ies): (.+)$`)
// appendKnownFieldValidValuesHint appends a "Valid values: …" hint, "Did you mean?" suggestions,
// and a documentation link to message when the jsonPath matches a well-known field and the
// message is an unknown-property error.
// It returns the message unchanged for unknown paths or non-additional-properties messages.
// The second return value is true if a hint was actually appended to the message.
func appendKnownFieldValidValuesHint(message string, jsonPath string) (string, bool) {
// Use truncated prefix "unknown propert" to match both singular ("Unknown property")
// and plural ("Unknown properties") forms produced by rewriteAdditionalPropertiesError.
if !strings.Contains(strings.ToLower(message), "unknown propert") {
return message, false
}
log.Printf("Appending known field hint for path: %s", jsonPath)
// Find the best matching known path: exact match first, then the longest matching parent.
hint, hintOK := knownFieldValidValues[jsonPath]
scopes := knownFieldScopes[jsonPath]
docsURL := knownFieldDocs[jsonPath]
if !hintOK {
// Select the longest matching parent path deterministically to avoid
// random map iteration order when multiple known paths share a common prefix.
bestPath := ""
bestLen := 0
for path := range knownFieldValidValues {
if strings.HasPrefix(jsonPath, path+"/") {
if l := len(path); l > bestLen {
bestLen = l
bestPath = path
}
}
}
if bestPath != "" {
hint = knownFieldValidValues[bestPath]
scopes = knownFieldScopes[bestPath]
docsURL = knownFieldDocs[bestPath]
hintOK = true
}
}
if !hintOK {
return message, false
}
result := message + " (" + hint + ")"
// Add "Did you mean?" suggestions when the unknown property name is close to a valid scope.
if len(scopes) > 0 {
// unknownPropertyPattern has exactly one capture group, so a successful match
// returns [fullMatch, captureGroup1], giving len(m) == 2.
if m := unknownPropertyPattern.FindStringSubmatch(message); len(m) == 2 {
unknownProps := strings.Split(m[1], ", ")
var allSuggestions []string
for _, prop := range unknownProps {
prop = strings.TrimSpace(prop)
if prop == "" {
continue
}
// maxClosestMatches is defined in schema_suggestions.go in the same package.
closest := FindClosestMatches(prop, scopes, maxClosestMatches)
allSuggestions = append(allSuggestions, closest...)
}
// Deduplicate suggestions
seen := make(map[string]bool)
var unique []string
for _, s := range allSuggestions {
if !seen[s] {
seen[s] = true
unique = append(unique, s)
}
}
if len(unique) == 1 {
result = fmt.Sprintf("%s. Did you mean '%s'?", result, unique[0])
} else if len(unique) > 1 {
result = fmt.Sprintf("%s. Did you mean: %s?", result, strings.Join(unique, ", "))
}
}
}
// Append documentation link on the same line to avoid breaking bullet-list formatting
// when this message is embedded in "Multiple schema validation failures:" output.
if docsURL != "" {
result = fmt.Sprintf("%s See: %s", result, docsURL)
}
return result, true
}
// rewriteAdditionalPropertiesError rewrites "additional properties not allowed" errors to be more user-friendly
func rewriteAdditionalPropertiesError(message string) string {
// Check if this is an "additional properties not allowed" error
if strings.Contains(strings.ToLower(message), "additional propert") && strings.Contains(strings.ToLower(message), "not allowed") {
// Extract property names from the message using regex
re := regexp.MustCompile(`additional propert(?:y|ies) (.+?) not allowed`)
match := re.FindStringSubmatch(message)
if len(match) >= 2 {
properties := normalizeAdditionalPropertyList(match[1])
log.Printf("Rewriting additional properties error: %s", properties)
if strings.Contains(properties, ",") {
return "Unknown properties: " + properties
} else {
return "Unknown property: " + properties
}
}
}
return message
}
// normalizeAdditionalPropertyList strips quotes, trims whitespace, and sorts the
// comma-separated property names so that diagnostics are deterministic regardless
// of the order in which the schema validator emits them.
func normalizeAdditionalPropertyList(raw string) string {
raw = strings.ReplaceAll(raw, "'", "")
parts := strings.Split(raw, ",")
cleaned := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
cleaned = append(cleaned, trimmed)
}
}
sort.Strings(cleaned)
return strings.Join(cleaned, ", ")
}