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

View File

@@ -24,6 +24,20 @@ The [configuration reference](../config.md) owns Scriptorium's source-location
fields and precedence. Maintained files under `examples/` are application fields and precedence. Maintained files under `examples/` are application
inputs that use Promptkit's tagged formats. 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 ## Restricted HTTP Artifact Reader
`internal/adapter/http` implements `promptkit.ArtifactReader` for HTTP `internal/adapter/http` implements `promptkit.ArtifactReader` for HTTP

View File

@@ -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) 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 ## Promptkit OpenRouter Catalog
Upstream copyright statement: no project-specific statement is present in the 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) [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) 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)

View File

@@ -109,7 +109,9 @@ command-specific validation retains those responsibilities where applicable.
## Stage 1: Add The Dependency And Native Application Definition ## 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`, Primary files: `go.mod`, `go.sum`, new focused files under `internal/config`,
and `docs/oss/attribution.md`. and `docs/oss/attribution.md`.

2
go.mod
View File

@@ -3,6 +3,7 @@ module gitea.maximumdirect.net/eric/scriptorium
go 1.25.5 go 1.25.5
require ( require (
gitea.maximumdirect.net/eric/ConfigKit v0.4.0
gitea.maximumdirect.net/eric/promptkit v0.11.0 gitea.maximumdirect.net/eric/promptkit v0.11.0
gopkg.in/yaml.v3 v3.0.1 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-openrouter/v2 v2.20260922.1 // indirect
gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.0 // indirect gitea.maximumdirect.net/eric/promptkit-backend-rakestrawhome/v2 v2.20260922.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // 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 golang.org/x/text v0.14.0 // indirect
) )

4
go.sum
View File

@@ -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 h1:x7JguOUiInPVXK0GEitjyoRFqqvcuSTjYUvqJ+whtik=
gitea.maximumdirect.net/eric/promptkit v0.11.0/go.mod h1:0zEBVlJJC+ldDdXazAgybYBXNSe74vwFx5dyOuSos/w= 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= 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/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 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= 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 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

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