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
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,22 @@ jobs:
name: coverage
path: coverage.out

schema:
name: JSON schema validation
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "stable"
cache: false

- name: Validate generated reports against schema
run: go test ./pkg/prmaven -run TestGeneratedJSONReportsValidateAgainstSchema -v

build:
name: Build (${{ matrix.goos }}/${{ matrix.goarch }})
runs-on: ubuntu-latest
Expand Down Expand Up @@ -210,6 +226,7 @@ jobs:
- test
- race
- coverage
- schema
- build
- smoke
steps:
Expand Down
3 changes: 3 additions & 0 deletions docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Jobs:
- `Go tests`: Linux, Windows, macOS, Go 1.22.x, and current stable Go.
- `Race detector`: `go test -race ./...` on Linux.
- `Coverage gate`: coverage profile with a 70% total coverage floor.
- `JSON schema validation`: generates demo JSON reports and validates them against `schema/prmaven-report.schema.json` through Go tests.
- `Build`: cross-platform binary builds for Linux, macOS, and Windows.
- `CLI smoke test`: exercises the compiled binary against demo fixtures.
- `All CI checks`: stable aggregate job for future branch protection.
Expand Down Expand Up @@ -99,6 +100,7 @@ Before opening a PR, contributors should run:

```bash
sh scripts/quality.sh
go test ./pkg/prmaven -run TestGeneratedJSONReportsValidateAgainstSchema -v
PRMAVEN_COVERAGE=1 sh scripts/test.sh
sh scripts/build.sh
```
Expand All @@ -107,6 +109,7 @@ On Windows PowerShell:

```powershell
.\scripts\quality.ps1
go test ./pkg/prmaven -run TestGeneratedJSONReportsValidateAgainstSchema -v
.\scripts\test.ps1 -Coverage
.\scripts\build.ps1
```
Expand Down
7 changes: 7 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ make coverage-check
make ci
```

Run the focused JSON schema validation:

```bash
go test ./pkg/prmaven -run TestGeneratedJSONReportsValidateAgainstSchema -v
```

## Test Layers

### Library Tests
Expand All @@ -64,6 +70,7 @@ Coverage includes:
- slash-separated path normalization for JSON output;
- reproduction command generation;
- JSON output contract;
- generated demo JSON validation against `schema/prmaven-report.schema.json`;
- text output snapshots;
- missing project error behavior.

Expand Down
2 changes: 1 addition & 1 deletion pkg/prmaven/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ func Analyze(options Options) (Report, error) {
return Report{}, err
}

var findings []Finding
findings := make([]Finding, 0)
for _, reportFile := range reportFiles {
reportFindings, err := parseReport(absRoot, moduleByPath, reportFile)
if err != nil {
Expand Down
240 changes: 231 additions & 9 deletions pkg/prmaven/schema_test.go
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
package prmaven

import (
"bytes"
"encoding/json"
"fmt"
"math"
"os"
"reflect"
"strings"
"testing"
)

func TestReportSchemaTracksJSONContractFields(t *testing.T) {
data, err := os.ReadFile("../../schema/prmaven-report.schema.json")
if err != nil {
t.Fatal(err)
}

var schema map[string]any
if err := json.Unmarshal(data, &schema); err != nil {
t.Fatal(err)
}
schema := loadReportSchema(t)

assertSchemaProperties(t, schema, jsonFields(reflect.TypeOf(Report{})), "properties")
assertSchemaRequired(t, schema, requiredJSONFields(reflect.TypeOf(Report{})), "required")
Expand All @@ -30,6 +25,54 @@ func TestReportSchemaTracksJSONContractFields(t *testing.T) {
assertSchemaRequired(t, defs, requiredJSONFields(reflect.TypeOf(Finding{})), "finding.required")
}

func TestGeneratedJSONReportsValidateAgainstSchema(t *testing.T) {
schema := loadReportSchema(t)

tests := []struct {
name string
projectDir string
}{
{name: "multi-module failure demo", projectDir: "../../demo/multi-module-failure"},
{name: "no-failure demo", projectDir: "../../demo/no-failure"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
report, err := Analyze(Options{ProjectDir: tt.projectDir})
if err != nil {
t.Fatal(err)
}

var output bytes.Buffer
if err := WriteJSON(&output, report); err != nil {
t.Fatal(err)
}

var generated any
if err := json.Unmarshal(output.Bytes(), &generated); err != nil {
t.Fatalf("generated JSON is not parseable: %v", err)
}

validateSchemaValue(t, schema, schema, generated, "$")
})
}
}

func loadReportSchema(t *testing.T) map[string]any {
t.Helper()

data, err := os.ReadFile("../../schema/prmaven-report.schema.json")
if err != nil {
t.Fatal(err)
}

var schema map[string]any
if err := json.Unmarshal(data, &schema); err != nil {
t.Fatal(err)
}
return schema
}

func assertSchemaProperties(t *testing.T, schema map[string]any, expected []string, path string) {
t.Helper()

Expand Down Expand Up @@ -137,3 +180,182 @@ func jsonFieldName(field reflect.StructField) string {
}
return strings.Split(tag, ",")[0]
}

func validateSchemaValue(t *testing.T, root map[string]any, schema map[string]any, value any, path string) {
t.Helper()

if ref, ok := schema["$ref"].(string); ok {
validateSchemaValue(t, root, resolveSchemaRef(t, root, ref), value, path)
return
}

if typ, ok := schema["type"].(string); ok {
validateSchemaType(t, typ, value, path)
}

if enumValues, ok := schema["enum"].([]any); ok {
validateSchemaEnum(t, enumValues, value, path)
}

switch schema["type"] {
case "object":
validateSchemaObject(t, root, schema, value, path)
case "array":
validateSchemaArray(t, root, schema, value, path)
case "integer":
validateSchemaMinimum(t, schema, value, path)
}
}

func validateSchemaType(t *testing.T, typ string, value any, path string) {
t.Helper()

ok := false
switch typ {
case "object":
_, ok = value.(map[string]any)
case "array":
_, ok = value.([]any)
case "string":
_, ok = value.(string)
case "integer":
number, isNumber := value.(float64)
ok = isNumber && number == math.Trunc(number)
default:
t.Fatalf("%s uses unsupported schema type %q in test validator", path, typ)
}
if !ok {
t.Fatalf("%s is %T, want schema type %q", path, value, typ)
}
}

func validateSchemaEnum(t *testing.T, enumValues []any, value any, path string) {
t.Helper()

for _, enumValue := range enumValues {
if enumValue == value {
return
}
}
t.Fatalf("%s value %q is not allowed by enum %v", path, value, enumValues)
}

func validateSchemaObject(t *testing.T, root map[string]any, schema map[string]any, value any, path string) {
t.Helper()

object, ok := value.(map[string]any)
if !ok {
t.Fatalf("%s is %T, want object", path, value)
}

for _, name := range schemaStringArray(t, schema, "required", path) {
if _, ok := object[name]; !ok {
t.Fatalf("%s missing required property %q", path, name)
}
}

propertiesValue, ok := schema["properties"]
if !ok {
return
}
properties, ok := propertiesValue.(map[string]any)
if !ok {
t.Fatalf("%s.properties is %T, want object", path, propertiesValue)
}

for name, childSchemaValue := range properties {
childValue, ok := object[name]
if !ok {
continue
}
childSchema, ok := childSchemaValue.(map[string]any)
if !ok {
t.Fatalf("%s.properties.%s is %T, want object", path, name, childSchemaValue)
}
validateSchemaValue(t, root, childSchema, childValue, path+"."+name)
}
}

func validateSchemaArray(t *testing.T, root map[string]any, schema map[string]any, value any, path string) {
t.Helper()

values, ok := value.([]any)
if !ok {
t.Fatalf("%s is %T, want array", path, value)
}

itemsValue, ok := schema["items"]
if !ok {
return
}
itemsSchema, ok := itemsValue.(map[string]any)
if !ok {
t.Fatalf("%s.items is %T, want object", path, itemsValue)
}

for i, childValue := range values {
validateSchemaValue(t, root, itemsSchema, childValue, fmt.Sprintf("%s[%d]", path, i))
}
}

func validateSchemaMinimum(t *testing.T, schema map[string]any, value any, path string) {
t.Helper()

minimum, ok := schema["minimum"].(float64)
if !ok {
return
}
number, ok := value.(float64)
if !ok {
t.Fatalf("%s is %T, want number", path, value)
}
if number < minimum {
t.Fatalf("%s value %v is below schema minimum %v", path, number, minimum)
}
}

func resolveSchemaRef(t *testing.T, root map[string]any, ref string) map[string]any {
t.Helper()

if !strings.HasPrefix(ref, "#/") {
t.Fatalf("unsupported schema ref %q", ref)
}
current := any(root)
for _, part := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") {
object, ok := current.(map[string]any)
if !ok {
t.Fatalf("schema ref %q parent is %T, want object", ref, current)
}
current, ok = object[part]
if !ok {
t.Fatalf("schema ref %q missing part %q", ref, part)
}
}
object, ok := current.(map[string]any)
if !ok {
t.Fatalf("schema ref %q resolves to %T, want object", ref, current)
}
return object
}

func schemaStringArray(t *testing.T, schema map[string]any, key, path string) []string {
t.Helper()

value, ok := schema[key]
if !ok {
return nil
}
values, ok := value.([]any)
if !ok {
t.Fatalf("%s.%s is %T, want array", path, key, value)
}
result := make([]string, 0, len(values))
for _, item := range values {
name, ok := item.(string)
if !ok {
t.Fatalf("%s.%s contains %T, want string", path, key, item)
}
result = append(result, name)
}
return result
}
Loading