diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 961fdaf..70d2f70 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/internal/core/config/config.go b/internal/core/config/config.go index 4a932e3..1c08dea 100644 --- a/internal/core/config/config.go +++ b/internal/core/config/config.go @@ -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") diff --git a/internal/core/config/glossary_test.go b/internal/core/config/glossary_test.go new file mode 100644 index 0000000..910d307 --- /dev/null +++ b/internal/core/config/glossary_test.go @@ -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) + } +}