Skip to content

Commit 109670d

Browse files
committed
feat: add Category and Examples to palette descriptors
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent db5f27c commit 109670d

3 files changed

Lines changed: 157 additions & 4 deletions

File tree

state/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ ready to freeze on sign-off. The `analysis`, `evolution`, `conformance`, and
126126
- `verify` kind-specific `Finding` accessors (`IsReachable`, `Holds`, `Violated`,
127127
`Covered`).
128128
- An advisory pointer-context determinism diagnostic surfaced through Temper/Assay.
129+
- Palette completeness for builder UIs: `Descriptor.Category` (an optional grouping
130+
label such as "guards" or "side-effects"), `Descriptor.Examples` (optional example
131+
usages of a behavior), and `ParamSpec.Examples` (optional sample values for a single
132+
parameter). They are set through the new `Describe(...).Category(...)` and
133+
`Describe(...).Examples(...)` builder methods, and per-parameter examples ride
134+
`ParamSpec.Examples` via `ParamSpec`. All three are additive and `omitempty`: a
135+
descriptor or parameter that declares none serializes byte-identically to before.
129136

130137
### Changed
131138

state/palette.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ type ParamSpec struct {
7474
// Enum lists the allowed values when Type is EnumParam; it is empty for every
7575
// other type.
7676
Enum []string `json:"enum,omitempty"`
77+
// Examples lists sample values a UI can offer for this parameter — a
78+
// prefill or "did you mean" hint distinct from Default (the value used when
79+
// the parameter is omitted) and from Enum (the closed set of legal values).
80+
// It is empty unless the registration declares examples.
81+
Examples []any `json:"examples,omitempty"`
7782
}
7883

7984
// Descriptor is the serializable palette entry for one registered implementation.
@@ -86,7 +91,16 @@ type Descriptor struct {
8691
Kind DescriptorKind `json:"kind"`
8792
Name string `json:"name"`
8893
Description string `json:"description,omitempty"`
89-
Params []ParamSpec `json:"params,omitempty"`
94+
// Category is an optional grouping label a UI uses to organize behaviors in
95+
// the palette (e.g. "guards", "side-effects", "lifecycle"). It is free-form
96+
// and absent unless the registration declares one.
97+
Category string `json:"category,omitempty"`
98+
// Examples lists optional example usages of the behavior as a whole — short
99+
// snippets a UI can show as documentation, distinct from ParamSpec.Examples
100+
// (which gives sample values for one parameter). It is empty unless the
101+
// registration declares examples.
102+
Examples []string `json:"examples,omitempty"`
103+
Params []ParamSpec `json:"params,omitempty"`
90104
// Reads and Writes are optional type hints naming the entity fields the
91105
// implementation reads from or writes to, for a UI that surfaces data flow.
92106
Reads []string `json:"reads,omitempty"`
@@ -172,15 +186,18 @@ func BindingTransportOf(d Descriptor) string {
172186
// registration method supplies.
173187
type describeSpec struct {
174188
description string
189+
category string
190+
examples []string
175191
params []ParamSpec
176192
reads []string
177193
writes []string
178194
}
179195

180196
// DescribeBuilder fluently accumulates a registration's descriptor metadata —
181-
// its description, parameter schema, and read/write hints. Obtain one with
182-
// Describe, chain Param / OptionalParam / Reads / Writes, and pass it as the
183-
// trailing option to a registration (Guard / Action / Service / Actor). A
197+
// its description, category, example usages, parameter schema, and read/write
198+
// hints. Obtain one with Describe, chain Category / Examples / Param /
199+
// OptionalParam / Reads / Writes, and pass it as the trailing option to a
200+
// registration (Guard / Action / Service / Actor). A
184201
// DescribeBuilder is itself a DescribeOption, so it drops straight into the
185202
// options tail.
186203
type DescribeBuilder struct {
@@ -233,6 +250,21 @@ func (d *DescribeBuilder) EnumParam(name string, allowed ...string) *DescribeBui
233250
return d
234251
}
235252

253+
// Category sets the grouping label a UI uses to organize this behavior in the
254+
// palette (e.g. "guards", "side-effects", "lifecycle"). The last call wins.
255+
func (d *DescribeBuilder) Category(category string) *DescribeBuilder {
256+
d.spec.category = category
257+
return d
258+
}
259+
260+
// Examples records example usages of the behavior as a whole — short snippets a
261+
// UI can show as documentation. Successive calls accumulate. For sample values of
262+
// a single parameter, set ParamSpec.Examples via ParamSpec instead.
263+
func (d *DescribeBuilder) Examples(examples ...string) *DescribeBuilder {
264+
d.spec.examples = append(d.spec.examples, examples...)
265+
return d
266+
}
267+
236268
// Reads records the entity fields the implementation reads, a data-flow hint for
237269
// a UI. Successive calls accumulate.
238270
func (d *DescribeBuilder) Reads(fields ...string) *DescribeBuilder {
@@ -282,6 +314,8 @@ func descriptorFrom(kind DescriptorKind, name string, spec describeSpec) Descrip
282314
Kind: kind,
283315
Name: name,
284316
Description: spec.description,
317+
Category: spec.category,
318+
Examples: append([]string(nil), spec.examples...),
285319
Params: append([]ParamSpec(nil), spec.params...),
286320
Reads: append([]string(nil), spec.reads...),
287321
Writes: append([]string(nil), spec.writes...),

state/palette_completeness_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package state_test
2+
3+
import (
4+
"encoding/json"
5+
"reflect"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stablekernel/crucible/state"
10+
)
11+
12+
// TestPaletteCategoryAndExamplesSurface registers a behavior with a category, a
13+
// per-parameter example set, and behavior-level example usages, then asserts they
14+
// all reach the Palette descriptor and serialize under the expected JSON keys.
15+
func TestPaletteCategoryAndExamplesSurface(t *testing.T) {
16+
reg := state.NewRegistry[order]()
17+
reg.Guard("minAmount", func(c state.GuardCtx[order]) bool { return c.Entity.amount >= 1 },
18+
state.Describe("Passes when the amount is at least min.").
19+
Category("guards").
20+
Examples(`guard("minAmount", {"min": 5})`).
21+
ParamSpec(state.ParamSpec{
22+
Name: "min",
23+
Type: state.IntParam,
24+
Required: true,
25+
Examples: []any{1, 5, 100},
26+
}))
27+
28+
d := findDescriptor(t, reg.Palette(), state.KindGuard, "minAmount")
29+
if d.Category != "guards" {
30+
t.Errorf("category = %q, want %q", d.Category, "guards")
31+
}
32+
if !reflect.DeepEqual(d.Examples, []string{`guard("minAmount", {"min": 5})`}) {
33+
t.Errorf("descriptor examples = %v", d.Examples)
34+
}
35+
if len(d.Params) != 1 || !reflect.DeepEqual(d.Params[0].Examples, []any{1, 5, 100}) {
36+
t.Errorf("param examples = %+v", d.Params)
37+
}
38+
39+
b, err := json.Marshal(d)
40+
if err != nil {
41+
t.Fatalf("marshal: %v", err)
42+
}
43+
for _, key := range []string{`"category":"guards"`, `"examples":["guard`, `"examples":[1,5,100]`} {
44+
if !strings.Contains(string(b), key) {
45+
t.Errorf("descriptor JSON %s missing %s", b, key)
46+
}
47+
}
48+
}
49+
50+
// TestPaletteWithoutCompletenessFieldsOmitsKeys is the additive guarantee: a
51+
// descriptor registered without a category or examples must serialize to exactly
52+
// the same bytes as before — no spurious "category" or "examples" keys.
53+
func TestPaletteWithoutCompletenessFieldsOmitsKeys(t *testing.T) {
54+
reg := state.NewRegistry[order]()
55+
reg.Action("charge", func(state.ActionCtx[order]) (state.Effect, error) { return nil, nil },
56+
state.Describe("Charges the order.").
57+
Param("gateway", state.StringParam).
58+
Writes("Order"))
59+
60+
d := findDescriptor(t, reg.Palette(), state.KindAction, "charge")
61+
if d.Category != "" || d.Examples != nil {
62+
t.Fatalf("expected empty completeness fields, got category=%q examples=%v", d.Category, d.Examples)
63+
}
64+
65+
b, err := json.Marshal(d)
66+
if err != nil {
67+
t.Fatalf("marshal: %v", err)
68+
}
69+
want := `{"kind":"action","name":"charge","description":"Charges the order.","params":[{"name":"gateway","type":"string","required":true}],"writes":["Order"]}`
70+
if string(b) != want {
71+
t.Fatalf("descriptor JSON changed:\n got %s\nwant %s", b, want)
72+
}
73+
74+
// A param without examples likewise omits the key.
75+
pb, err := json.Marshal(d.Params[0])
76+
if err != nil {
77+
t.Fatalf("marshal param: %v", err)
78+
}
79+
if strings.Contains(string(pb), "examples") {
80+
t.Fatalf("param JSON unexpectedly carries examples: %s", pb)
81+
}
82+
}
83+
84+
// TestPaletteCompletenessJSONRoundTrip confirms the new fields survive a marshal
85+
// and unmarshal so a loaded palette preserves them.
86+
func TestPaletteCompletenessJSONRoundTrip(t *testing.T) {
87+
reg := state.NewRegistry[order]()
88+
reg.Service("notify", nil,
89+
state.Describe("Notifies the customer.").
90+
Category("side-effects").
91+
Examples("notify email").
92+
ParamSpec(state.ParamSpec{
93+
Name: "channel",
94+
Type: state.EnumParam,
95+
Required: true,
96+
Enum: []string{"email", "sms"},
97+
Examples: []any{"email"},
98+
}))
99+
100+
in := findDescriptor(t, reg.Palette(), state.KindService, "notify")
101+
b, err := json.Marshal(in)
102+
if err != nil {
103+
t.Fatalf("marshal: %v", err)
104+
}
105+
var out state.Descriptor
106+
if err := json.Unmarshal(b, &out); err != nil {
107+
t.Fatalf("unmarshal: %v", err)
108+
}
109+
if !reflect.DeepEqual(in, out) {
110+
t.Fatalf("round trip mismatch:\n in %+v\nout %+v", in, out)
111+
}
112+
}

0 commit comments

Comments
 (0)