diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 07215e8..44cc575 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implement PromptKit comparison and catalogs -Status: Stages 1–5 complete; remaining stages are planned. +Status: Stages 1–6 complete; remaining stages are planned. ## Scope and authority @@ -369,6 +369,19 @@ in Stage 8 once helpers are wired. Test decoded types, fields, sequence order, and empty inventories; incidental quoting style, private names, and indentation are not new contracts. +**Completion evidence (2026-10-12)** + +- Added private YAML encoders that map native prompt identities to entries with + explicit string `id` and `version` fields and encode profile IDs as a bare + string sequence. Both return fully prepared bytes using the existing YAML + encoder; nil and allocated empty catalogs normalize to empty sequences. +- Unit coverage decodes prompt entries and profile values to check field names, + string types, order, duplicates, whitespace, and numeric-looking values. Nil + and allocated empty inputs both encode as `[]`. +- `GOTOOLCHAIN=local go test -count=1 .` passed in `integrations/ckprompt`. + Command-boundary schema coverage remains assigned to Stage 8; command wiring + and delivery remain assigned to Stage 7. + ## Stage 7: Assemble and execute independent catalog commands **Outcome:** catalog commands and optional named-show commands share existing diff --git a/integrations/ckprompt/command.go b/integrations/ckprompt/command.go index f9a9849..0f8b1d1 100644 --- a/integrations/ckprompt/command.go +++ b/integrations/ckprompt/command.go @@ -322,6 +322,34 @@ func encodePromptYAML(writer io.Writer, value any) error { return encoder.Encode(value) } +type promptCatalogEntry struct { + ID string `yaml:"id"` + Version string `yaml:"version"` +} + +func encodePromptCatalog(prompts []promptkit.PromptIdentity) ([]byte, error) { + entries := make([]promptCatalogEntry, len(prompts)) + for index, prompt := range prompts { + entries[index] = promptCatalogEntry{ID: prompt.ID, Version: prompt.Version} + } + return encodePromptYAMLBytes(entries) +} + +func encodeProfileCatalog(profiles []string) ([]byte, error) { + if profiles == nil { + profiles = []string{} + } + return encodePromptYAMLBytes(profiles) +} + +func encodePromptYAMLBytes(value any) ([]byte, error) { + var output bytes.Buffer + if err := encodePromptYAML(&output, value); err != nil { + return nil, err + } + return output.Bytes(), nil +} + func writePromptReport(invocation commandkit.Invocation, output []byte, diagnostics []commandkit.Diagnostic) commandkit.Outcome { if invocation.Context != nil { if err := invocation.Context.Err(); err != nil { diff --git a/integrations/ckprompt/command_test.go b/integrations/ckprompt/command_test.go index 92499e8..f202f92 100644 --- a/integrations/ckprompt/command_test.go +++ b/integrations/ckprompt/command_test.go @@ -309,3 +309,75 @@ func TestProfileTextUsesCredentialNamesAndExplicitEmptyExtraParams(t *testing.T) t.Fatalf("report contains a credential value: %q", text) } } + +func TestPromptCatalogYAMLPreservesSchemaStringsAndOrder(t *testing.T) { + want := []promptkit.PromptIdentity{ + {ID: " briefing ", Version: " 03 "}, + {ID: "001", Version: "1.10"}, + {ID: " briefing ", Version: " 03 "}, + } + output, err := encodePromptCatalog(want) + if err != nil { + t.Fatal(err) + } + var decoded []map[string]any + if err := yaml.Unmarshal(output, &decoded); err != nil { + t.Fatalf("decode prompt catalog %q: %v", output, err) + } + if len(decoded) != len(want) { + t.Fatalf("catalog entry count = %d, want %d: %q", len(decoded), len(want), output) + } + for index, entry := range decoded { + if len(entry) != 2 { + t.Errorf("entry %d fields = %#v, want only id and version", index, entry) + } + id, idOK := entry["id"].(string) + version, versionOK := entry["version"].(string) + if !idOK || !versionOK || id != want[index].ID || version != want[index].Version { + t.Errorf("entry %d = %#v, want id/version %q/%q as strings", index, entry, want[index].ID, want[index].Version) + } + } +} + +func TestCatalogYAMLEncodersRenderNilAndEmptyInventoriesAsSequences(t *testing.T) { + for _, test := range []struct { + name string + data func() ([]byte, error) + }{ + {name: "nil prompts", data: func() ([]byte, error) { return encodePromptCatalog(nil) }}, + {name: "empty prompts", data: func() ([]byte, error) { return encodePromptCatalog(make([]promptkit.PromptIdentity, 0)) }}, + {name: "nil profiles", data: func() ([]byte, error) { return encodeProfileCatalog(nil) }}, + {name: "empty profiles", data: func() ([]byte, error) { return encodeProfileCatalog(make([]string, 0)) }}, + } { + t.Run(test.name, func(t *testing.T) { + output, err := test.data() + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(output)) != "[]" { + t.Fatalf("empty catalog YAML = %q, want empty sequence", output) + } + }) + } +} + +func TestProfileCatalogYAMLPreservesStringValuesAndOrder(t *testing.T) { + want := []string{" default ", "001", "same", "same"} + output, err := encodeProfileCatalog(want) + if err != nil { + t.Fatal(err) + } + var decoded []any + if err := yaml.Unmarshal(output, &decoded); err != nil { + t.Fatalf("decode profile catalog %q: %v", output, err) + } + if len(decoded) != len(want) { + t.Fatalf("catalog entry count = %d, want %d: %q", len(decoded), len(want), output) + } + for index, item := range decoded { + value, ok := item.(string) + if !ok || value != want[index] { + t.Errorf("profile %d = %#v, want string %q", index, item, want[index]) + } + } +}