Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ on the default branch using [git-cliff](https://git-cliff.org/).

### Bug Fixes

- **test:** Restore pointeredResult passthrough test syntax [c3289e5](https://github.com/platformrelay/kollect-render/commit/c3289e5f272b3ead96d33b89bcb6dde0c234b6de)

- **sonar:** Exclude tests from sources so CPD clears [094fc8e](https://github.com/platformrelay/kollect-render/commit/094fc8ee9a97f1647fee02d29d3036b25930cd4a)

- **ci:** Disable Codecov coverage file search [1107268](https://github.com/platformrelay/kollect-render/commit/110726825c04ae240bc2585b26db5c387ec61885)
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ module github.com/platformrelay/kollect-render
go 1.26.5

require (
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
golang.org/x/mod v0.37.0
golang.org/x/text v0.39.0
gopkg.in/yaml.v3 v3.0.1
)

require golang.org/x/mod v0.29.0 // indirect
12 changes: 8 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
31 changes: 31 additions & 0 deletions hack/test/jsonschema_v6_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# E6-S03: go.mod must require jsonschema/v6 only (no leftover v5).
# Run: bash hack/test/jsonschema_v6_test.sh
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
GOMOD="${ROOT}/go.mod"

fail=0
ok() { echo "ok: $*"; }
bad() { echo "FAIL: $*" >&2; fail=1; }

[[ -f "${GOMOD}" ]] || { echo "FAIL: go.mod missing" >&2; exit 1; }

if grep -Eq 'github.com/santhosh-tekuri/jsonschema/v6 v6\.' "${GOMOD}"; then
ok "go.mod requires jsonschema/v6"
else
bad "go.mod does not require github.com/santhosh-tekuri/jsonschema/v6"
fi

if grep -Eq 'github.com/santhosh-tekuri/jsonschema/v5' "${GOMOD}"; then
bad "go.mod still mentions jsonschema/v5 — dual-require is the broken Renovate shape"
else
ok "go.mod has no jsonschema/v5"
fi

if [[ "${fail}" -ne 0 ]]; then
echo "jsonschema_v6_test: RED" >&2
exit 1
fi
echo "jsonschema_v6_test: GREEN"
104 changes: 104 additions & 0 deletions internal/validate/v6_pointer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package validate

import (
"errors"
"testing"

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

func TestValidationMessageNil(t *testing.T) {
t.Parallel()
if validationMessage(nil) != "validation failed" {
t.Fatal("nil error")
}
}

func TestPointeredResultPassesThroughNonValidationError(t *testing.T) {
t.Parallel()
in := errors.New("not a schema error")
if got := pointeredResult(in); !errors.Is(got, in) {
t.Fatalf("got %v, want passthrough", got)
}
}

func TestCollectLeavesNilIsNoop(t *testing.T) {
t.Parallel()
res := &Result{}
collectLeaves(nil, res)
if len(res.Errors) != 0 {
t.Fatalf("errors = %+v", res.Errors)
}
}

func TestCollectLeavesNilKindUsesErrorString(t *testing.T) {
t.Parallel()
res := &Result{}
collectLeaves(&jsonschema.ValidationError{ErrorKind: nil}, res)
if len(res.Errors) != 1 || res.Errors[0].Message != "validation failed" {
t.Fatalf("errors = %+v", res.Errors)
}
}

func TestPropertyFromKindEdges(t *testing.T) {
t.Parallel()
if _, ok := propertyFromKind(nil); ok {
t.Fatal("nil kind should not yield a property")
}
if _, ok := propertyFromKind(&kind.Required{Missing: []string{"a", "b"}}); ok {
t.Fatal("multi-missing required should not pick a single property")
}
if _, ok := propertyFromKind(&kind.AdditionalProperties{Properties: []string{"a", "b"}}); ok {
t.Fatal("multi additional properties should not pick a single property")
}
if _, ok := propertyFromKind(&kind.Type{Got: "string", Want: []string{"number"}}); ok {
t.Fatal("type mismatch is not a property name")
}
got, ok := propertyFromKind(&kind.Required{Missing: []string{"schemaVersion"}})
if !ok || got != "schemaVersion" {
t.Fatalf("required single = %q ok=%v", got, ok)
}
got, ok = propertyFromKind(&kind.AdditionalProperties{Properties: []string{"secretLeak"}})
if !ok || got != "secretLeak" {
t.Fatalf("additional single = %q ok=%v", got, ok)
}
}

func TestJSONPointerEscapes(t *testing.T) {
t.Parallel()
if jsonPointer(nil) != "" {
t.Fatal("empty tokens")
}
got := jsonPointer([]string{"a/b", "c~d"})
if got != "/a~1b/c~0d" {
t.Fatalf("got %q", got)
}
}

func TestJoinPointerRootAndNested(t *testing.T) {
t.Parallel()
if joinPointer("", "x") != "/x" {
t.Fatal("root")
}
if joinPointer("/metadata", "enviromentId") != "/metadata/enviromentId" {
t.Fatal("nested")
}
}

func TestPointeredResultFallbackWhenNoLeaves(t *testing.T) {
t.Parallel()
// A ValidationError with a cause that is nil is skipped; the parent then
// has Causes != 0 so collectLeaves adds nothing, and pointeredResult
// must still emit one error (the empty-leaves fallback).
err := pointeredResult(&jsonschema.ValidationError{
Causes: []*jsonschema.ValidationError{nil},
})
var res *Result
if !errors.As(err, &res) || len(res.Errors) != 1 {
t.Fatalf("got %v", err)
}
if res.Errors[0].Message != "validation failed" {
t.Fatalf("fallback message = %q", res.Errors[0].Message)
}
}
95 changes: 49 additions & 46 deletions internal/validate/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ import (
"sync"

"github.com/platformrelay/kollect-render/schema"
"github.com/santhosh-tekuri/jsonschema/v5"
"github.com/santhosh-tekuri/jsonschema/v6"
"github.com/santhosh-tekuri/jsonschema/v6/kind"
"golang.org/x/text/language"
"golang.org/x/text/message"
"gopkg.in/yaml.v3"
)

const schemaURL = "https://github.com/platformrelay/kollect-render/schema/inventory-v0.schema.json"

var schemaPrinter = message.NewPrinter(language.English)

// Error is a single schema violation located by JSON Pointer.
type Error struct {
Pointer string
Expand Down Expand Up @@ -107,8 +112,13 @@ func decodeDocument(raw []byte) (any, error) {
func loadSchema() (*jsonschema.Schema, error) {
schemaOnce.Do(func() {
c := jsonschema.NewCompiler()
c.AssertFormat = true
if err := c.AddResource(schemaURL, bytes.NewReader(schema.InventoryV0)); err != nil {
c.AssertFormat()
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schema.InventoryV0))
if err != nil {
schemaErr = fmt.Errorf("decode schema: %w", err)
return
}
if err := c.AddResource(schemaURL, doc); err != nil {
schemaErr = fmt.Errorf("add schema resource: %w", err)
return
}
Expand All @@ -128,8 +138,7 @@ func pointeredResult(err error) error {
res := &Result{}
collectLeaves(ve, res)
if len(res.Errors) == 0 {
msg := ve.Message
res.Errors = append(res.Errors, Error{Pointer: pointerFor(ve.InstanceLocation, msg), Message: msg})
res.Errors = append(res.Errors, Error{Pointer: pointerFor(ve), Message: validationMessage(ve)})
}
return res
}
Expand All @@ -139,55 +148,59 @@ func collectLeaves(ve *jsonschema.ValidationError, res *Result) {
return
}
if len(ve.Causes) == 0 {
msg := ve.Message
if msg == "" {
msg = ve.Error()
}
ptr := pointerFor(ve.InstanceLocation, msg)
res.Errors = append(res.Errors, Error{Pointer: ptr, Message: msg})
res.Errors = append(res.Errors, Error{Pointer: pointerFor(ve), Message: validationMessage(ve)})
return
}
for _, cause := range ve.Causes {
collectLeaves(cause, res)
}
}

func pointerFor(loc, msg string) string {
base := normalizePointer(loc)
if prop, ok := propertyFromMessage(msg); ok {
func validationMessage(ve *jsonschema.ValidationError) string {
if ve == nil {
return "validation failed"
}
if ve.ErrorKind != nil {
return ve.ErrorKind.LocalizedString(schemaPrinter)
}
return "validation failed"
}

func pointerFor(ve *jsonschema.ValidationError) string {
base := jsonPointer(ve.InstanceLocation)
if prop, ok := propertyFromKind(ve.ErrorKind); ok {
return joinPointer(base, prop)
}
return base
}

func propertyFromMessage(msg string) (string, bool) {
// jsonschema v5 often names the offending key in the message while
// InstanceLocation points at the parent object (or the document root).
const (
additionalPrefix = "additionalProperties '"
missingPrefix = "missing properties: '"
)
switch {
case strings.Contains(msg, additionalPrefix):
return extractQuotedAfter(msg, additionalPrefix)
case strings.Contains(msg, missingPrefix):
return extractQuotedAfter(msg, missingPrefix)
default:
return "", false
func jsonPointer(tokens []string) string {
if len(tokens) == 0 {
return ""
}
var b strings.Builder
for _, tok := range tokens {
b.WriteByte('/')
b.WriteString(escapePointerToken(tok))
}
return b.String()
}

func extractQuotedAfter(msg, prefix string) (string, bool) {
i := strings.Index(msg, prefix)
if i < 0 {
func propertyFromKind(k jsonschema.ErrorKind) (string, bool) {
if k == nil {
return "", false
}
rest := msg[i+len(prefix):]
end := strings.IndexByte(rest, '\'')
if end <= 0 {
return "", false
switch typed := k.(type) {
case *kind.Required:
if len(typed.Missing) == 1 {
return typed.Missing[0], true
}
case *kind.AdditionalProperties:
if len(typed.Properties) == 1 {
return typed.Properties[0], true
}
}
return rest[:end], true
return "", false
}

func joinPointer(base, prop string) string {
Expand All @@ -203,13 +216,3 @@ func escapePointerToken(s string) string {
s = strings.ReplaceAll(s, "/", "~1")
return s
}

func normalizePointer(loc string) string {
if loc == "" {
return ""
}
if strings.HasPrefix(loc, "/") {
return loc
}
return "/" + loc
}
Loading