Compose glossary collections through configuration

This commit is contained in:
2026-11-10 17:14:31 +00:00
parent 199c1d1dbe
commit 0a21ee2b50
3 changed files with 171 additions and 1 deletions

View File

@@ -388,6 +388,11 @@ roadmap's selected flag combinations; avoid retesting the full upstream parser.
## Stage 9: Compose and validate native glossary definitions ## 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 Own the glossary definition types and application load inputs. Read
[glossary ownership and named collections](glossary-stages.md#glossary-configuration-ownership--resolved) [glossary ownership and named collections](glossary-stages.md#glossary-configuration-ownership--resolved)
through its default-source and entry-contract sections. through its default-source and entry-contract sections.

View File

@@ -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. // 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) { func Load(input ck.Input, overlay string, overrides ...ck.Override) (*ck.Result[Config], error) {
input.Imports = true input.Imports = true
return ck.LoadLayered(input, ck.LayeredOptions[Config]{ return ck.LoadLayered(input, ck.LayeredOptions[Config]{
Overlay: overlay, Overrides: overrides, 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]{ 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 { Version: &ck.VersionRule{Path: ck.Keys("version"), Check: func(version ck.Version, v *ck.Validation) error {
if version.Present && (version.Null || version.Text != "3") { if version.Present && (version.Null || version.Text != "3") {
v.Error(ck.Keys("version"), "use application configuration version 3; see docs/migrations/v3.md") 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) 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.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.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") check(c.LLM.OperationTimeout > 0, ck.Keys("llm", "operation_timeout"), "operation timeout must be positive")

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