Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions pkg/parser/json_path_locator.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func ExtractJSONPathFromValidationError(err error) []JSONPathInfo {
Message: cause.Error(),
Location: cause.InstanceLocation,
ErrorKind: cause.ErrorKind,
Causes: cause.Causes,
}
paths = append(paths, path)
}
Expand All @@ -49,10 +50,11 @@ func ExtractJSONPathFromValidationError(err error) []JSONPathInfo {

// JSONPathInfo holds information about a validation error and its path
type JSONPathInfo struct {
Path string // JSON path like "/tools/1" or "/age"
Message string // Error message
Location []string // Instance location from jsonschema (e.g., ["tools", "1"])
ErrorKind jsonschema.ErrorKind // Structural error kind; nil when built from a string-only context
Path string // JSON path like "/tools/1" or "/age"
Message string // Error message

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] JSONPathInfo.Causes leaks an internal jsonschema type ([]*jsonschema.ValidationError) into a struct that is otherwise expressed in domain terms (Path, Message, Location, ErrorKind).

💡 Suggestion

If JSONPathInfo is ever serialised, logged, or passed across package boundaries, callers will transitively depend on the jsonschema package. Consider whether additionalPropertyNamesFromCauses should live as a closure or helper that accepts the raw *jsonschema.ValidationError directly (bypassing JSONPathInfo) — keeping the struct focused on describing a single error rather than carrying a full cause tree.

This is a minor coupling concern, not a blocker, but worth calling out given JSONPathInfo is already used in several other places in the package.

@copilot please address this.

Location []string // Instance location from jsonschema (e.g., ["tools", "1"])
ErrorKind jsonschema.ErrorKind // Structural error kind; nil when built from a string-only context
Causes []*jsonschema.ValidationError // Nested validation errors from a composite error kind such as OneOf or Group
}

// convertInstanceLocationToJSONPath converts jsonschema InstanceLocation to JSON path string
Expand Down Expand Up @@ -105,24 +107,40 @@ func LocateJSONPathForPathInfo(yamlContent string, info JSONPathInfo) JSONPathLo
}

// additionalPropertyNamesFor returns the disallowed property names for a JSONPathInfo.
// It checks ErrorKind first (structural, no string parsing) and falls back to regex on Message.
// It checks ErrorKind first (structural, no string parsing) and falls back to regex on Message
// only when no structural error kind is available.
func additionalPropertyNamesFor(info JSONPathInfo) []string {
if info.ErrorKind != nil {
if ap, ok := info.ErrorKind.(*kind.AdditionalProperties); ok {
return ap.Properties
}
switch info.ErrorKind.(type) {
case *kind.OneOf, *kind.AnyOf, *kind.AllOf, *kind.Group:
// Composite errors may wrap an additional-properties leaf; regex fallback
// preserves the historical location behavior for these aggregate kinds.
return extractAdditionalPropertyNames(info.Message)
return additionalPropertyNamesFromCauses(info.Causes)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/diagnosing-bugs] Silent regression: when a composite error (Group/OneOf/etc.) has a non-empty Message but empty Causes, additionalPropertyNamesFromCauses returns nil where the old regex fallback would have returned property names.

💡 Details

Before this change, extractAdditionalPropertyNames(info.Message) was called for all composite kinds regardless of Causes. After this change, empty Causes silently returns nil — the location degrades to parent-path level with no diagnostic.

TestLocateJSONPathForPathInfoDoesNotRegexCompositeErrorKind documents this as intentional, but the function comment on additionalPropertyNamesFor still says "falls back to regex on Message / only when no structural error kind is available" — the comment now contradicts the code for composite kinds with parseable messages.

Consider adding a note in the function doc that composite-kind errors with empty Causes return nil by design, so future readers don't reinstate the regex fallback under the assumption it was an oversight.

@copilot please address this.

default:
return nil
}
}
return extractAdditionalPropertyNames(info.Message)
}

func additionalPropertyNamesFromCauses(causes []*jsonschema.ValidationError) []string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The new additionalPropertyNamesFromCauses function has no test for a nil cause element in the slice — despite the explicit nil guard in the code (if cause == nil { continue }).

💡 Suggested test
func TestAdditionalPropertyNamesFromCausesHandlesNilCause(t *testing.T) {
    info := JSONPathInfo{
        Path:      "/on",
        Message:   "irrelevant",
        ErrorKind: &kind.Group{},
        Causes: []*jsonschema.ValidationError{
            nil,
            {ErrorKind: &kind.AdditionalProperties{Properties: []string{"foobar"}}},
        },
    }
    location := LocateJSONPathForPathInfo(yamlContent, info)
    // expect foobar to be found, not a panic
    ...
}

The guard is good, but without a test the intention is invisible and the guard could be removed in a cleanup pass.

@copilot please address this.

var names []string
for _, cause := range causes {
if cause == nil {
continue
}
if ap, ok := cause.ErrorKind.(*kind.AdditionalProperties); ok {
names = append(names, ap.Properties...)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Harden: nil ErrorKind not guarded in additionalPropertyNamesFromCauses

In additionalPropertyNamesFromCauses, there is a nil-pointer guard for cause == nil, but no guard for cause.ErrorKind == nil. A *jsonschema.ValidationError with a nil ErrorKind would panic at the type-assertion cause.ErrorKind.(*kind.AdditionalProperties) on the next line.

Suggested fix:

if cause == nil || cause.ErrorKind == nil {
    continue
}

@copilot please address this.

continue
}
if len(cause.Causes) > 0 {
names = append(names, additionalPropertyNamesFromCauses(cause.Causes)...)
Comment on lines +133 to +138
}
}
return names
}

func findPathInYAMLLines(yamlContent string, pathSegments []PathSegment) JSONPathLocation {
lines := strings.Split(yamlContent, "\n")

Expand Down
40 changes: 36 additions & 4 deletions pkg/parser/json_path_locator_improvements_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"
"testing"

"github.com/santhosh-tekuri/jsonschema/v6"
"github.com/santhosh-tekuri/jsonschema/v6/kind"
)

Expand Down Expand Up @@ -179,16 +180,19 @@ func TestLocateJSONPathForPathInfoSkipsRegexForNonAdditionalPropertiesErrorKind(
}
}

func TestLocateJSONPathForPathInfoUsesRegexFallbackForGroupErrorKind(t *testing.T) {
func TestLocateJSONPathForPathInfoUsesGroupCausesForAdditionalProperties(t *testing.T) {
yamlContent := `on:
push:
branches: [main]
foobar: invalid`

info := JSONPathInfo{
Path: "/on",
Message: "at '/on': additional properties 'foobar' not allowed",
Message: "this message is intentionally not parseable",
ErrorKind: &kind.Group{},
Causes: []*jsonschema.ValidationError{
{ErrorKind: &kind.AdditionalProperties{Properties: []string{"foobar"}}},
},
}

location := LocateJSONPathForPathInfo(yamlContent, info)
Expand All @@ -200,16 +204,44 @@ func TestLocateJSONPathForPathInfoUsesRegexFallbackForGroupErrorKind(t *testing.
}
}

func TestLocateJSONPathForPathInfoUsesRegexFallbackForOneOfErrorKind(t *testing.T) {
func TestLocateJSONPathForPathInfoDoesNotRegexCompositeErrorKind(t *testing.T) {
yamlContent := `on:
push:
branches: [main]
foobar: invalid`

info := JSONPathInfo{
Path: "/on",
Message: "at '/on': additional properties 'foobar' not allowed",
ErrorKind: &kind.Group{},
}

location := LocateJSONPathForPathInfo(yamlContent, info)
expected := LocateJSONPathInYAML(yamlContent, "/on")
if location != expected {
t.Fatalf("expected fallback to LocateJSONPathInYAML location %+v, got %+v", expected, location)
}
}

func TestLocateJSONPathForPathInfoUsesNestedAdditionalPropertiesErrorKind(t *testing.T) {
yamlContent := `on:
push:
branches: [main]
foobar: invalid`

info := JSONPathInfo{
Path: "/on",
Message: "at '/on': 'oneOf' failed, none matched\n- at '/on': additional properties 'foobar' not allowed\n- at '/on': got object, want null",
Message: "this message is intentionally not parseable",
ErrorKind: &kind.OneOf{},
Causes: []*jsonschema.ValidationError{
{
ErrorKind: &kind.Group{},
Causes: []*jsonschema.ValidationError{
{ErrorKind: &kind.AdditionalProperties{Properties: []string{"foobar"}}},
},
},
{ErrorKind: &kind.Type{}},
},
}

location := LocateJSONPathForPathInfo(yamlContent, info)
Expand Down
1 change: 1 addition & 0 deletions pkg/parser/schema_compiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ func CompileSchema(schemaJSON, schemaURL string) (*jsonschema.Schema, error) {

// Create a new compiler

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Harden: AssertFormat() is now globally enforced — potentially breaking

compiler.AssertFormat() changes format keywords (e.g. date, email, uri) from advisory annotations to hard validation failures. Per the JSON Schema spec, format enforcement is opt-in, so adding this globally could silently break previously-valid workflow YAML files that use schemas with format annotations.

Consider adding a comment explaining the deliberate backward-compatibility trade-off, or audit existing schemas to confirm no valid values will now fail.

@copilot please address this.

compiler := jsonschema.NewCompiler()
compiler.AssertFormat()

// Parse the schema JSON first
var schemaDoc any
Expand Down
23 changes: 23 additions & 0 deletions pkg/parser/schema_compiler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,26 @@ func TestCompileSchema(t *testing.T) {
t.Fatal("Validate(invalid) returned nil error, want validation failure")
}
}

func TestCompileSchemaAssertsFormat(t *testing.T) {
t.Parallel()

schema, err := CompileSchema(`{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"expires": {"type": "string", "format": "date"}
}
}`, "http://example.com/format-schema.json")
if err != nil {
t.Fatalf("CompileSchema returned error: %v", err)
}

if err := schema.Validate(map[string]any{"expires": "2026-08-28"}); err != nil {
t.Fatalf("Validate(valid date) returned error: %v", err)
}

if err := schema.Validate(map[string]any{"expires": "not-a-date"}); err == nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] TestCompileSchemaAssertsFormat only tests format: "date" — it doesn't cover uri, which is the other format explicitly called out in the PR description as previously broken.

💡 Suggestion

Add a parallel sub-test (or a table-driven case) for format: "uri" so the fix is confirmed for the full set of formats mentioned in the PR body:

{"type": "string", "format": "uri"}
// valid: "(example.com/redacted)"
// invalid: "not a uri"

This mirrors the PR description's own claim: "This makes declared formats such as date and uri validation errors."

@copilot please address this.

t.Fatal("Validate(invalid date) returned nil error, want format validation failure")
}
}
Loading