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
**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.

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.
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")

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