From d4e074a4949a9c8956f19fc7f0dd08e1df26ca27 Mon Sep 17 00:00:00 2001 From: "songtianyi.theo" Date: Fri, 3 Jul 2026 02:09:17 +0800 Subject: [PATCH] feat: validate svglide stage artifacts --- internal/svglide/schema.go | 277 ++++++++++++++++++++++++++++++++ internal/svglide/schema_test.go | 120 ++++++++++++++ internal/svglide/stage.go | 4 + 3 files changed, 401 insertions(+) create mode 100644 internal/svglide/schema.go create mode 100644 internal/svglide/schema_test.go diff --git a/internal/svglide/schema.go b/internal/svglide/schema.go new file mode 100644 index 000000000..e3b592324 --- /dev/null +++ b/internal/svglide/schema.go @@ -0,0 +1,277 @@ +package svglide + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math/big" + "path/filepath" + "regexp" + "strings" +) + +type liteJSONSchema struct { + Type string `json:"type"` + Required []string `json:"required"` + AdditionalProperties *bool `json:"additionalProperties"` + Properties map[string]liteJSONSchema `json:"properties"` + Items *liteJSONSchema `json:"items"` + MinItems *int `json:"minItems"` + Enum []string `json:"enum"` + Pattern string `json:"pattern"` +} + +var stageOutputSchemaPaths = map[string]string{ + "request/request.json": "schemas/request.schema.json", + "request/source_manifest.json": "schemas/source_manifest.schema.json", + "research/sources.json": "schemas/sources.schema.json", + "brief/design_brief.json": "schemas/design_brief.schema.json", + "brief/visual_system.json": "schemas/visual_system.schema.json", + "outline/deck.json": "schemas/deck.schema.json", + "content/slide_content.json": "schemas/slide_content.schema.json", + "assets/assets_plan.json": "schemas/assets_plan.schema.json", + "receipts/lint.json": "schemas/lint.schema.json", + "receipts/preview.json": "schemas/preview.schema.json", +} + +func ValidateStageOutputs(root string) error { + safeRoot, run, err := readRun(root) + if err != nil { + return err + } + stage, err := currentStage(run) + if err != nil { + return err + } + for _, output := range stage.Outputs { + if hasGlobMeta(output) || strings.ToLower(filepath.Ext(output)) != ".json" { + continue + } + schemaPath, ok := stageOutputSchemaPaths[output] + if !ok { + continue + } + if err := validateStageOutputSchema(safeRoot, output, schemaPath); err != nil { + return err + } + } + return nil +} + +func validateStageOutputSchema(safeRoot, artifactPath, schemaPath string) error { + artifactRaw, err := readRunRegularArtifact(safeRoot, artifactPath) + if err != nil { + return fmt.Errorf("%s: read artifact: %w", artifactPath, err) + } + schemaRaw, err := readRunRegularArtifact(safeRoot, schemaPath) + if err != nil { + return fmt.Errorf("%s: read schema %s: %w", artifactPath, schemaPath, err) + } + schema, err := decodeLiteJSONSchema(schemaRaw) + if err != nil { + return fmt.Errorf("%s: schema %s: %w", artifactPath, schemaPath, err) + } + value, err := decodeJSONValue(artifactRaw) + if err != nil { + return fmt.Errorf("%s: invalid JSON: %w", artifactPath, err) + } + if err := validateJSONValue(schema, value, ""); err != nil { + return fmt.Errorf("%s: %w", artifactPath, err) + } + return nil +} + +func decodeLiteJSONSchema(raw []byte) (liteJSONSchema, error) { + var schema liteJSONSchema + decoder := json.NewDecoder(bytes.NewReader(raw)) + if err := decoder.Decode(&schema); err != nil { + return liteJSONSchema{}, fmt.Errorf("invalid JSON: %w", err) + } + if err := rejectTrailingJSON(decoder); err != nil { + return liteJSONSchema{}, err + } + return schema, nil +} + +func decodeJSONValue(raw []byte) (any, error) { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + if err := rejectTrailingJSON(decoder); err != nil { + return nil, err + } + return value, nil +} + +func rejectTrailingJSON(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("contains trailing JSON value") + } + return err + } + return nil +} + +func validateJSONValue(schema liteJSONSchema, value any, fieldPath string) error { + switch schema.Type { + case "": + return nil + case "object": + return validateJSONObject(schema, value, fieldPath) + case "array": + return validateJSONArray(schema, value, fieldPath) + case "string": + return validateJSONString(schema, value, fieldPath) + case "integer": + if !isJSONInteger(value) { + return fmt.Errorf("field %s expected integer, got %s", displayFieldPath(fieldPath), jsonValueType(value)) + } + return nil + case "boolean": + if _, ok := value.(bool); !ok { + return fmt.Errorf("field %s expected boolean, got %s", displayFieldPath(fieldPath), jsonValueType(value)) + } + return nil + default: + return fmt.Errorf("field %s uses unsupported schema type %q", displayFieldPath(fieldPath), schema.Type) + } +} + +func validateJSONObject(schema liteJSONSchema, value any, fieldPath string) error { + object, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("field %s expected object, got %s", displayFieldPath(fieldPath), jsonValueType(value)) + } + for _, required := range schema.Required { + if _, ok := object[required]; !ok { + return fmt.Errorf("field %s is required", joinFieldPath(fieldPath, required)) + } + } + if schema.AdditionalProperties != nil && !*schema.AdditionalProperties { + for name := range object { + if _, ok := schema.Properties[name]; !ok { + return fmt.Errorf("field %s is not allowed by additionalProperties:false", joinFieldPath(fieldPath, name)) + } + } + } + for name, propertySchema := range schema.Properties { + child, ok := object[name] + if !ok { + continue + } + if err := validateJSONValue(propertySchema, child, joinFieldPath(fieldPath, name)); err != nil { + return err + } + } + return nil +} + +func validateJSONArray(schema liteJSONSchema, value any, fieldPath string) error { + array, ok := value.([]any) + if !ok { + return fmt.Errorf("field %s expected array, got %s", displayFieldPath(fieldPath), jsonValueType(value)) + } + if schema.MinItems != nil && len(array) < *schema.MinItems { + return fmt.Errorf("field %s has %d items, want minItems %d", displayFieldPath(fieldPath), len(array), *schema.MinItems) + } + if schema.Items == nil { + return nil + } + for i, item := range array { + if err := validateJSONValue(*schema.Items, item, joinArrayFieldPath(fieldPath, i)); err != nil { + return err + } + } + return nil +} + +func validateJSONString(schema liteJSONSchema, value any, fieldPath string) error { + text, ok := value.(string) + if !ok { + return fmt.Errorf("field %s expected string, got %s", displayFieldPath(fieldPath), jsonValueType(value)) + } + if len(schema.Enum) > 0 { + for _, allowed := range schema.Enum { + if text == allowed { + return nil + } + } + return fmt.Errorf("field %s value %q is not in enum %v", displayFieldPath(fieldPath), text, schema.Enum) + } + if schema.Pattern != "" { + matched, err := regexp.MatchString(schema.Pattern, text) + if err != nil { + return fmt.Errorf("field %s has invalid pattern %q: %w", displayFieldPath(fieldPath), schema.Pattern, err) + } + if !matched { + return fmt.Errorf("field %s value %q does not match pattern %q", displayFieldPath(fieldPath), text, schema.Pattern) + } + } + return nil +} + +func isJSONInteger(value any) bool { + switch typed := value.(type) { + case json.Number: + return isCanonicalJSONInteger(typed.String()) + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return true + default: + return false + } +} + +func isCanonicalJSONInteger(value string) bool { + if value == "" || strings.ContainsAny(value, ".eE") { + return false + } + var parsed big.Int + _, ok := parsed.SetString(value, 10) + return ok +} + +func jsonValueType(value any) string { + switch value.(type) { + case nil: + return "null" + case map[string]any: + return "object" + case []any: + return "array" + case string: + return "string" + case json.Number, float64: + return "number" + case bool: + return "boolean" + default: + return fmt.Sprintf("%T", value) + } +} + +func joinFieldPath(parent, name string) string { + if parent == "" { + return name + } + return parent + "." + name +} + +func joinArrayFieldPath(parent string, index int) string { + if parent == "" { + return fmt.Sprintf("[%d]", index) + } + return fmt.Sprintf("%s[%d]", parent, index) +} + +func displayFieldPath(path string) string { + if path == "" { + return "$" + } + return path +} diff --git a/internal/svglide/schema_test.go b/internal/svglide/schema_test.go new file mode 100644 index 000000000..69b491d6f --- /dev/null +++ b/internal/svglide/schema_test.go @@ -0,0 +1,120 @@ +package svglide + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateStageOutputsRejectsMissingRequiredField(t *testing.T) { + initStatusTestRun(t) + if err := os.WriteFile(filepath.Join("demo", "request", "request.json"), []byte(`{"title":"Demo"}`), 0o644); err != nil { + t.Fatal(err) + } + + err := ValidateStageOutputs("demo") + if err == nil { + t.Fatal("expected schema validation error") + } + if !strings.Contains(err.Error(), "request/request.json") || !strings.Contains(err.Error(), "input") { + t.Fatalf("error = %v, want path and missing field", err) + } +} + +func TestValidateStageOutputsAcceptsCurrentRequestArtifacts(t *testing.T) { + initStatusTestRun(t) + + if err := ValidateStageOutputs("demo"); err != nil { + t.Fatal(err) + } +} + +func TestValidateStageOutputsRejectsInvalidValidatePreviewRepairReceipts(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) + path string + }{ + { + name: "lint", + setup: func(t *testing.T) { + t.Helper() + if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"failed"}`), 0o644); err != nil { + t.Fatal(err) + } + }, + path: "receipts/lint.json", + }, + { + name: "preview", + setup: func(t *testing.T) { + t.Helper() + if err := os.WriteFile(filepath.Join("demo", "receipts", "lint.json"), []byte(`{"status":"passed","issues":[]}`), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join("demo", "receipts", "preview.json"), []byte(`{"status":"passed","slides":[{"path":"slides/01.svg","rendered":"yes"}]}`), 0o644); err != nil { + t.Fatal(err) + } + }, + path: "receipts/preview.json", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + initStatusTestRun(t) + setCurrentStageForStatusTest(t, StageValidatePreviewRepair) + tt.setup(t) + + err := ValidateStageOutputs("demo") + if err == nil { + t.Fatal("expected schema validation error") + } + if !strings.Contains(err.Error(), tt.path) { + t.Fatalf("error = %v, want path %s", err, tt.path) + } + }) + } +} + +func TestValidateStageOutputsRejectsNonCanonicalIntegers(t *testing.T) { + for _, pages := range []string{"8.0", "8e0", "0.99999999999999999"} { + t.Run(pages, func(t *testing.T) { + initStatusTestRun(t) + raw := `{"title":"Demo","input":"source.md","pages":` + pages + `}` + if err := os.WriteFile(filepath.Join("demo", "request", "request.json"), []byte(raw), 0o644); err != nil { + t.Fatal(err) + } + + err := ValidateStageOutputs("demo") + if err == nil { + t.Fatal("expected schema validation error") + } + if !strings.Contains(err.Error(), "request/request.json") || !strings.Contains(err.Error(), "pages") { + t.Fatalf("error = %v, want path and pages field", err) + } + }) + } +} + +func TestCompleteCurrentStageRejectsInvalidCurrentStageOutputSchema(t *testing.T) { + initStatusTestRun(t) + if err := os.WriteFile(filepath.Join("demo", "request", "source_manifest.json"), []byte(`{"sources":[{"path":"source.md","type":"remote"}]}`), 0o644); err != nil { + t.Fatal(err) + } + + _, err := CompleteCurrentStage("demo") + if err == nil { + t.Fatal("expected schema validation error") + } + run := readStatusTestRunFile(t) + if run.CurrentStage != StageRequest { + t.Fatalf("run.CurrentStage = %q, want %q", run.CurrentStage, StageRequest) + } + if got := stageStatus(t, run, StageRequest); got == StatusDone { + t.Fatalf("request stage status = %q, want not %q", got, StatusDone) + } + if _, statErr := os.Stat(filepath.Join("demo", "receipts", "request.json")); !os.IsNotExist(statErr) { + t.Fatalf("receipt should not be written, stat err = %v", statErr) + } +} diff --git a/internal/svglide/stage.go b/internal/svglide/stage.go index bc84d1184..ee749a91a 100644 --- a/internal/svglide/stage.go +++ b/internal/svglide/stage.go @@ -31,6 +31,10 @@ func CompleteCurrentStage(root string) (StatusReport, error) { return StatusReport{}, fmt.Errorf("current stage %q missing outputs: %s", stage.Name, strings.Join(missingOutputs, ", ")) } + if err := ValidateStageOutputs(root); err != nil { + return StatusReport{}, err + } + if err := writeStageReceipt(safeRoot, StageReceipt{ Stage: stage.Name, Status: StatusDone,