Migrate remaining configuration documents to ConfigKit

This commit is contained in:
2026-09-07 04:02:21 +00:00
parent 9e40c729e1
commit f0225e937c
13 changed files with 297 additions and 398 deletions

View File

@@ -106,9 +106,13 @@ remote state with an unsafe legacy identity must be migrated before use.
- An omitted profile, an empty profile option, and the exact name `default` - An omitted profile, an empty profile option, and the exact name `default`
select the base configuration. Any other name selects select the base configuration. Any other name selects
`profiles/<name>.yml` relative to the pipeline root. `profiles/<name>.yml` relative to the pipeline root.
- Campaign, session, and canonical-party documents are strict single-source
ConfigKit schemas. They do not enable imports, profiles, or `configkit`
directives.
- Configured timeout and retry-delay durations must be positive. An omitted - Configured timeout and retry-delay durations must be positive. An omitted
artifact timeout continues to inherit its configured Scriptorium timeout. artifact timeout continues to inherit its configured Scriptorium timeout.
- Session files must be concrete; unresolved `{{ ... }}` placeholders fail load. - Session files must be concrete; unresolved `{{ ... }}` placeholders fail load.
- Session and party inputs inspected before typed loading are limited to 8 MiB.
- Pipeline defaults are applied before validation. - Pipeline defaults are applied before validation.
- Campaign and session identities must agree. - Campaign and session identities must agree.
- Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`, - Required stable files (`speakers_file`, `autocorrect_file`, `glossary_file`,

View File

@@ -44,6 +44,14 @@ download retain that exact pipeline object and its private provenance. Removing
a temporary downloaded session file therefore cannot invalidate the resolved a temporary downloaded session file therefore cannot invalidate the resolved
pipeline or campaign context. pipeline or campaign context.
Campaign and session files use independent ConfigKit definitions with imports
and profiles disabled. Campaign files normally load directly from their source;
session files are first read through the 8 MiB bounded-read boundary because the
application must reject unresolved template placeholders before passing an exact
byte snapshot to ConfigKit. Local and remotely downloaded session bytes then use
the same typed loader and retain the existing post-load identity expectations,
stable-input precedence, and source-relative path semantics.
The application also has a separate read-only inspection resolver for `config The application also has a separate read-only inspection resolver for `config
validate`, `config show`, and `config sources`. It uses the same production root/profile and validate`, `config show`, and `config sources`. It uses the same production root/profile and
campaign selection functions, but never routes through session discovery, campaign selection functions, but never routes through session discovery,
@@ -88,13 +96,16 @@ configured roster authority; player and session-party fields are unsupported.
## Canonical Party Domain ## Canonical Party Domain
`ParseParty` is the package-owned boundary for parsing a party source. It `ParseParty` is the package-owned boundary for parsing a party source. Its
requires a top-level `schema_version` and strictly validates the single-source ConfigKit definition disables imports and profiles, requires
`narratio.party.v1` contract into ordered character domain values. The `schema_version` through `VersionRule`, and strictly decodes the
canonical value retains a separate exact byte copy of its source so consumers character-keyed typed schema before applying Narratio's party-domain checks.
can materialize the authored party document without reserializing it. Its Successful metadata coordinates recover authored character-map order without a
`PlayersYAML` method deterministically derives the versioned players-only second YAML parse; class and alias slice order comes directly from typed decode.
projection. The canonical value retains a separate exact byte copy of its source so
consumers can materialize the authored party document without reserializing it.
Its `PlayersYAML` method deterministically derives the ID-sorted, versioned
players-only projection.
An unversioned source is rejected before resolution, family expansion, or An unversioned source is rejected before resolution, family expansion, or
prepare-stage materialization. prepare-stage materialization.
@@ -159,8 +170,9 @@ metadata, and root-relative path behavior. Application configuration-loader test
single-read boundary by changing the pipeline file after its initial load and single-read boundary by changing the pipeline file after its initial load and
confirming local session resolution retains the original pipeline. Other confirming local session resolution retains the original pipeline. Other
configuration tests continue to protect defaults and validation after assembly. configuration tests continue to protect defaults and validation after assembly.
`party_test.go` protects the versioned party schema, domain invariants, and `party_test.go` protects the versioned typed party schema, domain invariants,
deterministic players projection without involving campaign or runtime wiring. authored character order, retained bytes, and deterministic players projection
without involving campaign or runtime wiring.
`party_resolution_test.go` protects campaign-owned party loading, canonical `party_resolution_test.go` protects campaign-owned party loading, canonical
input restrictions, source provenance, and virtual players input selection. input restrictions, source provenance, and virtual players input selection.

View File

@@ -240,7 +240,10 @@ still fail. Commands use the new pipeline loader with no format fallback. Update
## Stage 5 — Migrate campaign, session, and canonical party loading ## Stage 5 — Migrate campaign, session, and canonical party loading
Status: Pending Status: Complete — Migrated campaign, session, and canonical-party documents to
independent ConfigKit definitions, preserved bounded session inspection, exact
party bytes and metadata-derived authored order, and retained identity, source,
family-expansion, and validation behavior; verified all tests and focused races.
Read: `internal/config/load.go`, `party.go`, `party_resolution.go`, Read: `internal/config/load.go`, `party.go`, `party_resolution.go`,
`artifact_families.go`, session-template tests, `internal/app/config_loader.go`, `artifact_families.go`, session-template tests, `internal/app/config_loader.go`,

View File

@@ -60,7 +60,7 @@ func TestCampaignStrictDecodeRejectsLegacyCampaignField(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected load error, got nil") t.Fatal("expected load error, got nil")
} }
if !strings.Contains(err.Error(), "campaign file") || !strings.Contains(err.Error(), "strict decode failed") { if !strings.Contains(err.Error(), "load campaign config") || !strings.Contains(err.Error(), "decode.unknown_field") {
t.Fatalf("error = %q, want campaign strict decode context", err.Error()) t.Fatalf("error = %q, want campaign strict decode context", err.Error())
} }
} }
@@ -75,7 +75,7 @@ func TestCampaignStrictDecodeRejectsUnknownFields(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected load error, got nil") t.Fatal("expected load error, got nil")
} }
if !strings.Contains(err.Error(), "campaign file") || !strings.Contains(err.Error(), "strict decode failed") { if !strings.Contains(err.Error(), "load campaign config") || !strings.Contains(err.Error(), "decode.unknown_field") {
t.Fatalf("error = %q, want campaign strict decode context", err.Error()) t.Fatalf("error = %q, want campaign strict decode context", err.Error())
} }
} }

View File

@@ -0,0 +1,32 @@
package config
import (
"fmt"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
const maxConfigDocumentBytes int64 = 8 << 20
func loadCampaignWithConfigKit(path string) (*CampaignConfig, error) {
result, err := configkit.Load(configkit.File(path), configkit.Options[CampaignConfig]{
Definition: configkit.Definition[CampaignConfig]{},
})
if err != nil {
return nil, describeConfigKitLoadError("campaign", err)
}
return &result.Config, nil
}
func loadSessionWithConfigKit(label string, data []byte) (*SessionConfig, error) {
if int64(len(data)) > maxConfigDocumentBytes {
return nil, fmt.Errorf("load session config: session file %q exceeds %d-byte limit", label, maxConfigDocumentBytes)
}
result, err := configkit.Load(configkit.Bytes(label, data), configkit.Options[SessionConfig]{
Definition: configkit.Definition[SessionConfig]{},
})
if err != nil {
return nil, describeConfigKitLoadError("session", err)
}
return &result.Config, nil
}

View File

@@ -0,0 +1,56 @@
package config
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSingleSourceDocumentsRejectConfigKitDirectives(t *testing.T) {
dir := t.TempDir()
campaignPath := filepath.Join(dir, "campaign.yml")
if err := os.WriteFile(campaignPath, []byte("configkit:\n imports: []\ncampaign_id: sample\n"), 0o644); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
load func() error
}{
{name: "campaign", load: func() error { _, err := LoadCampaign(campaignPath); return err }},
{name: "session", load: func() error {
_, err := LoadSessionBytesWithOptions("session.yml", []byte("configkit:\n imports: []\nsession_id: sample\n"), SessionLoadOptions{})
return err
}},
{name: "party", load: func() error {
_, err := ParseParty([]byte("configkit:\n imports: []\nschema_version: narratio.party.v1\ncharacters: {}\n"))
return err
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := test.load(); err == nil || !strings.Contains(err.Error(), "import.disabled") {
t.Fatalf("load error = %v, want disabled-import diagnostic", err)
}
})
}
}
func TestInspectedDocumentsEnforceConfigKitSizeLimit(t *testing.T) {
tooLarge := bytes.Repeat([]byte{' '}, int(maxConfigDocumentBytes)+1)
if _, err := LoadSessionBytesWithOptions("session.yml", tooLarge, SessionLoadOptions{}); err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("oversized session error = %v", err)
}
if _, err := ParseParty(tooLarge); err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("oversized party error = %v", err)
}
path := filepath.Join(t.TempDir(), "session.yml")
if err := os.WriteFile(path, tooLarge, 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadSession(path); err == nil || !strings.Contains(err.Error(), "limit") {
t.Fatalf("oversized local session error = %v", err)
}
}

View File

@@ -2,14 +2,12 @@ package config
import ( import (
"fmt" "fmt"
"io"
"os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
"gopkg.in/yaml.v3" "gitea.maximumdirect.net/eric/narratio/internal/fileops"
) )
// LoadPipeline loads pipeline configuration from a YAML file with strict field checking. // LoadPipeline loads pipeline configuration from a YAML file with strict field checking.
@@ -65,11 +63,7 @@ func finalizeLoadedPipeline(path string, cfg *PipelineConfig) (*PipelineConfig,
// LoadCampaign loads campaign configuration from a YAML file with strict field checking. // LoadCampaign loads campaign configuration from a YAML file with strict field checking.
func LoadCampaign(path string) (*CampaignConfig, error) { func LoadCampaign(path string) (*CampaignConfig, error) {
var cfg CampaignConfig return loadCampaignWithConfigKit(path)
if err := decodeStrictYAML("campaign", path, &cfg); err != nil {
return nil, fmt.Errorf("load campaign config: %w", err)
}
return &cfg, nil
} }
// LoadSession loads session configuration from a YAML file with strict field checking. // LoadSession loads session configuration from a YAML file with strict field checking.
@@ -87,7 +81,7 @@ type SessionLoadOptions struct {
// LoadSessionWithOptions loads session configuration from a YAML file with // LoadSessionWithOptions loads session configuration from a YAML file with
// strict field checking. // strict field checking.
func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfig, error) { func LoadSessionWithOptions(path string, opts SessionLoadOptions) (*SessionConfig, error) {
sessionBytes, err := os.ReadFile(path) sessionBytes, err := fileops.ReadFileBounded(path, maxConfigDocumentBytes)
if err != nil { if err != nil {
return nil, fmt.Errorf("load session config: session file %q: open: %w", path, err) return nil, fmt.Errorf("load session config: session file %q: open: %w", path, err)
} }
@@ -101,9 +95,9 @@ func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOpti
return nil, fmt.Errorf("load session config: %w", err) return nil, fmt.Errorf("load session config: %w", err)
} }
var cfg SessionConfig cfg, err := loadSessionWithConfigKit(label, data)
if err := decodeStrictYAMLFromReader("session", label, strings.NewReader(string(data)), &cfg); err != nil { if err != nil {
return nil, fmt.Errorf("load session config: %w", err) return nil, err
} }
if strings.TrimSpace(opts.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != strings.TrimSpace(opts.SessionID) { if strings.TrimSpace(opts.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != "" && strings.TrimSpace(cfg.SessionID) != strings.TrimSpace(opts.SessionID) {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -131,7 +125,7 @@ func LoadSessionBytesWithOptions(label string, data []byte, opts SessionLoadOpti
) )
} }
} }
return &cfg, nil return cfg, nil
} }
// Load loads and resolves combined pipeline, campaign, and session configuration. // Load loads and resolves combined pipeline, campaign, and session configuration.
@@ -394,33 +388,6 @@ func selectStableInput(campaignValue, sessionValue, campaignPath, sessionPath st
} }
} }
func decodeStrictYAML(kind, path string, out any) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("%s file %q: open: %w", kind, path, err)
}
defer f.Close()
return decodeStrictYAMLFromReader(kind, path, f, out)
}
func decodeStrictYAMLFromReader(kind, path string, r io.Reader, out any) error {
dec := yaml.NewDecoder(r)
dec.KnownFields(true)
if err := dec.Decode(out); err != nil {
return fmt.Errorf("%s file %q: strict decode failed: %w", kind, path, err)
}
var extra yaml.Node
if err := dec.Decode(&extra); err == nil {
return fmt.Errorf("%s file %q: must contain exactly one YAML document", kind, path)
} else if err != io.EOF {
return fmt.Errorf("%s file %q: trailing content decode failed: %w", kind, path, err)
}
return nil
}
var sessionTemplatePlaceholderPattern = regexp.MustCompile(`\{\{[^}]*\}\}`) var sessionTemplatePlaceholderPattern = regexp.MustCompile(`\{\{[^}]*\}\}`)
var sessionTemplateVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`) var sessionTemplateVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)

View File

@@ -54,31 +54,3 @@ func TestLoadersRejectTrailingYAMLDocuments(t *testing.T) {
}) })
} }
} }
func TestStrictYAMLRejectsParserExposedEmptyTrailingDocument(t *testing.T) {
var target struct {
Name string `yaml:"name"`
}
err := decodeStrictYAMLFromReader("test", "memory", strings.NewReader("name: one\n---\n"), &target)
if err == nil {
t.Fatal("decodeStrictYAMLFromReader() error = nil, want trailing-document error")
}
if !strings.Contains(err.Error(), "exactly one YAML document") {
t.Fatalf("decodeStrictYAMLFromReader() error = %v, want exactly-one-document error", err)
}
}
func TestStrictYAMLAcceptsSingleDocumentAndRejectsUnknownFields(t *testing.T) {
var target struct {
Name string `yaml:"name"`
}
if err := decodeStrictYAMLFromReader("test", "memory", strings.NewReader("name: one\n"), &target); err != nil {
t.Fatalf("decodeStrictYAMLFromReader(single document) error = %v", err)
}
if target.Name != "one" {
t.Fatalf("Name = %q, want one", target.Name)
}
if err := decodeStrictYAMLFromReader("test", "memory", strings.NewReader("unknown: one\n"), &target); err == nil || !strings.Contains(err.Error(), "strict decode failed") {
t.Fatalf("decodeStrictYAMLFromReader(unknown field) error = %v, want strict decode error", err)
}
}

View File

@@ -3,12 +3,12 @@ package config
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"io" "math"
"sort" "sort"
"strconv"
"strings" "strings"
"unicode" "unicode"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -54,264 +54,141 @@ type PartyClass struct {
Level *int Level *int
} }
// ParseParty strictly parses one supported party document. type partyDocument struct {
SchemaVersion string `yaml:"schema_version"`
Characters map[string]partyCharacter `yaml:"characters"`
}
type partyCharacter struct {
Player partyPlayer `yaml:"player"`
Character partyCharacterDetails `yaml:"character"`
}
type partyPlayer struct {
Name string `yaml:"name"`
}
type partyCharacterDetails struct {
Name string `yaml:"name"`
Aliases []string `yaml:"alias"`
Classes []partyClass `yaml:"classes"`
}
type partyClass struct {
Name string `yaml:"name"`
Level *int `yaml:"level"`
}
type partyDomainError struct {
path configkit.Path
message string
}
// ParseParty strictly parses one supported party document through ConfigKit.
func ParseParty(data []byte) (*CanonicalParty, error) { func ParseParty(data []byte) (*CanonicalParty, error) {
root, decoder, err := parsePartyFirstDocument(data) if int64(len(data)) > maxConfigDocumentBytes {
return nil, fmt.Errorf("parse party config: party document exceeds %d-byte limit", maxConfigDocumentBytes)
}
definition := configkit.Definition[partyDocument]{
Version: &configkit.VersionRule{
Path: configkit.Keys("schema_version"),
Check: validatePartyVersion,
},
Validate: validatePartyDocument,
}
result, err := configkit.Load(configkit.Bytes("party.yml", data), configkit.Options[partyDocument]{Definition: definition})
if err != nil { if err != nil {
return nil, err return nil, describeConfigKitLoadError("party", err)
} }
if err := requireNoTrailingPartyDocuments(decoder); err != nil { return canonicalPartyFromResult(result, data), nil
return nil, err
}
party, err := parseCanonicalParty(root, data)
if err != nil {
return nil, err
}
return party, nil
} }
func parsePartyFirstDocument(data []byte) (*yaml.Node, *yaml.Decoder, error) { func validatePartyVersion(version configkit.Version, validation *configkit.Validation) error {
decoder := yaml.NewDecoder(bytes.NewReader(data)) path := configkit.Keys("schema_version")
var document yaml.Node if !version.Present || version.Null || version.Text == "" {
if err := decoder.Decode(&document); err != nil { validation.Error(path, fmt.Sprintf("schema_version is required; supported schema_version is %q", PartySchemaVersion))
if err == io.EOF { } else if version.Text != PartySchemaVersion {
return nil, nil, fmt.Errorf("party YAML document is empty") validation.Error(path, fmt.Sprintf("schema_version is unsupported; supported schema_version is %q", PartySchemaVersion))
}
return nil, nil, fmt.Errorf("decode party YAML: %w", err)
}
if document.Kind != yaml.DocumentNode || len(document.Content) != 1 {
return nil, nil, fmt.Errorf("party must contain one YAML document")
}
return document.Content[0], decoder, nil
}
func requireNoTrailingPartyDocuments(decoder *yaml.Decoder) error {
var trailing yaml.Node
if err := decoder.Decode(&trailing); err == nil {
return fmt.Errorf("party must contain exactly one YAML document")
} else if err != io.EOF {
return fmt.Errorf("decode trailing party YAML: %w", err)
} }
return nil return nil
} }
func parseCanonicalParty(root *yaml.Node, raw []byte) (*CanonicalParty, error) { func validatePartyDocument(document *partyDocument, validation *configkit.Validation) {
fields, err := partyMappingFields(root, "party") if problem := firstPartyDomainError(document); problem != nil {
if err != nil { validation.Error(problem.path, problem.message)
return nil, err }
} }
version, ok := fields["schema_version"] func firstPartyDomainError(document *partyDocument) *partyDomainError {
if !ok { charactersPath := configkit.Keys("characters")
return nil, fmt.Errorf("party.schema_version is required; supported schema_version is %q", PartySchemaVersion) if document == nil || len(document.Characters) == 0 {
return &partyDomainError{path: charactersPath, message: "characters must be non-empty"}
} }
schemaVersion, err := partyRequiredString(map[string]*yaml.Node{"schema_version": version}, "schema_version", "party") ids := make([]string, 0, len(document.Characters))
if err != nil { for id := range document.Characters {
return nil, fmt.Errorf("party.schema_version must be a non-empty string; supported schema_version is %q", PartySchemaVersion) ids = append(ids, id)
} }
if schemaVersion != PartySchemaVersion { sort.Strings(ids)
return nil, fmt.Errorf("party.schema_version %q is unsupported; supported schema_version is %q", schemaVersion, PartySchemaVersion) seenNames := make([]string, 0, len(ids)*2)
} for _, id := range ids {
if err := partyKnownFields(fields, "party", "schema_version", "characters"); err != nil { path := charactersPath.Key(id)
return nil, err character := document.Characters[id]
}
charactersNode, ok := fields["characters"]
if !ok {
return nil, fmt.Errorf("party.characters is required")
}
charactersFields, err := partyMappingFields(charactersNode, "party.characters")
if err != nil {
return nil, err
}
if len(charactersFields) == 0 {
return nil, fmt.Errorf("party.characters must be non-empty")
}
party := &CanonicalParty{Raw: append([]byte(nil), raw...)}
seenNames := make([]string, 0, len(charactersFields))
for _, id := range partyMappingOrder(charactersNode) {
entry, err := parsePartyCharacter(id, charactersFields[id], seenNames)
if err != nil {
return nil, err
}
party.Characters = append(party.Characters, entry)
seenNames = append(seenNames, entry.Character.Name)
seenNames = append(seenNames, entry.Character.Aliases...)
}
return party, nil
}
func parsePartyCharacter(id string, node *yaml.Node, seenNames []string) (PartyCharacter, error) {
path := "party.characters." + id
if id != strings.TrimSpace(id) || !artifactpolicy.IsConfiguredKey(id) { if id != strings.TrimSpace(id) || !artifactpolicy.IsConfiguredKey(id) {
return PartyCharacter{}, fmt.Errorf("%s has invalid character id %q", path, id) return &partyDomainError{path: path, message: "invalid character id"}
} }
fields, err := partyMappingFields(node, path) if err := validatePartyDisplayName(character.Player.Name); err != "" {
if err != nil { return &partyDomainError{path: path.Key("player").Key("name"), message: err}
return PartyCharacter{}, err
} }
if err := partyKnownFields(fields, path, "player", "character"); err != nil { if err := validatePartyDisplayName(character.Character.Name); err != "" {
return PartyCharacter{}, err return &partyDomainError{path: path.Key("character").Key("name"), message: err}
} }
player, err := parsePartyPlayer(fields["player"], path+".player") if partyNameAmbiguous(character.Character.Name, seenNames) {
if err != nil { return &partyDomainError{path: path.Key("character").Key("name"), message: "name or alias is ambiguous"}
return PartyCharacter{}, err
} }
character, err := parsePartyCharacterDetails(fields["character"], path+".character") visibleNames := append(append([]string(nil), seenNames...), character.Character.Name)
if err != nil { for index, alias := range character.Character.Aliases {
return PartyCharacter{}, err aliasPath := path.Key("character").Key("alias").Index(index)
if err := validatePartyDisplayName(alias); err != "" {
return &partyDomainError{path: aliasPath, message: err}
} }
if partyNameAmbiguous(character.Name, seenNames) { if partyNameAmbiguous(alias, visibleNames) {
return PartyCharacter{}, fmt.Errorf("%s name or alias %q is ambiguous", path, character.Name) return &partyDomainError{path: aliasPath, message: "name or alias is ambiguous"}
} }
visibleNames := append(append([]string(nil), seenNames...), character.Name) visibleNames = append(visibleNames, alias)
for _, name := range character.Aliases {
if partyNameAmbiguous(name, visibleNames) {
return PartyCharacter{}, fmt.Errorf("%s name or alias %q is ambiguous", path, name)
} }
visibleNames = append(visibleNames, name) classesPath := path.Key("character").Key("classes")
if len(character.Character.Classes) == 0 {
return &partyDomainError{path: classesPath, message: "classes must be a non-empty list"}
} }
return PartyCharacter{ID: id, Player: player, Character: character}, nil classNames := make([]string, 0, len(character.Character.Classes))
for index, class := range character.Character.Classes {
classPath := classesPath.Index(index)
if err := validatePartyDisplayName(class.Name); err != "" {
return &partyDomainError{path: classPath.Key("name"), message: err}
}
if partyNameAmbiguous(class.Name, classNames) {
return &partyDomainError{path: classPath.Key("name"), message: "duplicate class"}
}
classNames = append(classNames, class.Name)
if class.Level != nil && *class.Level <= 0 {
return &partyDomainError{path: classPath.Key("level"), message: "level must be a positive integer"}
}
}
seenNames = visibleNames
}
return nil
} }
func parsePartyPlayer(node *yaml.Node, path string) (PartyPlayer, error) { func validatePartyDisplayName(value string) string {
fields, err := partyMappingFields(node, path)
if err != nil {
return PartyPlayer{}, err
}
if err := partyKnownFields(fields, path, "name"); err != nil {
return PartyPlayer{}, err
}
name, err := partyRequiredDisplayString(fields, "name", path)
if err != nil {
return PartyPlayer{}, err
}
return PartyPlayer{Name: name}, nil
}
func parsePartyCharacterDetails(node *yaml.Node, path string) (PartyCharacterDetails, error) {
fields, err := partyMappingFields(node, path)
if err != nil {
return PartyCharacterDetails{}, err
}
if err := partyKnownFields(fields, path, "name", "alias", "classes"); err != nil {
return PartyCharacterDetails{}, err
}
name, err := partyRequiredDisplayString(fields, "name", path)
if err != nil {
return PartyCharacterDetails{}, err
}
aliases, err := partyAliases(fields["alias"], path+".alias")
if err != nil {
return PartyCharacterDetails{}, err
}
classes, err := partyClasses(fields["classes"], path+".classes")
if err != nil {
return PartyCharacterDetails{}, err
}
return PartyCharacterDetails{Name: name, Aliases: aliases, Classes: classes}, nil
}
func partyAliases(node *yaml.Node, path string) ([]string, error) {
if node == nil {
return nil, nil
}
if node.Kind != yaml.SequenceNode {
return nil, fmt.Errorf("%s must be a list", path)
}
aliases := make([]string, 0, len(node.Content))
for index, item := range node.Content {
alias, err := partyDisplayString(item, fmt.Sprintf("%s[%d]", path, index))
if err != nil {
return nil, err
}
aliases = append(aliases, alias)
}
return aliases, nil
}
func partyClasses(node *yaml.Node, path string) ([]PartyClass, error) {
if node == nil {
return nil, fmt.Errorf("%s is required", path)
}
if node.Kind != yaml.SequenceNode || len(node.Content) == 0 {
return nil, fmt.Errorf("%s must be a non-empty list", path)
}
classes := make([]PartyClass, 0, len(node.Content))
classNames := make([]string, 0, len(node.Content))
for index, item := range node.Content {
classPath := fmt.Sprintf("%s[%d]", path, index)
fields, err := partyMappingFields(item, classPath)
if err != nil {
return nil, err
}
if err := partyKnownFields(fields, classPath, "name", "level"); err != nil {
return nil, err
}
name, err := partyRequiredDisplayString(fields, "name", classPath)
if err != nil {
return nil, err
}
if partyNameAmbiguous(name, classNames) {
return nil, fmt.Errorf("%s has duplicate class %q", path, name)
}
classNames = append(classNames, name)
level, err := partyLevel(fields["level"], classPath+".level")
if err != nil {
return nil, err
}
classes = append(classes, PartyClass{Name: name, Level: level})
}
return classes, nil
}
func partyLevel(node *yaml.Node, path string) (*int, error) {
if node == nil {
return nil, nil
}
if node.Kind != yaml.ScalarNode || node.Tag != "!!int" {
return nil, fmt.Errorf("%s must be a positive integer", path)
}
level, err := strconv.Atoi(node.Value)
if err != nil || level <= 0 {
return nil, fmt.Errorf("%s must be a positive integer", path)
}
return &level, nil
}
func partyRequiredDisplayString(fields map[string]*yaml.Node, key, path string) (string, error) {
node, ok := fields[key]
if !ok {
return "", fmt.Errorf("%s.%s is required", path, key)
}
return partyDisplayString(node, path+"."+key)
}
func partyRequiredString(fields map[string]*yaml.Node, key, path string) (string, error) {
node, ok := fields[key]
if !ok {
return "", fmt.Errorf("%s.%s is required", path, key)
}
if node.Kind != yaml.ScalarNode || node.Tag != "!!str" || node.Value == "" {
return "", fmt.Errorf("%s.%s must be a non-empty string", path, key)
}
return node.Value, nil
}
func partyDisplayString(node *yaml.Node, path string) (string, error) {
if node == nil || node.Kind != yaml.ScalarNode || node.Tag != "!!str" {
return "", fmt.Errorf("%s must be a display string", path)
}
value := node.Value
if value == "" || strings.TrimSpace(value) != value { if value == "" || strings.TrimSpace(value) != value {
return "", fmt.Errorf("%s must be non-empty and trimmed", path) return "display name must be non-empty and trimmed"
} }
for _, runeValue := range value { for _, runeValue := range value {
if unicode.IsControl(runeValue) { if unicode.IsControl(runeValue) {
return "", fmt.Errorf("%s must not contain control characters", path) return "display name must not contain control characters"
} }
} }
return value, nil return ""
} }
func partyNameAmbiguous(name string, existing []string) bool { func partyNameAmbiguous(name string, existing []string) bool {
@@ -323,70 +200,45 @@ func partyNameAmbiguous(name string, existing []string) bool {
return false return false
} }
func partyMappingFields(node *yaml.Node, path string) (map[string]*yaml.Node, error) { func canonicalPartyFromResult(result *configkit.Result[partyDocument], raw []byte) *CanonicalParty {
if node == nil || node.Kind != yaml.MappingNode { type orderedCharacter struct {
return nil, fmt.Errorf("%s must be a mapping", path) id string
location configkit.Location
} }
if len(node.Content)%2 != 0 { metadata := result.Metadata()
return nil, fmt.Errorf("%s has an incomplete mapping", path) ordered := make([]orderedCharacter, 0, len(result.Config.Characters))
for id := range result.Config.Characters {
entry := orderedCharacter{id: id, location: configkit.Location{Line: math.MaxInt}}
if info, ok := metadata.Lookup(configkit.Keys("characters", id)); ok && len(info.Origin.Locations) != 0 {
entry.location = info.Origin.Locations[0]
} }
fields := make(map[string]*yaml.Node, len(node.Content)/2) ordered = append(ordered, entry)
for index := 0; index < len(node.Content); index += 2 {
key, value := node.Content[index], node.Content[index+1]
if key.Kind != yaml.ScalarNode || key.Tag != "!!str" {
return nil, fmt.Errorf("%s has a non-string field name", path)
} }
if key.Value == "" { sort.Slice(ordered, func(left, right int) bool {
return nil, fmt.Errorf("%s has an empty field name", path) if ordered[left].location.Line != ordered[right].location.Line {
return ordered[left].location.Line < ordered[right].location.Line
} }
if _, duplicate := fields[key.Value]; duplicate { if ordered[left].location.Column != ordered[right].location.Column {
return nil, fmt.Errorf("%s has duplicate field %q", path, key.Value) return ordered[left].location.Column < ordered[right].location.Column
} }
if partyContainsAlias(value) { return ordered[left].id < ordered[right].id
return nil, fmt.Errorf("%s.%s must not use YAML aliases", path, key.Value) })
party := &CanonicalParty{Raw: bytes.Clone(raw), Characters: make([]PartyCharacter, 0, len(ordered))}
for _, orderedCharacter := range ordered {
input := result.Config.Characters[orderedCharacter.id]
classes := make([]PartyClass, len(input.Character.Classes))
for index, class := range input.Character.Classes {
classes[index] = PartyClass{Name: class.Name, Level: class.Level}
} }
fields[key.Value] = value party.Characters = append(party.Characters, PartyCharacter{
ID: orderedCharacter.id,
Player: PartyPlayer{Name: input.Player.Name},
Character: PartyCharacterDetails{
Name: input.Character.Name, Aliases: append([]string(nil), input.Character.Aliases...), Classes: classes,
},
})
} }
return fields, nil return party
}
func partyMappingOrder(node *yaml.Node) []string {
keys := make([]string, 0, len(node.Content)/2)
for index := 0; index < len(node.Content); index += 2 {
keys = append(keys, node.Content[index].Value)
}
return keys
}
func partyKnownFields(fields map[string]*yaml.Node, path string, allowed ...string) error {
for name := range fields {
known := false
for _, candidate := range allowed {
if name == candidate {
known = true
break
}
}
if !known {
return fmt.Errorf("%s has unknown field %q", path, name)
}
}
return nil
}
func partyContainsAlias(node *yaml.Node) bool {
if node == nil {
return false
}
if node.Kind == yaml.AliasNode {
return true
}
for _, child := range node.Content {
if partyContainsAlias(child) {
return true
}
}
return false
} }
// ClassSummary preserves the declaration order required by the party contract. // ClassSummary preserves the declaration order required by the party contract.
@@ -429,16 +281,12 @@ func (p *CanonicalParty) PlayersYAML() ([]byte, error) {
projection := struct { projection := struct {
SchemaVersion string `yaml:"schema_version"` SchemaVersion string `yaml:"schema_version"`
Players []projectionPlayer `yaml:"players"` Players []projectionPlayer `yaml:"players"`
}{ }{SchemaVersion: PlayersSchemaVersion, Players: make([]projectionPlayer, 0, len(characters))}
SchemaVersion: PlayersSchemaVersion,
Players: make([]projectionPlayer, 0, len(characters)),
}
for _, character := range characters { for _, character := range characters {
projection.Players = append(projection.Players, projectionPlayer{ projection.Players = append(projection.Players, projectionPlayer{
Name: character.Player.Name, Name: character.Player.Name,
Character: projectionCharacter{ Character: projectionCharacter{
ID: character.ID, ID: character.ID, Name: character.Character.Name,
Name: character.Character.Name,
Aliases: append([]string(nil), character.Character.Aliases...), Aliases: append([]string(nil), character.Character.Aliases...),
}, },
}) })

View File

@@ -8,8 +8,6 @@ import (
"gitea.maximumdirect.net/eric/narratio/internal/fileops" "gitea.maximumdirect.net/eric/narratio/internal/fileops"
) )
const maxPartyDocumentBytes = 8 << 20
func resolvePartyInput(input ResolvedInputFile) (ResolvedParty, error) { func resolvePartyInput(input ResolvedInputFile) (ResolvedParty, error) {
configuredPath := strings.TrimSpace(input.Path) configuredPath := strings.TrimSpace(input.Path)
if configuredPath == "" { if configuredPath == "" {
@@ -24,7 +22,7 @@ func resolvePartyInput(input ResolvedInputFile) (ResolvedParty, error) {
path = filepath.Join(filepath.Dir(configPath), path) path = filepath.Join(filepath.Dir(configPath), path)
} }
path = filepath.Clean(path) path = filepath.Clean(path)
raw, err := fileops.ReadFileBounded(path, maxPartyDocumentBytes) raw, err := fileops.ReadFileBounded(path, maxConfigDocumentBytes)
if err != nil { if err != nil {
return ResolvedParty{}, fmt.Errorf("read party input %q: %w", path, err) return ResolvedParty{}, fmt.Errorf("read party input %q: %w", path, err)
} }

View File

@@ -65,9 +65,9 @@ func TestCanonicalPartyRejectsSeparatePlayersAndSessionPartyOverrides(t *testing
sessionExtra string sessionExtra string
want string want string
}{ }{
{name: "campaign players", campaignExtra: " players_file: players.yml\n", want: "field players_file not found"}, {name: "campaign players", campaignExtra: " players_file: players.yml\n", want: "/inputs/players_file"},
{name: "session players", sessionExtra: " players_file: players.yml\n", want: "field players_file not found"}, {name: "session players", sessionExtra: " players_file: players.yml\n", want: "/inputs/players_file"},
{name: "session party", sessionExtra: " party_file: party-override.yml\n", want: "field party_file not found"}, {name: "session party", sessionExtra: " party_file: party-override.yml\n", want: "/inputs/party_file"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {

View File

@@ -60,26 +60,26 @@ characters:
want string want string
}{ }{
{name: "unsupported version", yaml: strings.Replace(valid, PartySchemaVersion, "narratio.party.v2", 1), want: "unsupported"}, {name: "unsupported version", yaml: strings.Replace(valid, PartySchemaVersion, "narratio.party.v2", 1), want: "unsupported"},
{name: "non-string version", yaml: strings.Replace(valid, PartySchemaVersion, "1", 1), want: "must be a non-empty string"}, {name: "non-string version", yaml: strings.Replace(valid, PartySchemaVersion, "1", 1), want: "version.type"},
{name: "unknown top field", yaml: valid + "unknown: true\n", want: "unknown field"}, {name: "unknown top field", yaml: valid + "unknown: true\n", want: "decode.unknown_field"},
{name: "unknown nested field", yaml: strings.Replace(valid, " name: Eric\n", " name: Eric\n role: GM\n", 1), want: "unknown field"}, {name: "unknown nested field", yaml: strings.Replace(valid, " name: Eric\n", " name: Eric\n role: GM\n", 1), want: "decode.unknown_field"},
{name: "trailing document", yaml: valid + "---\ncharacters: {}\n", want: "exactly one YAML document"}, {name: "trailing document", yaml: valid + "---\ncharacters: {}\n", want: "yaml.multiple_documents"},
{name: "empty characters", yaml: "schema_version: narratio.party.v1\ncharacters: {}\n", want: "must be non-empty"}, {name: "empty characters", yaml: "schema_version: narratio.party.v1\ncharacters: {}\n", want: "must be non-empty"},
{name: "invalid id", yaml: strings.Replace(valid, " arannis:", " bad-id:", 1), want: "invalid character id"}, {name: "invalid id", yaml: strings.Replace(valid, " arannis:", " bad-id:", 1), want: "invalid character id"},
{name: "whitespace id", yaml: strings.Replace(valid, " arannis:", " ' arannis':", 1), want: "invalid character id"}, {name: "whitespace id", yaml: strings.Replace(valid, " arannis:", " ' arannis':", 1), want: "invalid character id"},
{name: "missing player name", yaml: strings.Replace(valid, "player:\n name: Eric", "player: {}", 1), want: "player.name is required"}, {name: "missing player name", yaml: strings.Replace(valid, "player:\n name: Eric", "player: {}", 1), want: "/characters/arannis/player/name"},
{name: "missing character name", yaml: strings.Replace(valid, " name: Arannis\n", "", 1), want: "character.name is required"}, {name: "missing character name", yaml: strings.Replace(valid, " name: Arannis\n", "", 1), want: "/characters/arannis/character/name"},
{name: "blank player name", yaml: strings.Replace(valid, "name: Eric", "name: ' Eric'", 1), want: "non-empty and trimmed"}, {name: "blank player name", yaml: strings.Replace(valid, "name: Eric", "name: ' Eric'", 1), want: "non-empty and trimmed"},
{name: "control character", yaml: strings.Replace(valid, "name: Arannis", "name: \"Ara\\tnnis\"", 1), want: "control characters"}, {name: "control character", yaml: strings.Replace(valid, "name: Arannis", "name: \"Ara\\tnnis\"", 1), want: "control characters"},
{name: "non-string display", yaml: strings.Replace(valid, "name: Eric", "name: 42", 1), want: "display string"}, {name: "non-string display", yaml: strings.Replace(valid, "name: Eric", "name: 42", 1), want: "decode.type"},
{name: "missing classes", yaml: strings.Replace(valid, " classes:\n - name: wizard\n level: 8\n", "", 1), want: "classes is required"}, {name: "missing classes", yaml: strings.Replace(valid, " classes:\n - name: wizard\n level: 8\n", "", 1), want: "classes must be a non-empty list"},
{name: "empty classes", yaml: strings.Replace(valid, " classes:\n - name: wizard\n level: 8\n", " classes: []\n", 1), want: "non-empty list"}, {name: "empty classes", yaml: strings.Replace(valid, " classes:\n - name: wizard\n level: 8\n", " classes: []\n", 1), want: "non-empty list"},
{name: "zero level", yaml: strings.Replace(valid, "level: 8", "level: 0", 1), want: "positive integer"}, {name: "zero level", yaml: strings.Replace(valid, "level: 8", "level: 0", 1), want: "positive integer"},
{name: "negative level", yaml: strings.Replace(valid, "level: 8", "level: -2", 1), want: "positive integer"}, {name: "negative level", yaml: strings.Replace(valid, "level: 8", "level: -2", 1), want: "positive integer"},
{name: "non-integer level", yaml: strings.Replace(valid, "level: 8", "level: eight", 1), want: "positive integer"}, {name: "non-integer level", yaml: strings.Replace(valid, "level: 8", "level: eight", 1), want: "decode.type"},
{name: "duplicate class", yaml: strings.Replace(valid, " level: 8\n", " level: 8\n - name: WIZARD\n", 1), want: "duplicate class"}, {name: "duplicate class", yaml: strings.Replace(valid, " level: 8\n", " level: 8\n - name: WIZARD\n", 1), want: "duplicate class"},
{name: "duplicate field", yaml: strings.Replace(valid, " name: Eric\n", " name: Eric\n name: Erin\n", 1), want: "duplicate field"}, {name: "duplicate field", yaml: strings.Replace(valid, " name: Eric\n", " name: Eric\n name: Erin\n", 1), want: "yaml.duplicate_key"},
{name: "yaml alias", yaml: strings.Replace(strings.Replace(valid, " name: Eric", " name: &player Eric", 1), " name: Arannis", " name: *player", 1), want: "must not use YAML aliases"}, {name: "yaml alias", yaml: strings.Replace(strings.Replace(valid, " name: Eric", " name: &player Eric", 1), " name: Arannis", " name: *player", 1), want: "yaml.anchor"},
} }
for _, test := range tests { for _, test := range tests {
@@ -242,6 +242,9 @@ characters:
if err != nil { if err != nil {
t.Fatalf("ParseParty() error = %v", err) t.Fatalf("ParseParty() error = %v", err)
} }
if len(document.Characters) != 2 || document.Characters[0].ID != "zeta" || document.Characters[1].ID != "alpha" {
t.Fatalf("authored character order = %#v, want zeta then alpha", document.Characters)
}
got, err := document.PlayersYAML() got, err := document.PlayersYAML()
if err != nil { if err != nil {
t.Fatalf("PlayersYAML() error = %v", err) t.Fatalf("PlayersYAML() error = %v", err)

View File

@@ -65,9 +65,13 @@ func loadPipelineWithConfigKit(path string, opts PipelineLoadOptions) (*Pipeline
} }
func describePipelineLoadError(err error) error { func describePipelineLoadError(err error) error {
return describeConfigKitLoadError("pipeline", err)
}
func describeConfigKitLoadError(kind string, err error) error {
var loadError *configkit.LoadError var loadError *configkit.LoadError
if !errors.As(err, &loadError) { if !errors.As(err, &loadError) {
return fmt.Errorf("load pipeline config: %w", err) return fmt.Errorf("load %s config: %w", kind, err)
} }
var diagnostics bytes.Buffer var diagnostics bytes.Buffer
resolved := loadError.Diagnostics() resolved := loadError.Diagnostics()
@@ -92,9 +96,9 @@ func describePipelineLoadError(err error) error {
} }
} }
if writeErr := configkit.WriteDiagnostics(&diagnostics, resolved); writeErr != nil { if writeErr := configkit.WriteDiagnostics(&diagnostics, resolved); writeErr != nil {
return fmt.Errorf("load pipeline config: %w", err) return fmt.Errorf("load %s config: %w", kind, err)
} }
return fmt.Errorf("load pipeline config: strict decode failed or validation failed: %w: %s", err, strings.TrimSpace(diagnostics.String())) return fmt.Errorf("load %s config: strict decode failed or validation failed: %w: %s", kind, err, strings.TrimSpace(diagnostics.String()))
} }
func selectedPipelineProfileName(opts PipelineLoadOptions) string { func selectedPipelineProfileName(opts PipelineLoadOptions) string {