diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9b00077..21a140c 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Sequence Identity And Combination Implementation Plan -Status: implementation in progress; Stages 1–27 complete. +Status: implementation in progress; Stages 1–28 complete. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. This plan starts at commit @@ -948,6 +948,23 @@ Scope: a small number of meaningful integrations, not a Cartesian test matrix. Validation: `go test ./examples/consumers`, focused root integration tests, `go test -race ./...`, and the everyday check. +**Completion:** Added an executable source-backed catalog that combines an +underlay identity declaration, imported main fragments, identified record +inheritance, all three selected overlay operations, typed CLI assignment, and +recorded application resolution. Its checks demonstrate stable operation order, +complete matched-record replacement, final metadata and history inspection, +inherited resource lookup through source context, and secret-safe explanation. +Added a typed-map example with exact and wildcard registrations, independent +uniqueness across concrete collections, and a YAML-only identity example that +shows the absence of final application enforcement without registration. Audited +the existing helper-composed parent reference, source-version control consumption, +pending declarations, lower-tier identity before peer composition, and policy +replacement ownership coverage; added a consumer regression for a dynamic +collection destination introduced by inheritance. Verified +`go test ./examples/consumers -count=1`, +`go test . -run 'Facade.*(Inheritance|Sequence|Override)' -count=1`, +`go test -race ./...`, and `./scripts/check.sh`. + ## Stage 29 — Reconcile Documentation Scope: finish the current-contract narrative. Code cleanup belongs with the diff --git a/examples/consumers/sequence_catalog.go b/examples/consumers/sequence_catalog.go new file mode 100644 index 0000000..3685e82 --- /dev/null +++ b/examples/consumers/sequence_catalog.go @@ -0,0 +1,134 @@ +package consumers + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +// SequenceCatalogConfig is a source-backed prompt catalog with an application +// supplied run label and a value derived from that label during resolution. +type SequenceCatalogConfig struct { + RunLabel string `yaml:"run_label"` + ResolvedLabel string `yaml:"resolved_label"` + Prompts []SequenceCatalogEntry `yaml:"prompts"` +} + +// SequenceCatalogEntry keeps prompt meaning, resources, and credentials in the +// consuming application while ConfigKit handles collection assembly. +type SequenceCatalogEntry struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Description string `yaml:"description"` + Messages []string `yaml:"messages"` + Resource string `yaml:"resource"` + Credential configkit.Secret `yaml:"credential"` +} + +// LoadSequenceCatalog assembles imported main fragments over a file-backed +// underlay, applies one selected collection overlay, then resolves and validates +// the resulting application configuration. +func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Result[SequenceCatalogConfig], error) { + root := filepath.Dir(mainPath) + filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root)) + filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem} + definition := configkit.Definition[SequenceCatalogConfig]{ + SequenceIdentities: []configkit.SequenceIdentity{ + configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"), + }, + Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error { + configuration.ResolvedLabel = "for:" + configuration.RunLabel + resolution.Record(configkit.Keys("resolved_label"), configkit.ChangeNormalization, + "derive the resolved label from the selected run label", configkit.Keys("run_label")) + return nil + }, + Validate: validateSequenceCatalog, + } + return configkit.LoadLayered(configkit.Input{ + Source: filesystem.File(filepath.Base(mainPath)), + Imports: true, + }, configkit.LayeredOptions[SequenceCatalogConfig]{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{ + Source: filesystem.File("underlay.yml"), + }}}}, + Overlay: overlayName, + OverlayFilesystem: filesystemContext, + Overrides: []configkit.Override{{ + Path: configkit.Keys("run_label"), Value: runLabel, Flag: "--run-label", + }}, + Definition: definition, + }) +} + +// ReadSequenceCatalogResource follows the final prompt field's source context, +// including when record inheritance retained a resource from an underlay. +func ReadSequenceCatalogResource(result *configkit.Result[SequenceCatalogConfig], promptID string) (string, error) { + if result == nil { + return "", errors.New("sequence catalog result is nil") + } + for index, prompt := range result.Config.Prompts { + if prompt.ID != promptID { + continue + } + resourcePath := configkit.Keys("prompts").Index(index).Key("resource") + info, found := result.Metadata().Lookup(resourcePath) + if !found || len(info.Origin.Locations) != 1 { + return "", errors.New("prompt resource has no single declaring source") + } + context, found := result.SourceContext(info.Origin.Locations[0].SourceID) + if !found { + return "", errors.New("prompt resource source context is unavailable") + } + data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.Resource)) + if err != nil { + return "", fmt.Errorf("read prompt resource: %w", err) + } + return string(data), nil + } + return "", fmt.Errorf("prompt %q is unavailable", promptID) +} + +func validateSequenceCatalog(configuration *SequenceCatalogConfig, validation *configkit.Validation) { + for index, prompt := range configuration.Prompts { + if strings.TrimSpace(prompt.Name) == "" { + validation.Error(configkit.Keys("prompts").Index(index).Key("name"), "must not be empty") + } + if len(prompt.Messages) == 0 { + validation.Error(configkit.Keys("prompts").Index(index).Key("messages"), "must contain at least one message") + } + } + if len(configuration.Prompts) == 0 { + validation.Error(configkit.Keys("prompts"), "must contain at least one prompt") + } +} + +// NamespacedMapCatalogConfig demonstrates typed application maps whose +// independent prompt sequences use root and dynamic namespace registrations. +type NamespacedMapCatalogConfig struct { + Prompts []map[string]any `yaml:"prompts"` + Collections map[string]map[string]any `yaml:"collections"` +} + +// LoadNamespacedMapCatalog applies the same scalar identity constraint to one +// root collection and each namespace's independently scoped collection. +func LoadNamespacedMapCatalog(source configkit.Source) (*configkit.Result[NamespacedMapCatalogConfig], error) { + selector := configkit.SequenceSelector{ + configkit.SequenceLiteralKey("collections"), + configkit.SequenceAnyMappingKey(), + configkit.SequenceLiteralKey("prompts"), + } + return configkit.Load(source, configkit.Options[NamespacedMapCatalogConfig]{ + Definition: configkit.Definition[NamespacedMapCatalogConfig]{ + SequenceIdentities: []configkit.SequenceIdentity{ + configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"), + configkit.SequenceIdentityMatching(selector, "id"), + }, + }, + }) +} diff --git a/examples/consumers/sequence_catalog_test.go b/examples/consumers/sequence_catalog_test.go new file mode 100644 index 0000000..1d6b8ba --- /dev/null +++ b/examples/consumers/sequence_catalog_test.go @@ -0,0 +1,239 @@ +package consumers_test + +import ( + "bytes" + "errors" + "fmt" + "os" + "strings" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/ConfigKit/examples/consumers" +) + +func Example_sequenceCatalog() { + const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET" + const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET" + restoreOld, restoreNew := preserveEnvironment(oldSecret), preserveEnvironment(newSecret) + defer restoreOld() + defer restoreNew() + _ = os.Setenv(oldSecret, "disposable-old-example-credential") + _ = os.Setenv(newSecret, "disposable-new-example-credential") + + result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), "upsert", "preview") + if err != nil { + fmt.Println(err) + return + } + ids := make([]string, 0, len(result.Config.Prompts)) + for _, prompt := range result.Config.Prompts { + ids = append(ids, prompt.ID) + } + shared := configkit.Keys("prompts").Index(1).Key("name") + info, _ := result.Metadata().Lookup(shared) + resource, resourceErr := consumers.ReadSequenceCatalogResource(result, "concise") + fmt.Println(ids, result.Config.ResolvedLabel, info.Explicit, len(result.Metadata().History(shared)), resource, resourceErr == nil, err) + // Output: [foundation shared welcome concise extra] for:preview true 2 Foundation resource text. + // true +} + +func TestSequenceCatalogSelectedOverlayOperations(t *testing.T) { + const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET" + const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET" + t.Setenv(oldSecret, "sequence-catalog-old-canary") + t.Setenv(newSecret, "sequence-catalog-new-canary") + + tests := []struct { + name string + wantIDs []string + shared *consumers.SequenceCatalogEntry + }{ + {name: "append", wantIDs: []string{"foundation", "shared", "welcome", "concise", "extra"}}, + {name: "upsert", wantIDs: []string{"foundation", "shared", "welcome", "concise", "extra"}, shared: &consumers.SequenceCatalogEntry{ID: "shared", Name: "Shared replacement", Messages: []string{"Replacement message"}, Credential: configkit.SecretEnv(newSecret)}}, + {name: "replace", wantIDs: []string{"replacement", "last"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), test.name, "test") + if err != nil { + var loadError *configkit.LoadError + if errors.As(err, &loadError) { + t.Fatalf("load selected overlay: %v (%#v)", err, loadError.Diagnostics()) + } + t.Fatal(err) + } + gotIDs := make([]string, 0, len(result.Config.Prompts)) + for _, prompt := range result.Config.Prompts { + gotIDs = append(gotIDs, prompt.ID) + } + if strings.Join(gotIDs, ",") != strings.Join(test.wantIDs, ",") { + t.Fatalf("prompt order = %v, want %v", gotIDs, test.wantIDs) + } + if test.shared == nil { + return + } + shared := result.Config.Prompts[1] + if shared.ID != test.shared.ID || shared.Name != test.shared.Name || len(shared.Messages) != 1 || + shared.Messages[0] != test.shared.Messages[0] || shared.Description != "" || shared.Resource != "" || shared.Credential.EnvName() != newSecret { + t.Fatalf("upsert did not replace the complete matched record: %#v", shared) + } + }) + } +} + +func TestSequenceCatalogInspectionTracksResourcesAndSecretHistory(t *testing.T) { + const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET" + const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET" + const oldCanary = "sequence-catalog-old-canary" + const newCanary = "sequence-catalog-new-canary" + t.Setenv(oldSecret, oldCanary) + t.Setenv(newSecret, newCanary) + + result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), "upsert", "history") + if err != nil { + var loadError *configkit.LoadError + if errors.As(err, &loadError) { + t.Fatalf("load sequence catalog: %v (%#v)", err, loadError.Diagnostics()) + } + t.Fatal(err) + } + resourcePath := configkit.Keys("prompts").Index(3).Key("resource") + resourceInfo, found := result.Metadata().Lookup(resourcePath) + if !found || len(resourceInfo.Origin.Locations) != 1 || len(resourceInfo.Origin.Inheritance) != 1 { + t.Fatalf("inherited resource inspection = %#v, %v", resourceInfo, found) + } + resource, err := consumers.ReadSequenceCatalogResource(result, "concise") + if err != nil || resource != "Foundation resource text.\n" { + t.Fatalf("resource from inherited source context = %q, %v", resource, err) + } + if len(result.Metadata().History(configkit.Keys("resolved_label"))) == 0 { + t.Fatal("recorded CLI-driven resolution was absent from final inspection") + } + credentialPath := configkit.Keys("prompts").Index(1).Key("credential") + credentialHistory := result.Metadata().History(credentialPath) + if len(credentialHistory) == 0 { + t.Fatal("upsert did not retain the replaced secret reference in history") + } + var explanation bytes.Buffer + if err := result.WriteExplanation(&explanation, credentialPath); err != nil { + t.Fatal(err) + } + if strings.Contains(explanation.String(), oldCanary) || strings.Contains(explanation.String(), newCanary) { + t.Fatalf("secret explanation exposed a credential value: %s", explanation.String()) + } +} + +func TestNamespacedTypedMapIdentityScopesAreIndependent(t *testing.T) { + source := configkit.Bytes("namespaced maps", []byte(` +prompts: + - id: shared + text: root prompt +collections: + team: + prompts: + - id: shared + text: team prompt + operations: + prompts: + - id: shared + text: operations prompt +`)) + result, err := consumers.LoadNamespacedMapCatalog(source) + if err != nil { + t.Fatal(err) + } + if result.Config.Prompts[0]["text"] != "root prompt" || + result.Config.Collections["team"]["prompts"].([]any)[0].(map[string]any)["text"] != "team prompt" || + result.Config.Collections["operations"]["prompts"].([]any)[0].(map[string]any)["text"] != "operations prompt" { + t.Fatalf("typed map collections = %#v", result.Config) + } +} + +func TestNamespacedTypedMapIdentityFindsCollectionsIntroducedByInheritance(t *testing.T) { + result, err := consumers.LoadNamespacedMapCatalog(configkit.Bytes("inherited namespace", []byte(` +collections: + source: + prompts: + - id: shared + text: inherited prompt + copied: + extends: source +`))) + if err != nil { + t.Fatal(err) + } + copied := result.Config.Collections["copied"]["prompts"].([]any) + if len(copied) != 1 || copied[0].(map[string]any)["text"] != "inherited prompt" { + t.Fatalf("inherited typed-map collection = %#v", result.Config.Collections["copied"]) + } +} + +func Example_yamlDeclaredIdentityOnly() { + result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(` +configkit: + sequences: + - destination: [prompts] + key: id +prompts: + - id: initial +`)), configkit.Options[map[string]any]{ + Overrides: []configkit.Override{{ + Path: configkit.Keys("prompts"), + Value: []any{ + map[string]any{"id": "same"}, + map[string]any{"id": "same"}, + }, + Flag: "--prompts", + }}, + }) + fmt.Println(len(result.Config["prompts"].([]any)), err) + // Output: 2 +} + +func TestYAMLDeclaredIdentityDoesNotRegisterFinalTypedConstraint(t *testing.T) { + result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(` +configkit: + sequences: + - destination: [prompts] + key: id +prompts: + - id: initial +`)), configkit.Options[map[string]any]{ + Overrides: []configkit.Override{{ + Path: configkit.Keys("prompts"), + Value: []any{ + map[string]any{"id": "same"}, + map[string]any{"id": "same"}, + }, + Flag: "--prompts", + }}, + }) + if err != nil { + t.Fatalf("YAML-only identity incorrectly became an application constraint: %v", err) + } + prompts := result.Config["prompts"].([]any) + if len(prompts) != 2 || prompts[0].(map[string]any)["id"] != prompts[1].(map[string]any)["id"] { + t.Fatalf("CLI result = %#v", result.Config) + } +} + +func TestNamespacedTypedMapRejectsDuplicatesWithinOneCollection(t *testing.T) { + _, err := consumers.LoadNamespacedMapCatalog(configkit.Bytes("duplicate namespace", []byte(` +collections: + team: + prompts: + - id: duplicate + - id: duplicate +`))) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || + loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict || + loadError.Diagnostics()[0].Path.String() != "/collections/team/prompts" { + diagnostics := []configkit.Diagnostic(nil) + if loadError != nil { + diagnostics = loadError.Diagnostics() + } + t.Fatalf("duplicate namespaced identity error = %v (%#v)", err, diagnostics) + } +} diff --git a/examples/consumers/testdata/sequence-catalog/config.yml b/examples/consumers/testdata/sequence-catalog/config.yml new file mode 100644 index 0000000..cc2d08c --- /dev/null +++ b/examples/consumers/testdata/sequence-catalog/config.yml @@ -0,0 +1,7 @@ +run_label: source-default +configkit: + imports: + - dir: fragments + pattern: '*.yml' + destination: [prompts] + placement: sequence-item diff --git a/examples/consumers/testdata/sequence-catalog/foundation.txt b/examples/consumers/testdata/sequence-catalog/foundation.txt new file mode 100644 index 0000000..147dc29 --- /dev/null +++ b/examples/consumers/testdata/sequence-catalog/foundation.txt @@ -0,0 +1 @@ +Foundation resource text. diff --git a/examples/consumers/testdata/sequence-catalog/fragments/01-welcome.yml b/examples/consumers/testdata/sequence-catalog/fragments/01-welcome.yml new file mode 100644 index 0000000..d9df867 --- /dev/null +++ b/examples/consumers/testdata/sequence-catalog/fragments/01-welcome.yml @@ -0,0 +1,4 @@ +id: welcome +name: Welcome +messages: [Welcome message] +credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET diff --git a/examples/consumers/testdata/sequence-catalog/fragments/02-concise.yml b/examples/consumers/testdata/sequence-catalog/fragments/02-concise.yml new file mode 100644 index 0000000..e350f9a --- /dev/null +++ b/examples/consumers/testdata/sequence-catalog/fragments/02-concise.yml @@ -0,0 +1,3 @@ +id: concise +extends: {id: foundation} +name: Concise diff --git a/examples/consumers/testdata/sequence-catalog/overlays/append.yml b/examples/consumers/testdata/sequence-catalog/overlays/append.yml new file mode 100644 index 0000000..8898d4d --- /dev/null +++ b/examples/consumers/testdata/sequence-catalog/overlays/append.yml @@ -0,0 +1,10 @@ +configkit: + sequences: + - destination: [prompts] + key: id + mode: append +prompts: + - id: extra + name: Extra + messages: [Extra message] + credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET diff --git a/examples/consumers/testdata/sequence-catalog/overlays/replace.yml b/examples/consumers/testdata/sequence-catalog/overlays/replace.yml new file mode 100644 index 0000000..9eec06f --- /dev/null +++ b/examples/consumers/testdata/sequence-catalog/overlays/replace.yml @@ -0,0 +1,14 @@ +configkit: + sequences: + - destination: [prompts] + key: id + mode: replace +prompts: + - id: replacement + name: Replacement + messages: [Replacement message] + credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET + - id: last + name: Last + messages: [Last message] + credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET diff --git a/examples/consumers/testdata/sequence-catalog/overlays/upsert.yml b/examples/consumers/testdata/sequence-catalog/overlays/upsert.yml new file mode 100644 index 0000000..826442d --- /dev/null +++ b/examples/consumers/testdata/sequence-catalog/overlays/upsert.yml @@ -0,0 +1,14 @@ +configkit: + sequences: + - destination: [prompts] + key: id + mode: upsert +prompts: + - id: shared + name: Shared replacement + messages: [Replacement message] + credential: CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET + - id: extra + name: Extra + messages: [Extra message] + credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET diff --git a/examples/consumers/testdata/sequence-catalog/underlay.yml b/examples/consumers/testdata/sequence-catalog/underlay.yml new file mode 100644 index 0000000..58db8b6 --- /dev/null +++ b/examples/consumers/testdata/sequence-catalog/underlay.yml @@ -0,0 +1,18 @@ +configkit: + sequences: + - destination: [prompts] + key: id + mode: append +prompts: + - id: foundation + name: Foundation + description: Shared foundation instruction + messages: [Foundation message] + resource: foundation.txt + credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET + - id: shared + name: Shared draft + description: This field must not survive a whole-record replacement + messages: [Draft message] + resource: shared.txt + credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET