diff --git a/pkg/parser/safe_outputs_error_location_test.go b/pkg/parser/safe_outputs_error_location_test.go index 47d667dd9a..3242156d0f 100644 --- a/pkg/parser/safe_outputs_error_location_test.go +++ b/pkg/parser/safe_outputs_error_location_test.go @@ -3,6 +3,9 @@ package parser import ( + "os" + "path/filepath" + "strings" "testing" ) @@ -385,3 +388,51 @@ safe-outputs: }) } } + +func TestValidateWithSchemaAndLocationReportsAllSafeOutputFailures(t *testing.T) { + t.Parallel() + + yamlContent := `--- +on: daily +safe-outputs: + create-issue: + invalid-issue-field: true + create-discussion: + invalid-discussion-field: true +--- +# body` + filePath := filepath.Join(t.TempDir(), "workflow.md") + if err := os.WriteFile(filePath, []byte(yamlContent), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + frontmatter := map[string]any{ + "on": "daily", + "safe-outputs": map[string]any{ + "create-issue": map[string]any{ + "invalid-issue-field": true, + }, + "create-discussion": map[string]any{ + "invalid-discussion-field": true, + }, + }, + } + + err := validateWithSchemaAndLocation(frontmatter, mainWorkflowSchema, "main workflow file", filePath) + if err == nil { + t.Fatal("expected schema validation error, got nil") + } + + errorText := err.Error() + wantSubstrings := []string{ + "/safe-outputs/create-issue", + "/safe-outputs/create-discussion", + "line 5, column 5", + "line 7, column 5", + } + for _, want := range wantSubstrings { + if !strings.Contains(errorText, want) { + t.Fatalf("expected error to contain %q, got:\n%s", want, errorText) + } + } +} diff --git a/pkg/parser/schema_compiler.go b/pkg/parser/schema_compiler.go index f50a3b3cfd..3c9c13bf03 100644 --- a/pkg/parser/schema_compiler.go +++ b/pkg/parser/schema_compiler.go @@ -244,7 +244,12 @@ func validateWithSchemaAndLocation(frontmatter map[string]any, schemaJSON, conte // If we have paths and frontmatter content, try to get precise locations if len(jsonPaths) > 0 && frontmatterContent != "" { - // Use the first error path for the primary error location + detailLines := make([]string, 0, len(jsonPaths)) + for _, pathInfo := range jsonPaths { + detailLines = append(detailLines, formatSchemaFailureDetail(pathInfo, frontmatterContent, frontmatterStart)) + } + + // Use the first error path for primary context rendering. primaryPath := jsonPaths[0] location := LocateJSONPathInYAMLWithAdditionalProperties(frontmatterContent, primaryPath.Path, primaryPath.Message) @@ -275,14 +280,12 @@ func validateWithSchemaAndLocation(frontmatter map[string]any, schemaJSON, conte adjustedContextLines = contextLines } - // Rewrite "additional properties not allowed" errors to be more friendly - // Also clean up oneOf jargon (e.g., "got string, want object") to plain English - message := rewriteAdditionalPropertiesError(cleanOneOfMessage(primaryPath.Message)) - - // Add schema-based suggestions - suggestions := generateSchemaBasedSuggestions(schemaJSON, primaryPath.Message, primaryPath.Path, frontmatterContent) - if suggestions != "" { - message = message + ". " + suggestions + // Include every schema failure with path + line + column. + message := "" + if len(detailLines) == 1 { + message = detailLines[0] + } else { + message = "Multiple schema validation failures:\n- " + strings.Join(detailLines, "\n- ") } // Create a compiler error with precise location information @@ -335,6 +338,24 @@ func validateWithSchemaAndLocation(frontmatter map[string]any, schemaJSON, conte return err } +func formatSchemaFailureDetail(pathInfo JSONPathInfo, frontmatterContent string, frontmatterStart int) string { + path := pathInfo.Path + if path == "" { + path = "/" + } + + location := LocateJSONPathInYAMLWithAdditionalProperties(frontmatterContent, pathInfo.Path, pathInfo.Message) + line := frontmatterStart + column := 1 + if location.Found { + line = location.Line + frontmatterStart - 1 + column = location.Column + } + + message := rewriteAdditionalPropertiesError(cleanOneOfMessage(pathInfo.Message)) + return fmt.Sprintf("at '%s' (line %d, column %d): %s", path, line, column, message) +} + // GetMainWorkflowSchema returns the embedded main workflow schema JSON func GetMainWorkflowSchema() string { return mainWorkflowSchema