Add ConfigKit application definition and attribution

This commit is contained in:
2026-09-26 17:58:16 +00:00
parent c016af04b7
commit e6e7d5c52b
7 changed files with 329 additions and 1 deletions

109
internal/config/document.go Normal file
View File

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

View File

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