From e6e7d5c52b3edbd17f43648f93c6c239cc97119f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 26 Sep 2026 17:58:16 +0000 Subject: [PATCH] Add ConfigKit application definition and attribution --- docs/internal/sources.md | 14 +++ docs/oss/attribution.md | 34 +++++++ docs/roadmap/implementation.md | 4 +- go.mod | 2 + go.sum | 4 + internal/config/document.go | 109 +++++++++++++++++++++ internal/config/document_test.go | 163 +++++++++++++++++++++++++++++++ 7 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 internal/config/document.go create mode 100644 internal/config/document_test.go diff --git a/docs/internal/sources.md b/docs/internal/sources.md index 7b3327a..fe7aa3a 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -24,6 +24,20 @@ The [configuration reference](../config.md) owns Scriptorium's source-location fields and precedence. Maintained files under `examples/` are application inputs that use Promptkit's tagged formats. +## Native Application Definition In Preparation + +`internal/config` also contains a ConfigKit-native `Document` definition. Its +fresh defaults come from Scriptorium's existing default constants; its resolver +records only actual trimming, path cleaning, and render-format normalization. +The validator reports application retry, size-limit, and format errors without +mutating the document or exposing rejected values. Application path values keep +working-directory-relative meaning. ConfigKit owns strict YAML decoding and +presence semantics, including dynamic integer values in backend parameters. + +The executable commands still use the existing loader. The new definition is +internal preparation and is not yet an alternative user-selectable configuration +mode or a change to the current [configuration contract](../config.md). + ## Restricted HTTP Artifact Reader `internal/adapter/http` implements `promptkit.ArtifactReader` for HTTP diff --git a/docs/oss/attribution.md b/docs/oss/attribution.md index dca4e7e..6f21857 100644 --- a/docs/oss/attribution.md +++ b/docs/oss/attribution.md @@ -17,6 +17,20 @@ License and notices: [LGPL v3](licenses/LGPL-3.0.txt) | Source: [Promptkit v0.11.0](https://gitea.maximumdirect.net/eric/promptkit/src/commit/db05b56166ca2d8650dfaf660e1cf0eed3932e1d) +## ConfigKit + +Upstream copyright statement: no project-specific statement is present in the +selected module; the FSF copyright in its license text belongs to that license +document and is not substituted for a project notice. + +SPDX-License-Identifier: LGPL-3.0-only + +License and notices: [LGPL v3](licenses/LGPL-3.0.txt) | +[GPL v3 companion](licenses/GPL-3.0.txt) | +[upstream LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/e6ced0d3d1194b58e0b3dc8970089471ba175258/LICENSE) + +Source: [ConfigKit v0.4.0](https://gitea.maximumdirect.net/eric/ConfigKit/src/commit/e6ced0d3d1194b58e0b3dc8970089471ba175258) + ## Promptkit OpenRouter Catalog Upstream copyright statement: no project-specific statement is present in the @@ -91,3 +105,23 @@ License and notices: [MIT](licenses/MIT.txt) | [upstream NOTICE](https://github.com/go-yaml/yaml/blob/f6f7691b1fdeb513f56608cd2c32c51f8194bf51/NOTICE) Source: [gopkg.in/yaml.v3 v3.0.1](https://github.com/go-yaml/yaml/tree/f6f7691b1fdeb513f56608cd2c32c51f8194bf51) + +## go.yaml.in/yaml/v4 + +Copyright 2006-2010 Kirill Simonov + +Copyright 2011-2019 Canonical Ltd + +Copyright 2025 The go-yaml Project Contributors + +SPDX-License-Identifier: MIT AND Apache-2.0 + +The upstream NOTICE assigns MIT to files ported from libyaml and Apache 2.0 +to the remaining files; these are file-specific terms, not alternatives. + +License and notices: [MIT](licenses/MIT.txt) | +[Apache 2.0](licenses/Apache-2.0.txt) | +[upstream LICENSE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/LICENSE) | +[upstream NOTICE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/NOTICE) + +Source: [go.yaml.in/yaml/v4 v4.0.0-rc.6](https://github.com/yaml/go-yaml/tree/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 13a532f..37ce317 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -109,7 +109,9 @@ command-specific validation retains those responsibilities where applicable. ## Stage 1: Add The Dependency And Native Application Definition -**Status: Not started.** +**Status: Complete.** The pinned dependency, native definition, focused +presence/normalization/validation tests, and commit-specific attribution are in +place. The existing executable loader remains active pending the command cutover. Primary files: `go.mod`, `go.sum`, new focused files under `internal/config`, and `docs/oss/attribution.md`. diff --git a/go.mod b/go.mod index 77d2b73..d0889eb 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gitea.maximumdirect.net/eric/scriptorium go 1.25.5 require ( + gitea.maximumdirect.net/eric/ConfigKit v0.4.0 gitea.maximumdirect.net/eric/promptkit v0.11.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -11,5 +12,6 @@ require ( gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260922.1 // indirect gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 4b779f8..cc81e76 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +gitea.maximumdirect.net/eric/ConfigKit v0.4.0 h1:fo+vKff3zTGeWqJIA38NtT84b6OUP//9ZmBH3lFuKgk= +gitea.maximumdirect.net/eric/ConfigKit v0.4.0/go.mod h1:tYWpsrbLyu05r8+1MapWp5fN6ZrX9I8hU2xEPedYmYk= gitea.maximumdirect.net/eric/promptkit v0.11.0 h1:x7JguOUiInPVXK0GEitjyoRFqqvcuSTjYUvqJ+whtik= gitea.maximumdirect.net/eric/promptkit v0.11.0/go.mod h1:0zEBVlJJC+ldDdXazAgybYBXNSe74vwFx5dyOuSos/w= gitea.maximumdirect.net/eric/promptkit-backend-openrouter/v2 v2.20260922.1 h1:S2/Q/JDE+m8Ca3pDhAjVbfYrdlEhs9blj0STgNopHo0= @@ -8,6 +10,8 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= +go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/config/document.go b/internal/config/document.go new file mode 100644 index 0000000..9aac2ce --- /dev/null +++ b/internal/config/document.go @@ -0,0 +1,109 @@ +package config + +import ( + "path/filepath" + "strings" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" + renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format" +) + +// Document is Scriptorium's ConfigKit-native application configuration shape. +type Document struct { + PromptDir string `yaml:"prompt_dir"` + ProfileDir string `yaml:"profile_dir"` + SchemaDir string `yaml:"schema_dir"` + TransientRetries *int `yaml:"transient_retries"` + Server ServerDocument `yaml:"server"` + Defaults DefaultsDocument `yaml:"defaults"` + Backends map[string]BackendDocument `yaml:"backends"` +} + +type BackendDocument struct { + Endpoint string `yaml:"endpoint"` + APIKeyEnv string `yaml:"api_key_env"` + ExtraParams map[string]any `yaml:"extra_params"` + ConcurrencyLimit int `yaml:"concurrency_limit"` + QueueCapacity *int `yaml:"queue_capacity"` +} + +type ServerDocument struct { + Addr string `yaml:"addr"` + ArtifactRoot string `yaml:"artifact_root"` + MaxRequestBytes int64 `yaml:"max_request_bytes"` + MaxArtifactBytes int64 `yaml:"max_artifact_bytes"` + MaxResponseBytes int64 `yaml:"max_response_bytes"` +} + +type DefaultsDocument struct { + RenderFormat string `yaml:"render_format"` +} + +func documentDefinition() configkit.Definition[Document] { + return configkit.Definition[Document]{ + Defaults: documentDefaults, + Resolve: resolveDocument, + Validate: validateDocument, + } +} + +func documentDefaults() Document { + return Document{ + SchemaDir: defaults.SchemaDirDefault, + Server: ServerDocument{ + Addr: defaults.HTTPAddrDefault, + MaxRequestBytes: defaults.HTTPMaxRequestBytesDefault, + MaxArtifactBytes: defaults.HTTPMaxArtifactBytesDefault, + MaxResponseBytes: defaults.HTTPMaxResponseBytesDefault, + }, + Defaults: DefaultsDocument{RenderFormat: string(renderformat.DefaultPreparedRunOutputFormat)}, + } +} + +func resolveDocument(document *Document, resolution *configkit.Resolution) error { + normalizePath := func(path configkit.Path, value *string) { + cleaned := strings.TrimSpace(*value) + if cleaned != "" { + cleaned = filepath.Clean(cleaned) + } + if cleaned != *value { + configkit.Normalize(resolution, path, value, cleaned, "normalized application path") + } + } + normalizeString := func(path configkit.Path, value *string, normalized, reason string) { + if normalized != *value { + configkit.Normalize(resolution, path, value, normalized, reason) + } + } + + normalizePath(configkit.Keys("prompt_dir"), &document.PromptDir) + normalizePath(configkit.Keys("profile_dir"), &document.ProfileDir) + normalizePath(configkit.Keys("schema_dir"), &document.SchemaDir) + normalizePath(configkit.Keys("server", "artifact_root"), &document.Server.ArtifactRoot) + normalizeString(configkit.Keys("server", "addr"), &document.Server.Addr, + strings.TrimSpace(document.Server.Addr), "trimmed server address") + normalizeString(configkit.Keys("defaults", "render_format"), &document.Defaults.RenderFormat, + strings.ToLower(strings.TrimSpace(document.Defaults.RenderFormat)), "normalized render format") + return nil +} + +func validateDocument(document *Document, validation *configkit.Validation) { + if document.TransientRetries != nil && *document.TransientRetries < 0 { + validation.Error(configkit.Keys("transient_retries"), "must be greater than or equal to zero") + } + if document.Server.MaxRequestBytes < 0 { + validation.Error(configkit.Keys("server", "max_request_bytes"), "must be greater than or equal to zero") + } + if document.Server.MaxArtifactBytes < 0 { + validation.Error(configkit.Keys("server", "max_artifact_bytes"), "must be greater than or equal to zero") + } + if document.Server.MaxResponseBytes < 0 { + validation.Error(configkit.Keys("server", "max_response_bytes"), "must be greater than or equal to zero") + } + switch document.Defaults.RenderFormat { + case string(renderformat.OutputFormatText), string(renderformat.OutputFormatJSON): + default: + validation.Error(configkit.Keys("defaults", "render_format"), "must be text or json") + } +} diff --git a/internal/config/document_test.go b/internal/config/document_test.go new file mode 100644 index 0000000..32e125e --- /dev/null +++ b/internal/config/document_test.go @@ -0,0 +1,163 @@ +package config + +import ( + "bytes" + "errors" + "reflect" + "strings" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func loadDocumentBytes(content string) (*configkit.Result[Document], error) { + return configkit.Load(configkit.Bytes("application.yml", []byte(content)), configkit.Options[Document]{ + Definition: documentDefinition(), + }) +} + +func TestDocumentDefaultsAndExplicitValues(t *testing.T) { + defaultsOnly, err := configkit.Load(configkit.NoSource(), configkit.Options[Document]{Definition: documentDefinition()}) + if err != nil { + t.Fatalf("defaults-only load: %v", err) + } + if !reflect.DeepEqual(defaultsOnly.Config, documentDefaults()) || len(defaultsOnly.Sources()) != 0 { + t.Fatalf("unexpected defaults-only result: %+v sources=%+v", defaultsOnly.Config, defaultsOnly.Sources()) + } + + result, err := loadDocumentBytes(`prompt_dir: "" +profile_dir: "" +schema_dir: "" +transient_retries: 0 +server: + addr: "" + artifact_root: "" + max_request_bytes: 0 + max_artifact_bytes: 0 + max_response_bytes: 0 +defaults: + render_format: " JSON " +backends: + local: + endpoint: "http://fixture.invalid/v1" + queue_capacity: 0 + extra_params: + sample_count: 23 +`) + if err != nil { + t.Fatalf("explicit-value load: %v", err) + } + config := result.Config + if config.SchemaDir != "" || config.Server.Addr != "" || config.Server.MaxRequestBytes != 0 || config.Server.MaxArtifactBytes != 0 || config.Server.MaxResponseBytes != 0 { + t.Fatalf("explicit empty and zero values did not replace defaults: %+v", config) + } + if config.TransientRetries == nil || *config.TransientRetries != 0 { + t.Fatalf("explicit zero retry budget lost: %+v", config.TransientRetries) + } + if backend := config.Backends["local"]; backend.QueueCapacity == nil || *backend.QueueCapacity != 0 || backend.ExtraParams["sample_count"] != int64(23) { + t.Fatalf("backend presence or dynamic integer changed: %+v", backend) + } + if config.Defaults.RenderFormat != "json" { + t.Fatalf("render format normalization failed: %q", config.Defaults.RenderFormat) + } + + nullable, err := loadDocumentBytes("transient_retries: null\nbackends:\n local:\n queue_capacity: null\n") + if err != nil { + t.Fatalf("nullable-value load: %v", err) + } + if nullable.Config.TransientRetries != nil || nullable.Config.Backends["local"].QueueCapacity != nil { + t.Fatalf("nullable fields did not remain nil: %+v", nullable.Config) + } + + nullBackends, err := loadDocumentBytes("backends: null\n") + if err != nil || nullBackends.Config.Backends != nil { + t.Fatalf("nullable backend map did not remain nil: %+v error=%v", nullBackends, err) + } +} + +func TestDocumentRecordsEffectiveNormalization(t *testing.T) { + result, err := loadDocumentBytes(`prompt_dir: " ./one/../two " +profile_dir: " " +server: + artifact_root: " ./data/../artifacts " + addr: " :9000 " +defaults: + render_format: " JSON " +`) + if err != nil { + t.Fatalf("load normalized document: %v", err) + } + if result.Config.PromptDir != "two" || result.Config.ProfileDir != "" || result.Config.Server.ArtifactRoot != "artifacts" || result.Config.Server.Addr != ":9000" { + t.Fatalf("unexpected normalized values: %+v", result.Config) + } + var effective bytes.Buffer + if err := result.WriteEffective(&effective); err != nil { + t.Fatalf("write effective configuration: %v", err) + } + for _, fragment := range []string{"prompt_dir: two", "profile_dir: ''", "artifact_root: artifacts", "addr: :9000", "render_format: json"} { + if !strings.Contains(effective.String(), fragment) { + t.Fatalf("effective output omitted %q: %s", fragment, effective.String()) + } + } + explanation, known := result.Explain(configkit.Keys("prompt_dir")) + if !known || explanation.Effective.YAML != "two\n" { + t.Fatalf("explanation does not agree with effective value: %+v known=%t", explanation, known) + } + found := false + for _, change := range explanation.History { + if change.Origin.Operation == configkit.ChangeNormalization { + found = true + } + } + if !found { + t.Fatalf("normalization was not recorded: %+v", explanation.History) + } +} + +func TestDocumentValidationIsSafeAndPathSpecific(t *testing.T) { + for _, tc := range []struct{ name, content, path string }{ + {"negative retry", "transient_retries: -9173\n", "/transient_retries"}, + {"negative request limit", "server:\n max_request_bytes: -9173\n", "/server/max_request_bytes"}, + {"negative artifact limit", "server:\n max_artifact_bytes: -9173\n", "/server/max_artifact_bytes"}, + {"negative response limit", "server:\n max_response_bytes: -9173\n", "/server/max_response_bytes"}, + {"empty format", "defaults:\n render_format: \"\"\n", "/defaults/render_format"}, + {"unknown format", "defaults:\n render_format: private-invalid-value\n", "/defaults/render_format"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := loadDocumentBytes(tc.content) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("expected typed validation error, got %v", err) + } + found := false + for _, diagnostic := range loadError.Diagnostics() { + if diagnostic.Code == configkit.CodeValidateError && diagnostic.Path.String() == tc.path { + found = true + } + if strings.Contains(diagnostic.Message, "9173") || strings.Contains(diagnostic.Message, "private-invalid-value") { + t.Fatalf("diagnostic disclosed rejected value: %+v", diagnostic) + } + } + if !found { + t.Fatalf("expected validation at %s, got %+v", tc.path, loadError.Diagnostics()) + } + }) + } +} + +func TestDocumentRejectsNativeShapeErrors(t *testing.T) { + for _, content := range []string{ + "server: null\n", + "defaults: null\n", + "server:\n addr: null\n", + "server:\n max_request_bytes: null\n", + "defaults:\n render_format: null\n", + "server:\n unknown_field: 1\n", + "backends:\n local:\n unknown_field: 1\n", + } { + _, err := loadDocumentBytes(content) + if err == nil { + t.Fatalf("unexpected acceptance of %q", content) + } + } +}