feat: validate svglide stage artifacts

This commit is contained in:
songtianyi.theo
2026-07-03 02:09:17 +08:00
parent 15e7ab8b66
commit d4e074a494
3 changed files with 401 additions and 0 deletions

277
internal/svglide/schema.go Normal file
View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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,