Compose glossary collections through configuration
This commit is contained in:
@@ -388,6 +388,11 @@ roadmap's selected flag combinations; avoid retesting the full upstream parser.
|
||||
|
||||
## Stage 9: Compose and validate native glossary definitions
|
||||
|
||||
**Completed.** Typed glossary definitions use native identified collection
|
||||
composition and main-relative optional imports. Application source evidence
|
||||
includes consumed glossary files; selection resolution and runtime consumers
|
||||
remain assigned below. Focused configuration tests passed.
|
||||
|
||||
Own the glossary definition types and application load inputs. Read
|
||||
[glossary ownership and named collections](glossary-stages.md#glossary-configuration-ownership--resolved)
|
||||
through its default-source and entry-contract sections.
|
||||
|
||||
@@ -115,13 +115,27 @@ func defaults() Config {
|
||||
}
|
||||
}
|
||||
|
||||
const glossaryImports = `config:
|
||||
imports:
|
||||
- path: glossary.yml
|
||||
required: false
|
||||
destination: [glossary_collections]
|
||||
- dir: glossary
|
||||
required: false
|
||||
recursive: true
|
||||
pattern: ["*.yml", "*.yaml"]
|
||||
destination: [glossary_collections]
|
||||
`
|
||||
|
||||
// Load resolves the selected main source and preserves native source evidence.
|
||||
func Load(input ck.Input, overlay string, overrides ...ck.Override) (*ck.Result[Config], error) {
|
||||
input.Imports = true
|
||||
return ck.LoadLayered(input, ck.LayeredOptions[Config]{
|
||||
Overlay: overlay, Overrides: overrides,
|
||||
Underlays: []ck.Underlay{{Inputs: []ck.Input{{Source: ck.Bytes("Audita glossary imports", []byte(glossaryImports)), Imports: true, MainRelative: ck.MainRelative()}}}},
|
||||
Definition: ck.Definition[Config]{
|
||||
Defaults: defaults,
|
||||
Defaults: defaults,
|
||||
SequenceIdentities: []ck.SequenceIdentity{ck.SequenceIdentityMatching(ck.SequenceSelector{ck.SequenceLiteralKey("glossary_collections"), ck.SequenceAnyMappingKey(), ck.SequenceLiteralKey("entries")}, "id")},
|
||||
Version: &ck.VersionRule{Path: ck.Keys("version"), Check: func(version ck.Version, v *ck.Validation) error {
|
||||
if version.Present && (version.Null || version.Text != "3") {
|
||||
v.Error(ck.Keys("version"), "use application configuration version 3; see docs/migrations/v3.md")
|
||||
@@ -139,6 +153,18 @@ func validate(c *Config, v *ck.Validation) {
|
||||
v.Error(path, message)
|
||||
}
|
||||
}
|
||||
for name, collection := range c.GlossaryCollections {
|
||||
for i, entry := range collection.Entries {
|
||||
path := ck.Keys("glossary_collections", name, "entries").Index(i)
|
||||
check(entry.ID != "", path.Key("id"), "entry id must not be empty")
|
||||
check(entry.Name != "", path.Key("name"), "entry name must not be empty")
|
||||
check(entry.Category != "", path.Key("category"), "entry category must not be empty")
|
||||
for index, alias := range entry.Aliases {
|
||||
check(alias != "", path.Key("aliases").Index(index), "alias must not be empty")
|
||||
}
|
||||
check(entry.Plural == nil || *entry.Plural != "", path.Key("plural"), "plural must not be empty")
|
||||
}
|
||||
}
|
||||
check(c.LLM.TransientRetries >= 0, ck.Keys("llm", "transient_retries"), "retry budget must be non-negative")
|
||||
check(c.LLM.RepairAttempts >= 0 && c.LLM.RepairAttempts <= 3, ck.Keys("llm", "repair_attempts"), "repair attempts must be between zero and three")
|
||||
check(c.LLM.OperationTimeout > 0, ck.Keys("llm", "operation_timeout"), "operation timeout must be positive")
|
||||
|
||||
139
internal/core/config/glossary_test.go
Normal file
139
internal/core/config/glossary_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
ck "go.maximumdirect.net/configkit"
|
||||
)
|
||||
|
||||
func TestGlossarySourcesComposeAndRetainExtensions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, data string) {
|
||||
t.Helper()
|
||||
file := filepath.Join(dir, name)
|
||||
if err := os.MkdirAll(filepath.Dir(file), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(file, []byte(data), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("config.yml", `version: 3
|
||||
config:
|
||||
imports: []
|
||||
glossary_collections:
|
||||
party:
|
||||
entries:
|
||||
- id: hero
|
||||
name: Revised
|
||||
category: character
|
||||
level: 5
|
||||
`)
|
||||
write("glossary.yml", `config:
|
||||
imports:
|
||||
- path: shared/extra.yml
|
||||
required: true
|
||||
party:
|
||||
entries:
|
||||
- id: hero
|
||||
name: Original
|
||||
category: character
|
||||
aliases: [Old]
|
||||
- id: other
|
||||
name: Other
|
||||
category: place
|
||||
`)
|
||||
write("shared/extra.yml", "lore:\n entries:\n - id: hero\n name: Ancient\n category: lore\n")
|
||||
write("glossary/nested/spells.yaml", "spells:\n entries:\n - id: bolt\n name: Bolt\n category: spell\n aliases: [Spark]\n plural: Bolts\n extra: {rank: 2}\n")
|
||||
result, err := Load(ck.Input{Source: ck.File(filepath.Join(dir, "config.yml"))}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%v: %+v", err, err.(*ck.LoadError).Diagnostics())
|
||||
}
|
||||
party := result.Config.GlossaryCollections["party"].Entries
|
||||
if len(party) != 2 || party[0].Name != "Revised" || len(party[0].Aliases) != 0 || party[1].ID != "other" {
|
||||
t.Fatalf("party=%+v", party)
|
||||
}
|
||||
if party[0].Extra["level"] == nil {
|
||||
t.Fatalf("extension missing: %+v", party[0])
|
||||
}
|
||||
if got := result.Config.GlossaryCollections["lore"].Entries; len(got) != 1 || got[0].ID != "hero" {
|
||||
t.Fatalf("lore=%+v", got)
|
||||
}
|
||||
discovered, err := Load(ck.Input{Source: ck.SearchConfig("audita", ck.SearchDirectory(dir))}, "")
|
||||
if err != nil || len(discovered.Config.GlossaryCollections["party"].Entries) != 2 {
|
||||
t.Fatalf("discovered glossary: %+v %v", discovered, err)
|
||||
}
|
||||
spells := result.Config.GlossaryCollections["spells"].Entries
|
||||
if len(spells) != 1 || spells[0].Plural == nil || *spells[0].Plural != "Bolts" || spells[0].Extra["extra"] == nil {
|
||||
t.Fatalf("spells=%+v", spells)
|
||||
}
|
||||
var paths []string
|
||||
for _, source := range result.Sources() {
|
||||
if source.Kind == ck.SourceKindFile {
|
||||
paths = append(paths, source.Path)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"glossary.yml", "shared/extra.yml", "glossary/nested/spells.yaml"} {
|
||||
if !slices.Contains(paths, filepath.Join(dir, name)) {
|
||||
t.Errorf("missing source %s: %v", name, paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlossaryValidationAndPeerConflicts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
main := filepath.Join(dir, "config.yml")
|
||||
write := func(path, data string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(data), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, data := range []string{
|
||||
"glossary_collections:\n empty: {entries: []}\n",
|
||||
"glossary_collections:\n x:\n entries:\n - {id: 'id with spaces', name: N, category: C}\n",
|
||||
} {
|
||||
write(main, data)
|
||||
if _, err := Load(ck.Input{Source: ck.File(main)}, ""); err != nil {
|
||||
t.Fatalf("valid %q: %v", data, err)
|
||||
}
|
||||
}
|
||||
for _, data := range []string{
|
||||
"glossary_collections:\n x:\n entries:\n - {id: '', name: N, category: C}\n",
|
||||
"glossary_collections:\n x:\n entries:\n - {id: x, name: '', category: C}\n",
|
||||
"glossary_collections:\n x:\n entries:\n - {id: x, name: N, category: C, aliases: ['']}\n",
|
||||
"glossary_collections:\n x:\n entries:\n - {id: x, name: N, category: C, plural: ''}\n",
|
||||
} {
|
||||
write(main, data)
|
||||
if _, err := Load(ck.Input{Source: ck.File(main)}, ""); err == nil {
|
||||
t.Fatalf("accepted %q", data)
|
||||
}
|
||||
}
|
||||
write(main, "version: 3\n")
|
||||
write(filepath.Join(dir, "glossary.yml"), "x:\n entries:\n - {id: same, name: One, category: C}\n")
|
||||
if err := os.Mkdir(filepath.Join(dir, "glossary"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(filepath.Join(dir, "glossary", "other.yml"), "x:\n entries:\n - {id: same, name: Two, category: C}\n")
|
||||
if _, err := Load(ck.Input{Source: ck.File(main)}, ""); err == nil {
|
||||
t.Fatal("peer conflict accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlossaryUnderlaySkipsAbsentMain(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "glossary.yml"), []byte("leak:\n entries:\n - {id: a, name: A, category: C}\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(dir)
|
||||
result, err := Load(ck.Input{Source: ck.NoSource()}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Config.GlossaryCollections) != 0 {
|
||||
t.Fatalf("unanchored glossary loaded: %+v", result.Config.GlossaryCollections)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user