From 3f4fd230b95054545eebb82730da66b3b426dee6 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 26 May 2026 07:36:10 -0500 Subject: [PATCH] Implemented support for loading configuration from nested subdirectories --- docs/config.md | 22 ++- docs/internal/adapters.md | 6 +- internal/profile/filesystem_repository.go | 102 ++++++++++-- internal/profile/repository_test.go | 77 +++++++++ internal/promptdef/filesystem_repository.go | 91 +++++++++-- internal/promptdef/repository_test.go | 158 +++++++++++++++++++ internal/validate/standard_validator_test.go | 43 +++++ 7 files changed, 468 insertions(+), 31 deletions(-) diff --git a/docs/config.md b/docs/config.md index 873fd13..935742c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -63,7 +63,9 @@ Validation behavior: ## Prompt Definition Files -Prompt definitions are YAML files in `prompt_dir`. +Prompt definitions are YAML files anywhere under `prompt_dir`, including nested subdirectories. + +Subdirectories are organizational only. Callers still select prompts by the YAML `id`, not by file path. For example, `prompts/dnd/recap.yaml` may still declare `id: dnd.recap`, and callers use `--prompt dnd.recap`. Example: @@ -122,7 +124,9 @@ Message rules: - Repeated roles are allowed. - `content_file` is resolved relative to the prompt YAML file location. +- Nested prompt files keep the same relative `content_file` behavior; `./recap.user.md` next to `dnd/recap.yaml` resolves from `dnd/`. - Prompt decoding is strict; unknown YAML fields are rejected. +- Duplicate prompt IDs are invalid. If multiple files declare the requested prompt ID, Scriptorium fails instead of choosing one. `output` fields: @@ -138,7 +142,9 @@ Repair behavior boundary: ## Profile Definition Files -Execution profiles are YAML files in `profile_dir`. +Execution profiles are YAML files anywhere under `profile_dir`, including nested subdirectories. + +Subdirectories are organizational only. Callers still select profiles by the YAML `id`, not by file path. For example, `profiles/local/local-quality.yaml` may still declare `id: local-quality`, and callers use `--profile local-quality`. Example: @@ -171,6 +177,7 @@ Profile rules: - Profile decoding is strict; unknown YAML fields are rejected. - Raw `api_key` is rejected; use `api_key_env`. - If `api_key_env` is set, that environment variable must be set when preparing/running. +- Duplicate profile IDs are invalid. If multiple files declare the requested profile ID, Scriptorium fails instead of choosing one. Current outbound request behavior: @@ -184,8 +191,9 @@ Schemas are JSON files, typically in `schema_dir`. Rules: - `output.validation_mode: json_schema` requires `output.schema_path`. -- Relative `schema_path` values resolve from `schema_dir`. +- Relative `schema_path` values resolve from `schema_dir`, including explicit nested paths such as `dnd/structured_events.schema.json`. - Absolute `schema_path` values are used directly. +- Scriptorium does not recursively search schemas by basename; nested schemas must be referenced by their relative path. - Missing or invalid schema documents cause runtime validation errors. - Invalid generated JSON causes validation status `failed` (not a runtime error). @@ -207,6 +215,14 @@ Supported artifact reference types for request inputs are `file` and `inline`. - Render example script: `examples/render-markdown-summary.sh` - HTTP request example: `examples/http-run.json` +Example organizational layout: + +```text +examples/prompts/dnd/recap.yaml +examples/profiles/local/local-quality.yaml +examples/schemas/dnd/structured_events.schema.json +``` + ## Integration References - [Inbound HTTP contract](integrations/http-api.md) diff --git a/docs/internal/adapters.md b/docs/internal/adapters.md index b574b8e..785505e 100644 --- a/docs/internal/adapters.md +++ b/docs/internal/adapters.md @@ -30,7 +30,7 @@ HTTP adapter: Filesystem repositories: -- Input: prompt/profile YAML files. +- Input: prompt/profile YAML files under configured directories. - Output: normalized domain definitions/profiles or typed errors. Artifact reader: @@ -79,6 +79,9 @@ Execution profile/request settings used through runner: Strict decoding and input checks: - config/prompt/profile loaders reject unknown YAML fields. +- prompt/profile repositories scan nested subdirectories recursively. +- prompt/profile lookup uses YAML `id` values; subdirectory paths are organizational only. +- duplicate prompt/profile IDs are invalid and fail instead of using first-match behavior. - HTTP DTO decoder rejects unknown JSON fields. - raw API key payload fields are rejected by strict decoding in profile/http paths. @@ -97,6 +100,7 @@ Validator: - `basic`, `json`, `json_schema` content failures return `ValidationFailed` results. - schema load/compile/path failures are runtime errors. +- schema lookup uses explicit `schema_path` values relative to `schema_dir`; it does not recursively search by basename. HTTP error mapping: diff --git a/internal/profile/filesystem_repository.go b/internal/profile/filesystem_repository.go index fb7f650..86919e2 100644 --- a/internal/profile/filesystem_repository.go +++ b/internal/profile/filesystem_repository.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" @@ -33,40 +34,39 @@ func (r *filesystemRepository) GetProfile(ctx context.Context, id string) (*doma return nil, fmt.Errorf("%w: profile id is required", ErrInvalidProfile) } - files, err := os.ReadDir(r.dir) + files, err := r.yamlFiles(ctx) if err != nil { return nil, fmt.Errorf("failed to read profile directory: %w", err) } - for _, file := range files { + var matches []profileMatch + for _, fullPath := range files { select { case <-ctx.Done(): return nil, ctx.Err() default: } - if file.IsDir() || (!strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml")) { - continue - } - - fullPath := filepath.Join(r.dir, file.Name()) + relPath := r.relativePath(fullPath) + fileMatch := profileIDFromFileName(filepath.Base(fullPath)) == id data, err := os.ReadFile(fullPath) if err != nil { - return nil, fmt.Errorf("failed to read profile file %s: %w", file.Name(), err) + return nil, fmt.Errorf("failed to read profile file %s: %w", relPath, err) } var prof domain.ExecutionProfile decoder := yaml.NewDecoder(bytes.NewReader(data)) decoder.KnownFields(true) if err := decoder.Decode(&prof); err != nil { + idMatch := fileMatch || profileFileHasID(data, id) if strings.Contains(err.Error(), "field api_key not found") { - if strings.TrimSuffix(strings.TrimSuffix(file.Name(), ".yaml"), ".yml") == id { - return nil, fmt.Errorf("%w: %s", ErrRawAPIKeyNotAllowed, file.Name()) + if idMatch { + return nil, fmt.Errorf("%w: %s", ErrRawAPIKeyNotAllowed, relPath) } continue } - if strings.TrimSuffix(strings.TrimSuffix(file.Name(), ".yaml"), ".yml") == id { - return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, file.Name(), err) + if idMatch { + return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err) } continue } @@ -76,16 +76,88 @@ func (r *filesystemRepository) GetProfile(ctx context.Context, id string) (*doma } if err := validateProfile(&prof); err != nil { if errors.Is(err, ErrRawAPIKeyNotAllowed) { - return nil, fmt.Errorf("%w: %s", err, file.Name()) + return nil, fmt.Errorf("%w: %s", err, relPath) } - return nil, fmt.Errorf("%w: %s: %v", ErrInvalidProfile, file.Name(), err) + return nil, fmt.Errorf("%w: %s: %v", ErrInvalidProfile, relPath, err) } - return &prof, nil + matches = append(matches, profileMatch{ + profile: &prof, + path: relPath, + }) + } + + if len(matches) > 1 { + paths := make([]string, 0, len(matches)) + for _, match := range matches { + paths = append(paths, match.path) + } + return nil, fmt.Errorf("%w: duplicate execution profile id %q found in: %s", ErrInvalidProfile, id, strings.Join(paths, ", ")) + } + + if len(matches) == 1 { + return matches[0].profile, nil } return nil, ErrProfileNotFound } +type profileMatch struct { + profile *domain.ExecutionProfile + path string +} + +func (r *filesystemRepository) yamlFiles(ctx context.Context) ([]string, error) { + var files []string + err := filepath.WalkDir(r.dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + if d.IsDir() { + return nil + } + if !isYAMLFile(d.Name()) { + return nil + } + files = append(files, path) + return nil + }) + sort.Strings(files) + return files, err +} + +func (r *filesystemRepository) relativePath(path string) string { + rel, err := filepath.Rel(r.dir, path) + if err != nil { + return filepath.Clean(path) + } + return filepath.Clean(rel) +} + +func isYAMLFile(name string) bool { + return strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") +} + +func profileIDFromFileName(name string) string { + name = strings.TrimSuffix(name, ".yaml") + name = strings.TrimSuffix(name, ".yml") + return name +} + +func profileFileHasID(data []byte, id string) bool { + var raw struct { + ID string `yaml:"id"` + } + if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&raw); err != nil { + return false + } + return strings.TrimSpace(raw.ID) == id +} + func validateProfile(p *domain.ExecutionProfile) error { if strings.TrimSpace(p.ID) == "" { return errors.New("id is required") diff --git a/internal/profile/repository_test.go b/internal/profile/repository_test.go index 234d213..e43f2d7 100644 --- a/internal/profile/repository_test.go +++ b/internal/profile/repository_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" ) @@ -60,6 +61,75 @@ func TestFilesystemRepository_GetProfile(t *testing.T) { } }) + t.Run("valid nested profile", func(t *testing.T) { + nestedDir := filepath.Join(tmpDir, "local") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + writeProfileTestFile(t, filepath.Join(nestedDir, "nested-local.yaml"), ` +id: nested-local +endpoint: http://localhost:8000/v1 +model: nested-model +temperature: 0.1 +`) + + p, err := repo.GetProfile(ctx, "nested-local") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if p.Model != "nested-model" { + t.Fatalf("unexpected model: %q", p.Model) + } + }) + + t.Run("duplicate profile IDs fail as ambiguous", func(t *testing.T) { + writeProfileTestFile(t, filepath.Join(tmpDir, "duplicate-profile-a.yaml"), ` +id: duplicate-profile +endpoint: http://localhost:8000/v1 +model: first-model +`) + nestedDir := filepath.Join(tmpDir, "duplicates") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + writeProfileTestFile(t, filepath.Join(nestedDir, "duplicate-profile-b.yaml"), ` +id: duplicate-profile +endpoint: http://localhost:8000/v1 +model: second-model +`) + + _, err := repo.GetProfile(ctx, "duplicate-profile") + if !errors.Is(err, ErrInvalidProfile) { + t.Fatalf("expected duplicate profile to return ErrInvalidProfile, got %v", err) + } + for _, want := range []string{"duplicate execution profile id", "duplicate-profile-a.yaml", filepath.Join("duplicates", "duplicate-profile-b.yaml")} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("expected error to contain %q, got %v", want, err) + } + } + }) + + t.Run("nested raw api_key rejected for likely target file", func(t *testing.T) { + nestedDir := filepath.Join(tmpDir, "secure") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + writeProfileTestFile(t, filepath.Join(nestedDir, "not_named_like_id.yaml"), ` +id: nested_raw_api_key +endpoint: http://localhost:8000/v1 +model: m +api_key: secret +`) + + _, err := repo.GetProfile(ctx, "nested_raw_api_key") + if !errors.Is(err, ErrRawAPIKeyNotAllowed) { + t.Fatalf("expected ErrRawAPIKeyNotAllowed, got %v", err) + } + if !strings.Contains(err.Error(), filepath.Join("secure", "not_named_like_id.yaml")) { + t.Fatalf("expected nested path in error, got %v", err) + } + }) + t.Run("invalid yaml", func(t *testing.T) { _, err := repo.GetProfile(ctx, "invalid_yaml") if !errors.Is(err, ErrInvalidYAML) { @@ -109,3 +179,10 @@ func TestFilesystemRepository_GetProfile(t *testing.T) { } }) } + +func writeProfileTestFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(strings.TrimLeft(content, "\n")), 0o644); err != nil { + t.Fatalf("failed to write profile test file %q: %v", path, err) + } +} diff --git a/internal/promptdef/filesystem_repository.go b/internal/promptdef/filesystem_repository.go index a872262..6572995 100644 --- a/internal/promptdef/filesystem_repository.go +++ b/internal/promptdef/filesystem_repository.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "gitea.maximumdirect.net/eric/scriptorium/internal/domain" @@ -62,29 +63,26 @@ func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id strin return nil, fmt.Errorf("%w: prompt id is required", ErrInvalidPromptDefinition) } - files, err := os.ReadDir(r.dir) + files, err := r.yamlFiles(ctx) if err != nil { return nil, fmt.Errorf("failed to read prompt definition directory: %w", err) } - for _, file := range files { + var matches []promptDefinitionMatch + for _, fullPath := range files { select { case <-ctx.Done(): return nil, ctx.Err() default: } - if file.IsDir() || !isYAMLFile(file.Name()) { - continue - } - - fullPath := filepath.Join(r.dir, file.Name()) - fileMatch := promptIDFromFileName(file.Name()) == id + relPath := r.relativePath(fullPath) + fileMatch := promptIDFromFileName(filepath.Base(fullPath)) == id raw, err := loadPromptDefinitionFile(fullPath) if err != nil { - if fileMatch { - return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, file.Name(), err) + if fileMatch || promptDefinitionFileHasID(fullPath, id) { + return nil, fmt.Errorf("%w: %s: %v", ErrInvalidYAML, relPath, err) } continue } @@ -92,7 +90,7 @@ func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id strin def, err := normalizePromptDefinition(raw, fullPath) if err != nil { if fileMatch || strings.TrimSpace(raw.ID) == id { - return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, file.Name(), err) + return nil, fmt.Errorf("%w: %s: %v", ErrInvalidPromptDefinition, relPath, err) } continue } @@ -103,12 +101,67 @@ func (r *filesystemRepository) GetPromptDefinition(ctx context.Context, id strin if version != "" && def.Version != version { continue } - return def, nil + matches = append(matches, promptDefinitionMatch{ + def: def, + path: relPath, + }) + } + + if len(matches) > 1 { + paths := make([]string, 0, len(matches)) + for _, match := range matches { + paths = append(paths, match.path) + } + if version != "" { + return nil, fmt.Errorf("%w: duplicate prompt definition id %q version %q found in: %s", ErrInvalidPromptDefinition, id, version, strings.Join(paths, ", ")) + } + return nil, fmt.Errorf("%w: duplicate prompt definition id %q found in: %s", ErrInvalidPromptDefinition, id, strings.Join(paths, ", ")) + } + + if len(matches) == 1 { + return matches[0].def, nil } return nil, ErrPromptDefinitionNotFound } +type promptDefinitionMatch struct { + def *domain.PromptDefinition + path string +} + +func (r *filesystemRepository) yamlFiles(ctx context.Context) ([]string, error) { + var files []string + err := filepath.WalkDir(r.dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + if d.IsDir() { + return nil + } + if !isYAMLFile(d.Name()) { + return nil + } + files = append(files, path) + return nil + }) + sort.Strings(files) + return files, err +} + +func (r *filesystemRepository) relativePath(path string) string { + rel, err := filepath.Rel(r.dir, path) + if err != nil { + return filepath.Clean(path) + } + return filepath.Clean(rel) +} + func loadPromptDefinitionFile(path string) (*promptDefinitionFile, error) { data, err := os.ReadFile(path) if err != nil { @@ -124,6 +177,20 @@ func loadPromptDefinitionFile(path string) (*promptDefinitionFile, error) { return &raw, nil } +func promptDefinitionFileHasID(path string, id string) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + var raw struct { + ID string `yaml:"id"` + } + if err := yaml.NewDecoder(bytes.NewReader(data)).Decode(&raw); err != nil { + return false + } + return strings.TrimSpace(raw.ID) == id +} + func normalizePromptDefinition(raw *promptDefinitionFile, sourcePath string) (*domain.PromptDefinition, error) { if raw == nil { return nil, errors.New("prompt definition is nil") diff --git a/internal/promptdef/repository_test.go b/internal/promptdef/repository_test.go index bfe7b80..b620c28 100644 --- a/internal/promptdef/repository_test.go +++ b/internal/promptdef/repository_test.go @@ -68,6 +68,39 @@ func TestFilesystemRepository_GetPromptDefinition(t *testing.T) { } }) + t.Run("valid nested file-backed prompt resolves content file relative to nested YAML", func(t *testing.T) { + nestedDir := filepath.Join(tmpDir, "dnd", "recap") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + writePromptTestFile(t, filepath.Join(nestedDir, "nested_recap.yaml"), ` +id: nested-recap +version: "1.0.0" +messages: + - role: user + content_file: ./nested_recap.user.tmpl +output: + format: markdown + validation_mode: basic + repair_attempts: 0 +`) + writePromptTestFile(t, filepath.Join(nestedDir, "nested_recap.user.tmpl"), `Nested recap: {{input "transcript"}}`) + + p, err := repo.GetPromptDefinition(ctx, "nested-recap", "") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(p.Templates) != 1 { + t.Fatalf("expected one template, got %d", len(p.Templates)) + } + if !strings.Contains(p.Templates[0].Content, "Nested recap") { + t.Fatalf("expected nested content file body, got %q", p.Templates[0].Content) + } + if !strings.Contains(p.Templates[0].ContentFile, filepath.Join("dnd", "recap", "nested_recap.user.tmpl")) { + t.Fatalf("expected nested content file path, got %q", p.Templates[0].ContentFile) + } + }) + t.Run("prompt with default_profile", func(t *testing.T) { p, err := repo.GetPromptDefinition(ctx, "with-default-profile", "") if err != nil { @@ -84,6 +117,124 @@ func TestFilesystemRepository_GetPromptDefinition(t *testing.T) { } }) + t.Run("duplicate prompt IDs fail as ambiguous", func(t *testing.T) { + writePromptTestFile(t, filepath.Join(tmpDir, "duplicate_a.yaml"), ` +id: duplicate-prompt +version: "1.0.0" +messages: + - role: user + content: First duplicate. +output: + format: markdown + validation_mode: basic + repair_attempts: 0 +`) + nestedDir := filepath.Join(tmpDir, "nested") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + writePromptTestFile(t, filepath.Join(nestedDir, "duplicate_b.yaml"), ` +id: duplicate-prompt +version: "2.0.0" +messages: + - role: user + content: Second duplicate. +output: + format: markdown + validation_mode: basic + repair_attempts: 0 +`) + + _, err := repo.GetPromptDefinition(ctx, "duplicate-prompt", "") + if !errors.Is(err, ErrInvalidPromptDefinition) { + t.Fatalf("expected duplicate prompt to return ErrInvalidPromptDefinition, got %v", err) + } + for _, want := range []string{"duplicate prompt definition id", "duplicate_a.yaml", filepath.Join("nested", "duplicate_b.yaml")} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("expected error to contain %q, got %v", want, err) + } + } + }) + + t.Run("duplicate prompt ID and requested version fails as ambiguous", func(t *testing.T) { + writePromptTestFile(t, filepath.Join(tmpDir, "version_duplicate_a.yaml"), ` +id: duplicate-version-prompt +version: "1.0.0" +messages: + - role: user + content: First duplicate version. +output: + format: markdown + validation_mode: basic + repair_attempts: 0 +`) + nestedDir := filepath.Join(tmpDir, "versioned") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + writePromptTestFile(t, filepath.Join(nestedDir, "version_duplicate_b.yaml"), ` +id: duplicate-version-prompt +version: "1.0.0" +messages: + - role: user + content: Second duplicate version. +output: + format: markdown + validation_mode: basic + repair_attempts: 0 +`) + + _, err := repo.GetPromptDefinition(ctx, "duplicate-version-prompt", "1.0.0") + if !errors.Is(err, ErrInvalidPromptDefinition) { + t.Fatalf("expected duplicate prompt version to return ErrInvalidPromptDefinition, got %v", err) + } + for _, want := range []string{"duplicate prompt definition id", "version \"1.0.0\"", "version_duplicate_a.yaml", filepath.Join("versioned", "version_duplicate_b.yaml")} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("expected error to contain %q, got %v", want, err) + } + } + }) + + t.Run("non-matching malformed nested prompt is ignored for not found lookup", func(t *testing.T) { + nestedDir := filepath.Join(tmpDir, "broken") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + writePromptTestFile(t, filepath.Join(nestedDir, "unrelated.yaml"), "id: [") + + _, err := repo.GetPromptDefinition(ctx, "does-not-exist-even-with-broken-nested-file", "") + if !errors.Is(err, ErrPromptDefinitionNotFound) { + t.Fatalf("expected ErrPromptDefinitionNotFound, got %v", err) + } + }) + + t.Run("strict decode failure in nested prompt matches by YAML ID", func(t *testing.T) { + nestedDir := filepath.Join(tmpDir, "strict") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + writePromptTestFile(t, filepath.Join(nestedDir, "not_named_like_id.yaml"), ` +id: nested-strict-error +version: "1.0.0" +unknown_field: true +messages: + - role: user + content: Invalid because of unknown field. +output: + format: markdown + validation_mode: basic + repair_attempts: 0 +`) + + _, err := repo.GetPromptDefinition(ctx, "nested-strict-error", "") + if !errors.Is(err, ErrInvalidYAML) { + t.Fatalf("expected ErrInvalidYAML, got %v", err) + } + if !strings.Contains(err.Error(), filepath.Join("strict", "not_named_like_id.yaml")) { + t.Fatalf("expected nested path in error, got %v", err) + } + }) + t.Run("version lookup", func(t *testing.T) { _, err := repo.GetPromptDefinition(ctx, "valid-inline", "9.9.9") if !errors.Is(err, ErrPromptDefinitionNotFound) { @@ -131,6 +282,13 @@ func TestFilesystemRepository_GetPromptDefinition(t *testing.T) { }) } +func writePromptTestFile(t *testing.T, path string, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(strings.TrimLeft(content, "\n")), 0o644); err != nil { + t.Fatalf("failed to write prompt test file %q: %v", path, err) + } +} + func copyTree(src, dst string) error { return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { if err != nil { diff --git a/internal/validate/standard_validator_test.go b/internal/validate/standard_validator_test.go index 6e1a70c..30d910b 100644 --- a/internal/validate/standard_validator_test.go +++ b/internal/validate/standard_validator_test.go @@ -116,6 +116,49 @@ func TestStandardValidatorJSONSchemaSuccess(t *testing.T) { } } +func TestStandardValidatorJSONSchemaNestedSchemaPathSuccess(t *testing.T) { + tmp := t.TempDir() + nestedDir := filepath.Join(tmp, "dnd") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nestedDir, "schema.json"), []byte(`{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"} + } +}`), 0644); err != nil { + t.Fatal(err) + } + + v := NewStandardValidator(tmp) + + res, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"name":"eris"}`)}, domain.OutputContract{ + ValidationMode: domain.ValidationJSONSchema, + SchemaPath: filepath.Join("dnd", "schema.json"), + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if res.Status != domain.ValidationPassed || !res.IsValid { + t.Fatalf("expected passed/valid, got status=%q valid=%v", res.Status, res.IsValid) + } +} + +func TestStandardValidatorJSONSchemaNestedSchemaPathMissing(t *testing.T) { + v := NewStandardValidator(t.TempDir()) + + _, err := v.Validate(context.Background(), &domain.Artifact{Body: []byte(`{"name":"eris"}`)}, domain.OutputContract{ + ValidationMode: domain.ValidationJSONSchema, + SchemaPath: filepath.Join("dnd", "missing.json"), + }) + if err == nil { + t.Fatal("expected nested schema load error") + } +} + func TestStandardValidatorJSONSchemaFailure(t *testing.T) { tmp := t.TempDir() schemaPath := filepath.Join(tmp, "schema.json")