From 70af003034549c3dd573112652d5f8277427226e Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 13 Jun 2026 20:40:23 -0400 Subject: [PATCH 1/6] feat: scaffold gen eject module Signed-off-by: Joshua Temple --- gen/doc.go | 21 +++++++++++++++++++++ gen/eject.go | 25 +++++++++++++++++++++++++ gen/go.mod | 7 +++++++ gen/options.go | 43 +++++++++++++++++++++++++++++++++++++++++++ go.work | 1 + 5 files changed, 97 insertions(+) create mode 100644 gen/doc.go create mode 100644 gen/eject.go create mode 100644 gen/go.mod create mode 100644 gen/options.go diff --git a/gen/doc.go b/gen/doc.go new file mode 100644 index 0000000..c36ba9c --- /dev/null +++ b/gen/doc.go @@ -0,0 +1,21 @@ +// Package gen turns a state machine's intermediate representation into typed Go +// stub source — an "eject" codegen. +// +// Eject walks an [state.IR] and emits a single gofmt'd Go source file containing: +// +// - a Context type synthesized from the IR's context schema (a struct when the +// schema declares fields, otherwise a map[string]any alias); +// - one panic-bodied stub per referenced behavior (guard, action, assign, +// service), each typed to the exact engine signature with the synthesized +// Context substituted for the machine's context type parameter; and +// - a Provide function that registers every stub against a [state.Registry] by +// its original IR name. +// +// The emitted file is a starting point a host fills in: every stub panics with a +// TODO until implemented, but the file compiles and its Provide type-checks +// against the real registry, so the wiring is proven before any behavior is +// written. +// +// Output is deterministic: names are sorted and the file is rendered from sorted +// slices, so ejecting the same IR twice yields byte-identical source. +package gen diff --git a/gen/eject.go b/gen/eject.go new file mode 100644 index 0000000..0447b83 --- /dev/null +++ b/gen/eject.go @@ -0,0 +1,25 @@ +package gen + +import ( + "errors" + + "github.com/stablekernel/crucible/state" +) + +// errNotImplemented is the scaffold placeholder returned until the codegen lands. +var errNotImplemented = errors.New("gen: Eject not implemented") + +// Eject renders the given IR to typed Go stub source. +// +// Signature choice (a): the IR is walked directly. The type parameters S, E, and +// C are unused by the codegen — the IR's behavior references (state.Ref) and its +// context schema (state.ContextSchema) are non-generic, so the walk and the +// emitted source are driven entirely by those concrete shapes. The parameters +// remain on the signature so callers can pass an *state.IR (or its value) without +// reflection or an interface wrapper, keeping Eject a drop-in over a typed +// machine's loaded IR. +func Eject[S comparable, E comparable, C any](ir state.IR[S, E, C], opts ...Option) ([]byte, error) { + _ = ir + _ = opts + return nil, errNotImplemented +} diff --git a/gen/go.mod b/gen/go.mod new file mode 100644 index 0000000..6cbca35 --- /dev/null +++ b/gen/go.mod @@ -0,0 +1,7 @@ +module github.com/stablekernel/crucible/gen + +go 1.25.11 + +require github.com/stablekernel/crucible/state v0.0.0 + +replace github.com/stablekernel/crucible/state => ../state diff --git a/gen/options.go b/gen/options.go new file mode 100644 index 0000000..4dfa4b4 --- /dev/null +++ b/gen/options.go @@ -0,0 +1,43 @@ +package gen + +// config holds the resolved settings for an Eject call. It is unexported; callers +// shape it through the functional Option tail so new knobs arrive additively +// without breaking the Eject signature. +type config struct { + packageName string + contextTypeName string +} + +// defaultConfig returns the baseline configuration applied before any Option. +func defaultConfig() config { + return config{ + packageName: "machine", + contextTypeName: "Context", + } +} + +// Option configures an Eject call. Options form an additive variadic tail on +// Eject; required inputs stay positional and new capabilities arrive as new +// Options, never as changes to existing signatures. +type Option func(*config) + +// WithPackageName sets the package clause of the generated file. The default is +// "machine". An empty name is ignored, leaving the default in place. +func WithPackageName(name string) Option { + return func(c *config) { + if name != "" { + c.packageName = name + } + } +} + +// WithContextTypeName sets the name of the generated context type — the type +// substituted wherever the engine signatures reference the machine's context type +// parameter. The default is "Context". An empty name is ignored. +func WithContextTypeName(name string) Option { + return func(c *config) { + if name != "" { + c.contextTypeName = name + } + } +} diff --git a/go.work b/go.work index fef5f97..99fe528 100644 --- a/go.work +++ b/go.work @@ -16,6 +16,7 @@ use ( ./e2e ./examples/dispatch ./examples/fooddelivery + ./gen ./magefiles ./sink ./sink/bridge From bc2cfe530c333ad45dca8b98014a9c8d02b47221 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 13 Jun 2026 20:41:40 -0400 Subject: [PATCH 2/6] feat: implement IR eject codegen Signed-off-by: Joshua Temple --- gen/eject.go | 380 ++++++++++++++++++++++++++++++++++++++++++++++++-- gen/schema.go | 97 +++++++++++++ 2 files changed, 468 insertions(+), 9 deletions(-) create mode 100644 gen/schema.go diff --git a/gen/eject.go b/gen/eject.go index 0447b83..f362a41 100644 --- a/gen/eject.go +++ b/gen/eject.go @@ -1,13 +1,71 @@ package gen import ( - "errors" + "bytes" + "fmt" + "go/format" + "sort" + "strings" "github.com/stablekernel/crucible/state" ) -// errNotImplemented is the scaffold placeholder returned until the codegen lands. -var errNotImplemented = errors.New("gen: Eject not implemented") +// behaviorKind discriminates the four registry slots a referenced behavior name +// can land in. The kinds are independent registries in the engine, so the same +// name may appear under more than one kind. +type behaviorKind int + +const ( + kindGuard behaviorKind = iota + kindAction + kindAssign + kindService +) + +// names accumulates referenced behavior names per kind while the IR is walked. +// Each set is collapsed to a sorted, deduplicated slice before code is emitted so +// output is deterministic. +type names struct { + guards map[string]struct{} + actions map[string]struct{} + assigns map[string]struct{} + services map[string]struct{} +} + +func newNames() *names { + return &names{ + guards: map[string]struct{}{}, + actions: map[string]struct{}{}, + assigns: map[string]struct{}{}, + services: map[string]struct{}{}, + } +} + +func (n *names) add(kind behaviorKind, name string) { + if name == "" { + return + } + switch kind { + case kindGuard: + n.guards[name] = struct{}{} + case kindAction: + n.actions[name] = struct{}{} + case kindAssign: + n.assigns[name] = struct{}{} + case kindService: + n.services[name] = struct{}{} + } +} + +// sortedSet returns the members of a set in ascending order. +func sortedSet(set map[string]struct{}) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out +} // Eject renders the given IR to typed Go stub source. // @@ -15,11 +73,315 @@ var errNotImplemented = errors.New("gen: Eject not implemented") // C are unused by the codegen — the IR's behavior references (state.Ref) and its // context schema (state.ContextSchema) are non-generic, so the walk and the // emitted source are driven entirely by those concrete shapes. The parameters -// remain on the signature so callers can pass an *state.IR (or its value) without -// reflection or an interface wrapper, keeping Eject a drop-in over a typed -// machine's loaded IR. +// remain on the signature so callers can pass an state.IR loaded from a typed +// machine without reflection or an interface wrapper, keeping Eject a drop-in over +// a typed machine's loaded IR. +// +// The returned bytes are a single gofmt'd Go source file. On a formatting failure +// (which indicates a codegen bug) the error wraps the unformatted source so it can +// be inspected. func Eject[S comparable, E comparable, C any](ir state.IR[S, E, C], opts ...Option) ([]byte, error) { - _ = ir - _ = opts - return nil, errNotImplemented + cfg := defaultConfig() + for _, o := range opts { + o(&cfg) + } + + collected := newNames() + for i := range ir.States { + walkState(ir.States[i], collected) + } + + // Build a deterministic identifier per (kind, name). A bare sanitized name is + // used when it is unique across all kinds; otherwise the kind is suffixed so + // the generated Go identifiers never collide while the registration string + // stays the original name. + idents := buildIdents(collected) + + var buf bytes.Buffer + buf.WriteString("// Code generated by gen. DO NOT EDIT.\n\n") + fmt.Fprintf(&buf, "package %s\n\n", cfg.packageName) + + // Imports are computed by the body builders; emit a placeholder, render the + // body, then prepend the resolved import block. + imports := newImportSet() + imports.add("github.com/stablekernel/crucible/state") + + var body bytes.Buffer + writeContextType(&body, ir.Context, cfg.contextTypeName, imports) + writeStubs(&body, collected, idents, cfg.contextTypeName, imports) + writeProvide(&body, collected, idents, cfg.contextTypeName) + + writeImports(&buf, imports) + buf.Write(body.Bytes()) + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("gen: format generated source: %w\n--- unformatted ---\n%s", err, buf.String()) + } + return formatted, nil +} + +// walkState collects every behavior reference on a state and recurses into its +// children, regions, and (via transitions) its outgoing edges. +func walkState[S comparable, E comparable, C any](s state.State[S, E, C], n *names) { + for _, r := range s.OnEntry { + n.add(kindAction, r.Name) + } + for _, r := range s.OnExit { + n.add(kindAction, r.Name) + } + for _, r := range s.OnDone { + n.add(kindAction, r.Name) + } + for _, r := range s.OnEntryAssign { + n.add(kindAssign, r.Name) + } + for _, r := range s.OnExitAssign { + n.add(kindAssign, r.Name) + } + for _, inv := range s.Invoke { + n.add(kindService, inv.Src.Name) + } + for i := range s.Transitions { + walkTransition(s.Transitions[i], n) + } + for i := range s.Children { + walkState(s.Children[i], n) + } + for i := range s.Regions { + for j := range s.Regions[i].States { + walkState(s.Regions[i].States[j], n) + } + } +} + +// walkTransition collects the guard, action, and assign references on one edge. +func walkTransition[S comparable, E comparable, C any](t state.Transition[S, E, C], n *names) { + for _, r := range t.Guards { + n.add(kindGuard, r.Name) + } + for _, r := range t.Effects { + n.add(kindAction, r.Name) + } + for _, r := range t.Assigns { + n.add(kindAssign, r.Name) + } +} + +// identKey pairs a kind with the original name for indexing the ident map. +type identKey struct { + kind behaviorKind + name string +} + +// buildIdents derives a unique, deterministic Go identifier for every collected +// (kind, name). The base identifier is the sanitized name; when that base is +// shared by more than one (kind, name) pair the kind is appended so the emitted +// function identifiers stay unique. The original name is always preserved as the +// registration string elsewhere. +func buildIdents(n *names) map[identKey]string { + type entry struct { + key identKey + base string + } + var entries []entry + collect := func(kind behaviorKind, set map[string]struct{}) { + for _, name := range sortedSet(set) { + entries = append(entries, entry{key: identKey{kind, name}, base: behaviorIdent(name)}) + } + } + collect(kindGuard, n.guards) + collect(kindAction, n.actions) + collect(kindAssign, n.assigns) + collect(kindService, n.services) + + baseCount := map[string]int{} + for _, e := range entries { + baseCount[e.base]++ + } + + out := make(map[identKey]string, len(entries)) + used := map[string]struct{}{} + for _, e := range entries { + id := e.base + if baseCount[e.base] > 1 { + id = e.base + kindSuffix(e.key.kind) + } + // Guard against any residual collision (e.g. two original names that + // sanitize to the same base within one kind) by appending an ordinal. + base := id + for i := 1; ; i++ { + if _, taken := used[id]; !taken { + break + } + id = fmt.Sprintf("%s%d", base, i) + } + used[id] = struct{}{} + out[e.key] = id + } + return out +} + +func kindSuffix(k behaviorKind) string { + switch k { + case kindGuard: + return "Guard" + case kindAction: + return "Action" + case kindAssign: + return "Assign" + case kindService: + return "Service" + default: + return "" + } +} + +// writeStubs emits one panic-bodied stub per collected behavior, typed to the +// exact engine signature with the context type substituted for the engine's C. +func writeStubs(buf *bytes.Buffer, n *names, idents map[identKey]string, ctxName string, imports *importSet) { + for _, name := range sortedSet(n.guards) { + id := idents[identKey{kindGuard, name}] + fmt.Fprintf(buf, "func %s(ctx state.GuardCtx[%s]) bool {\n\tpanic(%q)\n}\n\n", + id, ctxName, "TODO: implement guard "+name) + } + for _, name := range sortedSet(n.actions) { + id := idents[identKey{kindAction, name}] + fmt.Fprintf(buf, "func %s(ctx state.ActionCtx[%s]) (state.Effect, error) {\n\tpanic(%q)\n}\n\n", + id, ctxName, "TODO: implement action "+name) + } + for _, name := range sortedSet(n.assigns) { + id := idents[identKey{kindAssign, name}] + fmt.Fprintf(buf, "func %s(in state.AssignCtx[%s]) %s {\n\tpanic(%q)\n}\n\n", + id, ctxName, ctxName, "TODO: implement assign "+name) + } + if len(n.services) > 0 { + imports.add("context") + } + for _, name := range sortedSet(n.services) { + id := idents[identKey{kindService, name}] + fmt.Fprintf(buf, "func %s(ctx context.Context, in state.ServiceCtx[%s]) (any, error) {\n\tpanic(%q)\n}\n\n", + id, ctxName, "TODO: implement service "+name) + } +} + +// writeProvide emits the Provide function registering every stub against a +// state.Registry by its ORIGINAL name. Assigns register through Reducer (the +// engine's name for the assign registry slot). +func writeProvide(buf *bytes.Buffer, n *names, idents map[identKey]string, ctxName string) { + fmt.Fprintf(buf, "// Provide registers every generated stub against reg by its IR name and returns\n") + fmt.Fprintf(buf, "// reg for chaining.\n") + fmt.Fprintf(buf, "func Provide(reg *state.Registry[%s]) *state.Registry[%s] {\n", ctxName, ctxName) + for _, name := range sortedSet(n.guards) { + fmt.Fprintf(buf, "\treg.Guard(%q, %s)\n", name, idents[identKey{kindGuard, name}]) + } + for _, name := range sortedSet(n.actions) { + fmt.Fprintf(buf, "\treg.Action(%q, %s)\n", name, idents[identKey{kindAction, name}]) + } + for _, name := range sortedSet(n.assigns) { + fmt.Fprintf(buf, "\treg.Reducer(%q, %s)\n", name, idents[identKey{kindAssign, name}]) + } + for _, name := range sortedSet(n.services) { + fmt.Fprintf(buf, "\treg.Service(%q, %s)\n", name, idents[identKey{kindService, name}]) + } + buf.WriteString("\treturn reg\n}\n") +} + +// importSet collects import paths and renders them as a sorted block. +type importSet struct { + paths map[string]struct{} +} + +func newImportSet() *importSet { return &importSet{paths: map[string]struct{}{}} } + +func (s *importSet) add(path string) { s.paths[path] = struct{}{} } + +func writeImports(buf *bytes.Buffer, s *importSet) { + if len(s.paths) == 0 { + return + } + paths := make([]string, 0, len(s.paths)) + for p := range s.paths { + paths = append(paths, p) + } + sort.Strings(paths) + if len(paths) == 1 { + fmt.Fprintf(buf, "import %q\n\n", paths[0]) + return + } + buf.WriteString("import (\n") + for _, p := range paths { + fmt.Fprintf(buf, "\t%q\n", p) + } + buf.WriteString(")\n\n") +} + +// behaviorIdent derives the Go function identifier for a behavior stub from its +// IR name. When the name is already a valid Go identifier it is used verbatim, so +// a name like "isPaid" or "g" maps to the same-cased func — the stub reads as the +// behavior it stands for. A name with separators (snake_case, kebab-case, dotted +// path) or a leading digit is camel-cased and prefixed via sanitizeIdent so the +// result is always valid. The mapping is deterministic. +func behaviorIdent(name string) string { + if isGoIdent(name) { + return name + } + return sanitizeIdent(name) +} + +// isGoIdent reports whether name is already a non-empty valid Go identifier (ASCII +// letters/underscore start, ASCII alphanumerics/underscore tail). Keyword +// collisions are not a concern for callable identifiers here. +func isGoIdent(name string) bool { + if name == "" { + return false + } + for i, r := range name { + isLetter := r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') + isDigit := r >= '0' && r <= '9' + if i == 0 && !isLetter { + return false + } + if i > 0 && !isLetter && !isDigit { + return false + } + } + return true +} + +// sanitizeIdent turns an arbitrary IR name into a safe, exported Go identifier. +// Non-identifier runes act as word separators (snake_case, kebab-case, and dotted +// paths all split into camel-cased words); a leading digit is prefixed so the +// result is always a valid identifier. The mapping is deterministic. +func sanitizeIdent(name string) string { + var words []string + var cur strings.Builder + flush := func() { + if cur.Len() > 0 { + words = append(words, cur.String()) + cur.Reset() + } + } + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + cur.WriteRune(r) + default: + flush() + } + } + flush() + + var b strings.Builder + for _, w := range words { + b.WriteString(strings.ToUpper(w[:1]) + w[1:]) + } + ident := b.String() + if ident == "" { + return "Behavior" + } + if c := ident[0]; c >= '0' && c <= '9' { + ident = "X" + ident + } + return ident } diff --git a/gen/schema.go b/gen/schema.go new file mode 100644 index 0000000..4e7d505 --- /dev/null +++ b/gen/schema.go @@ -0,0 +1,97 @@ +package gen + +import ( + "bytes" + "fmt" + + "github.com/stablekernel/crucible/state" +) + +// writeContextType emits the generated context type from the IR's context schema. +// +// When the schema is absent or declares no fields, a map alias is emitted rather +// than an empty struct or a bare any: +// +// type Context = map[string]any +// +// The alias is chosen over `type Context any` because the stubs still need to +// index context fields (ctx.Entity["order_total"]) without a type assertion; an +// alias to map[string]any keeps that ergonomic while honestly admitting the shape +// is open. A distinct named struct with no fields would force callers to migrate +// off it once the schema is filled in, whereas the alias is a drop-in. +func writeContextType(buf *bytes.Buffer, schema *state.ContextSchema, ctxName string, imports *importSet) { + if schema == nil || len(schema.Fields) == 0 { + fmt.Fprintf(buf, "// %s is the machine context. The IR carried no context schema, so it is an\n", ctxName) + fmt.Fprintf(buf, "// open map alias: stubs may still index fields by key while the concrete shape\n") + fmt.Fprintf(buf, "// is unknown.\n") + fmt.Fprintf(buf, "type %s = map[string]any\n\n", ctxName) + return + } + + fmt.Fprintf(buf, "// %s is the machine context, synthesized from the IR's context schema.\n", ctxName) + fmt.Fprintf(buf, "type %s struct {\n", ctxName) + for _, f := range schema.Fields { + goType := schemaFieldGoType(f, imports) + fmt.Fprintf(buf, "\t%s %s `json:%q`\n", exportedFieldName(f.Name), goType, f.Name) + } + buf.WriteString("}\n\n") +} + +// schemaFieldGoType maps a SchemaField's kind to a Go type for the generated +// struct field. +// +// Mapping decisions: +// - Int -> int64: IR JSON numbers have no width; int64 is the widest lossless +// integer and avoids platform-dependent int sizing in generated code. +// - Float -> float64, Bool -> bool, String/Enum -> string. Enum is a plain +// string (the allowed set is not enforced at the Go type level here). +// - Duration -> time.Duration, Time -> time.Time (both add the time import). +// - Object -> map[string]any: nested objects are flattened to an open map. This +// is the simplest deterministic choice and avoids emitting and naming nested +// struct types; a host that wants nested structs can refine the generated +// field. +// - List -> []any, Map -> map[string]any: element/key/value shapes are not +// expanded, again favoring a deterministic, dependency-free rendering. +// - Any / unknown kinds -> any. +// +// Nullable is intentionally not reflected (no pointer wrapping): map/slice/any +// fields are already nilable, and pointer-wrapping scalars would complicate the +// stubs for little gain. Nullability is documented in the schema, not the Go type. +func schemaFieldGoType(f state.SchemaField, imports *importSet) string { + switch f.Kind { + case state.SchemaString, state.SchemaEnum: + return "string" + case state.SchemaInt: + return "int64" + case state.SchemaFloat: + return "float64" + case state.SchemaBool: + return "bool" + case state.SchemaDuration: + imports.add("time") + return "time.Duration" + case state.SchemaTime: + imports.add("time") + return "time.Time" + case state.SchemaObject, state.SchemaMap: + return "map[string]any" + case state.SchemaList: + return "[]any" + case state.SchemaAny: + return "any" + default: + return "any" + } +} + +// exportedFieldName turns a schema field's wire name into an exported Go struct +// field identifier. It reuses the identifier sanitizer (camel-casing across +// separators) so order_total becomes OrderTotal; an unnameable field falls back to +// a stable placeholder. +func exportedFieldName(name string) string { + id := sanitizeIdent(name) + if id == "" { + return "Field" + } + return id +} From ea2f83b4e97b7691a09811fc2b145b6f454586be Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 13 Jun 2026 20:44:24 -0400 Subject: [PATCH 3/6] test: cover gen eject codegen and add changelog Signed-off-by: Joshua Temple --- gen/CHANGELOG.md | 32 ++++++++ gen/eject_test.go | 196 ++++++++++++++++++++++++++++++++++++++++++++++ gen/unit_test.go | 146 ++++++++++++++++++++++++++++++++++ 3 files changed, 374 insertions(+) create mode 100644 gen/CHANGELOG.md create mode 100644 gen/eject_test.go create mode 100644 gen/unit_test.go diff --git a/gen/CHANGELOG.md b/gen/CHANGELOG.md new file mode 100644 index 0000000..2d0fb4d --- /dev/null +++ b/gen/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this module are documented here. The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this module adheres +to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] + +### Added + +- `Eject` codegen that turns a state machine's IR into typed Go stub source. From + an `state.IR` it renders a single gofmt'd Go file containing: + - a generated `Context` type from the IR's context schema — a struct (one field + per schema field, with kind-to-Go mapping and `json` tags) when fields are + declared, or a `map[string]any` alias when the schema is absent or empty; + - a panic-bodied stub for every referenced guard, action, assign, and service, + each typed to the exact engine signature with the generated context type + substituted for the machine's context type parameter; and + - a `Provide` function registering every stub against a `state.Registry` by its + original IR name (assigns register through `Reducer`). +- Functional options `WithPackageName` and `WithContextTypeName` configuring the + emitted package clause and context type name. +- Deterministic output: behavior names are walked across the full state hierarchy + (states, children, regions, transitions, invocations), deduplicated, and sorted, + so ejecting the same IR twice yields byte-identical source. A name shared across + behavior kinds gets a unique, kind-suffixed Go identifier while its registration + string stays the original name. + +[Unreleased]: https://github.com/stablekernel/crucible/compare/gen/v0.1.0...HEAD +[0.1.0]: https://github.com/stablekernel/crucible/releases/tag/gen/v0.1.0 diff --git a/gen/eject_test.go b/gen/eject_test.go new file mode 100644 index 0000000..1e9cec6 --- /dev/null +++ b/gen/eject_test.go @@ -0,0 +1,196 @@ +package gen + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stablekernel/crucible/state" +) + +// order is the representative context for the test machine. Its fields span +// several SchemaKinds (string, int, bool, duration, time, list, map) so the +// generated context type exercises the full kind->Go mapping. +type order struct { + ID string `json:"id"` + Total int `json:"order_total"` + Paid bool `json:"paid"` + Window time.Duration `json:"window"` + PlacedAt time.Time `json:"placed_at"` + Items []string `json:"items"` + Extras map[string]int `json:"extras"` +} + +// buildIR constructs a representative machine via the real DSL and round-trips it +// through JSON to obtain a *state.IR — the same path a host's eject tool would +// take from a persisted machine definition. The machine references two guards, two +// actions, one assign, and one invoked service. +func buildIR(t *testing.T) state.IR[string, string, order] { + t.Helper() + + m := state.ForgeFor[order]("orders"). + WithContextSchema(state.SchemaOf[order]()). + Guard("isPaid", func(c state.GuardCtx[order]) bool { return c.Entity.Paid }). + Guard("hasItems", func(c state.GuardCtx[order]) bool { return len(c.Entity.Items) > 0 }). + Action("notify", func(state.ActionCtx[order]) (state.Effect, error) { return nil, nil }). + Action("logEntry", func(state.ActionCtx[order]) (state.Effect, error) { return nil, nil }). + Reducer("applyTotal", func(in state.AssignCtx[order]) order { return in.Entity }). + Service("charge", func(context.Context, state.ServiceCtx[order]) (any, error) { return nil, nil }). + State("cart"). + OnEntry("logEntry"). + Transition("cart").On("checkout").GoTo("paying"). + When("hasItems"). + Assign("applyTotal"). + State("paying"). + Invoke("charge", state.WithInvokeOnDone("paid"), state.WithInvokeOnError("failed")). + Transition("paying").On("paid").GoTo("done"). + When("isPaid"). + Do("notify"). + State("done"). + Initial("cart"). + Quench() + + raw, err := m.ToJSON() + if err != nil { + t.Fatalf("ToJSON: %v", err) + } + ir, err := state.LoadFromJSON[string, string, order](raw) + if err != nil { + t.Fatalf("LoadFromJSON: %v", err) + } + return *ir +} + +func TestEject_GeneratesExpectedSurface(t *testing.T) { + src, err := Eject(buildIR(t)) + if err != nil { + t.Fatalf("Eject: %v", err) + } + got := string(src) + + mustContain := []string{ + "// Code generated by gen. DO NOT EDIT.", + "package machine", + "type Context struct {", + "func isPaid(ctx state.GuardCtx[Context]) bool", + "func hasItems(ctx state.GuardCtx[Context]) bool", + "func notify(ctx state.ActionCtx[Context]) (state.Effect, error)", + "func logEntry(ctx state.ActionCtx[Context]) (state.Effect, error)", + "func applyTotal(in state.AssignCtx[Context]) Context", + "func charge(ctx context.Context, in state.ServiceCtx[Context]) (any, error)", + `reg.Guard("isPaid", isPaid)`, + `reg.Action("notify", notify)`, + `reg.Reducer("applyTotal", applyTotal)`, + `reg.Service("charge", charge)`, + "func Provide(reg *state.Registry[Context]) *state.Registry[Context]", + } + for _, want := range mustContain { + if !strings.Contains(got, want) { + t.Errorf("generated source missing %q\n---\n%s", want, got) + } + } +} + +// collapseWS reduces every run of whitespace to a single space so gofmt's column +// alignment of struct fields does not defeat substring assertions. +func collapseWS(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +func TestEject_ContextFieldMapping(t *testing.T) { + src, err := Eject(buildIR(t)) + if err != nil { + t.Fatalf("Eject: %v", err) + } + got := collapseWS(string(src)) + mustContain := []string{ + "Id string `json:\"id\"`", + "OrderTotal int64 `json:\"order_total\"`", + "Paid bool `json:\"paid\"`", + "Window time.Duration `json:\"window\"`", + "PlacedAt time.Time `json:\"placed_at\"`", + "Items []any `json:\"items\"`", + "Extras map[string]any `json:\"extras\"`", + } + for _, want := range mustContain { + if !strings.Contains(got, want) { + t.Errorf("context field mapping missing %q\n---\n%s", want, string(src)) + } + } +} + +func TestEject_Deterministic(t *testing.T) { + ir := buildIR(t) + a, err := Eject(ir) + if err != nil { + t.Fatalf("Eject a: %v", err) + } + b, err := Eject(ir) + if err != nil { + t.Fatalf("Eject b: %v", err) + } + if !bytes.Equal(a, b) { + t.Fatalf("Eject is not deterministic:\n--- a ---\n%s\n--- b ---\n%s", a, b) + } +} + +func TestEject_Options(t *testing.T) { + src, err := Eject(buildIR(t), WithPackageName("orders"), WithContextTypeName("OrderCtx")) + if err != nil { + t.Fatalf("Eject: %v", err) + } + got := string(src) + for _, want := range []string{ + "package orders", + "type OrderCtx struct {", + "func isPaid(ctx state.GuardCtx[OrderCtx]) bool", + "func Provide(reg *state.Registry[OrderCtx]) *state.Registry[OrderCtx]", + } { + if !strings.Contains(got, want) { + t.Errorf("with options, missing %q\n---\n%s", want, got) + } + } +} + +// TestEject_CompilesAndWires writes the generated source into a fresh temp module +// and runs `go build ./...` against the real state module via a replace directive. +// A clean build proves every stub carries the exact engine signature and that +// Provide type-checks against a real *state.Registry[Context]. +func TestEject_CompilesAndWires(t *testing.T) { + src, err := Eject(buildIR(t)) + if err != nil { + t.Fatalf("Eject: %v", err) + } + + const stateAbs = "/Users/joshua.temple/go/src/github.com/stablekernel/crucible/.worktrees/gen-eject/state" + + tmp := t.TempDir() + gomod := "module genout\n\ngo 1.25.11\n\nrequire github.com/stablekernel/crucible/state v0.0.0\n\nreplace github.com/stablekernel/crucible/state => " + stateAbs + "\n" + writeFile(t, filepath.Join(tmp, "go.mod"), gomod) + writeFile(t, filepath.Join(tmp, "gen.go"), string(src)) + + // wire.go drives Provide against a real registry, proving the generated + // Provide type-checks end to end. + wire := "package machine\n\nimport \"github.com/stablekernel/crucible/state\"\n\nfunc wire() *state.Registry[Context] {\n\treturn Provide(state.NewRegistry[Context]())\n}\n\nvar _ = wire\n" + writeFile(t, filepath.Join(tmp, "wire.go"), wire) + + cmd := exec.Command("go", "build", "./...") + cmd.Dir = tmp + cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=-mod=mod") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("temp module build failed: %v\n%s\n--- generated ---\n%s", err, out, src) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/gen/unit_test.go b/gen/unit_test.go new file mode 100644 index 0000000..e25a514 --- /dev/null +++ b/gen/unit_test.go @@ -0,0 +1,146 @@ +package gen + +import ( + "strings" + "testing" + + "github.com/stablekernel/crucible/state" +) + +// irWith builds a minimal IR value directly (no DSL) so a single state can carry +// arbitrary behavior refs for focused walk/dedup/collision tests. +func irWith(schema *state.ContextSchema, states ...state.State[string, string, order]) state.IR[string, string, order] { + return state.IR[string, string, order]{ + Name: "t", + Context: schema, + States: states, + } +} + +func TestEject_NilAndEmptySchemaAlias(t *testing.T) { + cases := []struct { + name string + schema *state.ContextSchema + }{ + {"nil", nil}, + {"empty", &state.ContextSchema{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + src, err := Eject(irWith(tc.schema)) + if err != nil { + t.Fatalf("Eject: %v", err) + } + got := string(src) + if !strings.Contains(got, "type Context = map[string]any") { + t.Errorf("expected map alias, got:\n%s", got) + } + if strings.Contains(got, "type Context struct") { + t.Errorf("expected alias, not struct:\n%s", got) + } + }) + } +} + +func TestSchemaFieldGoType(t *testing.T) { + cases := []struct { + kind state.SchemaKind + want string + }{ + {state.SchemaString, "string"}, + {state.SchemaEnum, "string"}, + {state.SchemaInt, "int64"}, + {state.SchemaFloat, "float64"}, + {state.SchemaBool, "bool"}, + {state.SchemaDuration, "time.Duration"}, + {state.SchemaTime, "time.Time"}, + {state.SchemaObject, "map[string]any"}, + {state.SchemaMap, "map[string]any"}, + {state.SchemaList, "[]any"}, + {state.SchemaAny, "any"}, + {state.SchemaKind("unknownfuturekind"), "any"}, + } + for _, tc := range cases { + t.Run(string(tc.kind), func(t *testing.T) { + got := schemaFieldGoType(state.SchemaField{Kind: tc.kind}, newImportSet()) + if got != tc.want { + t.Errorf("kind %q: got %q, want %q", tc.kind, got, tc.want) + } + }) + } +} + +func TestSanitizeIdent(t *testing.T) { + cases := map[string]string{ + "order_total": "OrderTotal", + "has-items": "HasItems", + "order.total": "OrderTotal", + "isPaid": "IsPaid", + "3way": "X3way", + "": "Behavior", + "@@@": "Behavior", + } + for in, want := range cases { + if got := sanitizeIdent(in); got != want { + t.Errorf("sanitizeIdent(%q) = %q, want %q", in, got, want) + } + } +} + +func TestEject_DedupAndSort(t *testing.T) { + // One state, two transitions naming the same guard "g" twice and actions out + // of alphabetical order. Expect a single g stub and z-before-a sorted output. + st := state.State[string, string, order]{ + Name: "s", + Transitions: []state.Transition[string, string, order]{ + {From: "s", To: "s", On: "e1", Guards: []state.Ref{{Name: "g"}}, Effects: []state.Ref{{Name: "zAct"}}}, + {From: "s", To: "s", On: "e2", Guards: []state.Ref{{Name: "g"}}, Effects: []state.Ref{{Name: "aAct"}}}, + }, + } + src, err := Eject(irWith(nil, st)) + if err != nil { + t.Fatalf("Eject: %v", err) + } + got := string(src) + if n := strings.Count(got, "func g(ctx state.GuardCtx"); n != 1 { + t.Errorf("expected exactly one guard stub for g, got %d\n%s", n, got) + } + ai := strings.Index(got, "func aAct(") + zi := strings.Index(got, "func zAct(") + if ai < 0 || zi < 0 || ai > zi { + t.Errorf("actions not sorted ascending (aAct before zAct): aAct@%d zAct@%d\n%s", ai, zi, got) + } +} + +func TestEject_CollisionAcrossKinds(t *testing.T) { + // The same bare name "process" appears as a guard, an action, an assign, and a + // service. Identifiers must be unique; registration strings stay the original. + st := state.State[string, string, order]{ + Name: "s", + OnEntry: []state.Ref{{Name: "process"}}, // action + OnEntryAssign: []state.Ref{{Name: "process"}}, // assign + Invoke: []state.Invocation[string, string, order]{{Src: state.Ref{Name: "process"}}}, + Transitions: []state.Transition[string, string, order]{ + {From: "s", To: "s", On: "e", Guards: []state.Ref{{Name: "process"}}}, // guard + }, + } + src, err := Eject(irWith(nil, st)) + if err != nil { + t.Fatalf("Eject: %v", err) + } + got := string(src) + for _, want := range []string{ + "func processGuard(ctx state.GuardCtx[Context]) bool", + "func processAction(ctx state.ActionCtx[Context]) (state.Effect, error)", + "func processAssign(in state.AssignCtx[Context]) Context", + "func processService(ctx context.Context, in state.ServiceCtx[Context]) (any, error)", + `reg.Guard("process", processGuard)`, + `reg.Action("process", processAction)`, + `reg.Reducer("process", processAssign)`, + `reg.Service("process", processService)`, + } { + if !strings.Contains(got, want) { + t.Errorf("collision handling missing %q\n---\n%s", want, got) + } + } +} From 0243119058a79689b19aa8100e4e9f0f3637f22a Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 13 Jun 2026 21:01:46 -0400 Subject: [PATCH 4/6] feat: scaffold crucible cli module Signed-off-by: Joshua Temple --- cmd/crucible/go.mod | 12 ++++++ cmd/crucible/main.go | 95 ++++++++++++++++++++++++++++++++++++++++++++ go.work | 1 + 3 files changed, 108 insertions(+) create mode 100644 cmd/crucible/go.mod create mode 100644 cmd/crucible/main.go diff --git a/cmd/crucible/go.mod b/cmd/crucible/go.mod new file mode 100644 index 0000000..ef01a82 --- /dev/null +++ b/cmd/crucible/go.mod @@ -0,0 +1,12 @@ +module github.com/stablekernel/crucible/cmd/crucible + +go 1.25.11 + +require ( + github.com/stablekernel/crucible/gen v0.0.0 + github.com/stablekernel/crucible/state v0.0.0 +) + +replace github.com/stablekernel/crucible/state => ../../state + +replace github.com/stablekernel/crucible/gen => ../../gen diff --git a/cmd/crucible/main.go b/cmd/crucible/main.go new file mode 100644 index 0000000..50f4e05 --- /dev/null +++ b/cmd/crucible/main.go @@ -0,0 +1,95 @@ +// Command crucible is a headless command-line tool over the crucible +// state-machine IR. It lints, renders, diffs, validates, and ejects a machine's +// serialized IR JSON without running any behavior. +package main + +import ( + "fmt" + "io" + "os" +) + +// version is the crucible CLI's own version, independent of the state module's +// v1 freeze. +const version = "0.1.0" + +// exit codes. 0 is success, 1 a runtime or load error, 2 a usage error, and a +// non-zero code (1) also signals lint findings. +const ( + exitOK = 0 + exitError = 1 + exitUsage = 2 + exitFindings = 1 +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +// run dispatches a subcommand and returns its exit code. It is the testable +// seam: every subcommand is a func(args, stdout, stderr) int, so the whole CLI +// is exercised without spawning a process. +func run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + usage(stderr) + return exitUsage + } + + cmd, rest := args[0], args[1:] + switch cmd { + case "lint": + return runLint(rest, stdout, stderr) + case "render": + return runRender(rest, stdout, stderr) + case "diff": + return runDiff(rest, stdout, stderr) + case "validate": + return runValidate(rest, stdout, stderr) + case "eject": + return runEject(rest, stdout, stderr) + case "version": + emitln(stdout, version) + return exitOK + case "-version", "--version": + emitln(stdout, version) + return exitOK + case "-h", "--help", "help": + usage(stdout) + return exitOK + default: + emitf(stderr, "crucible: unknown subcommand %q\n\n", cmd) + usage(stderr) + return exitUsage + } +} + +// printf, println, and print write diagnostics and command output to an +// io.Writer. A failure writing to stdout or stderr is unrecoverable for a +// command-line tool (the stream the user reads is gone), so the error is +// deliberately ignored here rather than threaded back through every call site. +func emitf(w io.Writer, format string, a ...any) { _, _ = fmt.Fprintf(w, format, a...) } +func emitln(w io.Writer, a ...any) { _, _ = fmt.Fprintln(w, a...) } +func emit(w io.Writer, a ...any) { _, _ = fmt.Fprint(w, a...) } + +// usage prints the top-level command listing. +func usage(w io.Writer) { + emit(w, `crucible - headless tooling for the crucible state-machine IR + +Usage: + crucible [arguments] + +Commands: + lint run static analysis and report findings + render [-format f] render the machine as mermaid (default) or dot + diff classify changes and recommend a semver bump + validate confirm the IR loads and assembles + eject [-package p] [-o f] generate typed Go behavior stubs + version print the crucible CLI version + +Each command reads an IR JSON file path, or - for stdin. + +Flags: + -version print the crucible CLI version + -h, --help show this help +`) +} diff --git a/go.work b/go.work index 99fe528..5ab3e47 100644 --- a/go.work +++ b/go.work @@ -12,6 +12,7 @@ go 1.25.11 // source modules join the workspace. use ( ./cluster + ./cmd/crucible ./durable ./e2e ./examples/dispatch From 915a7d920547894eb3fe2a0c3b5dbcc815217348 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 13 Jun 2026 21:01:57 -0400 Subject: [PATCH 5/6] feat: implement crucible cli subcommands Signed-off-by: Joshua Temple --- cmd/crucible/cmd.go | 241 +++++++++++++++++++++++++++++++++++++++++++ cmd/crucible/load.go | 50 +++++++++ cmd/crucible/stub.go | 121 ++++++++++++++++++++++ 3 files changed, 412 insertions(+) create mode 100644 cmd/crucible/cmd.go create mode 100644 cmd/crucible/load.go create mode 100644 cmd/crucible/stub.go diff --git a/cmd/crucible/cmd.go b/cmd/crucible/cmd.go new file mode 100644 index 0000000..05c70f3 --- /dev/null +++ b/cmd/crucible/cmd.go @@ -0,0 +1,241 @@ +package main + +import ( + "flag" + "io" + "os" + + "github.com/stablekernel/crucible/gen" + "github.com/stablekernel/crucible/state/analysis" + "github.com/stablekernel/crucible/state/evolution" +) + +// runLint loads an IR, assembles it with stub behaviors, runs every static +// analysis check, and prints the findings. It exits non-zero when the analysis +// reports any finding so the command can gate CI. +func runLint(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("lint", flag.ContinueOnError) + fs.SetOutput(stderr) + if code, ok := parseSingleArg(fs, args, "lint", "", stderr); !ok { + return code + } + + ir, err := loadIR(fs.Arg(0), os.Stdin) + if err != nil { + emitf(stderr, "crucible lint: %v\n", err) + return exitError + } + m, err := quench(ir) + if err != nil { + emitf(stderr, "crucible lint: %v\n", err) + return exitError + } + + report := analysis.Analyze(m) + emitln(stdout, report.String()) + if len(report.Findings) > 0 { + return exitFindings + } + return exitOK +} + +// runRender loads an IR, assembles it with stub behaviors, and prints the +// machine diagram. -format selects mermaid (the default) or dot. SVG output is +// not produced here; pipe the dot text through Graphviz for an image. +func runRender(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("render", flag.ContinueOnError) + fs.SetOutput(stderr) + format := fs.String("format", "mermaid", "diagram format: mermaid or dot") + if err := fs.Parse(reorderArgs(args)); err != nil { + return exitUsage + } + if fs.NArg() != 1 { + emitln(stderr, "usage: crucible render [-format mermaid|dot]") + return exitUsage + } + if *format != "mermaid" && *format != "dot" { + emitf(stderr, "crucible render: unknown -format %q (want mermaid or dot)\n", *format) + return exitUsage + } + + ir, err := loadIR(fs.Arg(0), os.Stdin) + if err != nil { + emitf(stderr, "crucible render: %v\n", err) + return exitError + } + m, err := quench(ir) + if err != nil { + emitf(stderr, "crucible render: %v\n", err) + return exitError + } + + switch *format { + case "dot": + emit(stdout, m.ToDOT()) + default: + emit(stdout, m.ToMermaid()) + } + return exitOK +} + +// runDiff loads two serialized IRs, classifies the changes between them, and +// prints the recommended semver bump along with the breaking and additive +// changes split apart. +func runDiff(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("diff", flag.ContinueOnError) + fs.SetOutput(stderr) + if err := fs.Parse(args); err != nil { + return exitUsage + } + if fs.NArg() != 2 { + emitln(stderr, "usage: crucible diff ") + return exitUsage + } + + oldBytes, err := readInput(fs.Arg(0), os.Stdin) + if err != nil { + emitf(stderr, "crucible diff: read old: %v\n", err) + return exitError + } + newBytes, err := readInput(fs.Arg(1), os.Stdin) + if err != nil { + emitf(stderr, "crucible diff: read new: %v\n", err) + return exitError + } + + report, err := evolution.DiffJSON[string, string, any](oldBytes, newBytes) + if err != nil { + emitf(stderr, "crucible diff: %v\n", err) + return exitError + } + + emitf(stdout, "bump: %s\n", report.SemverBump()) + var breaking, additive []evolution.Change + for _, c := range report.Changes { + if c.Breaking { + breaking = append(breaking, c) + } else { + additive = append(additive, c) + } + } + emitf(stdout, "\nbreaking (%d):\n", len(breaking)) + for _, c := range breaking { + emitf(stdout, " %-24s %s: %s\n", c.Kind, c.Path, c.Description) + } + emitf(stdout, "\nadditive (%d):\n", len(additive)) + for _, c := range additive { + emitf(stdout, " %-24s %s: %s\n", c.Kind, c.Path, c.Description) + } + return exitOK +} + +// runValidate confirms an IR loads and assembles cleanly with stub behaviors. It +// is the well-formedness gate: a malformed JSON document or a structural defect +// the lint rejects exits non-zero with a message on stderr; a clean machine +// exits zero. +func runValidate(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("validate", flag.ContinueOnError) + fs.SetOutput(stderr) + if code, ok := parseSingleArg(fs, args, "validate", "", stderr); !ok { + return code + } + + ir, err := loadIR(fs.Arg(0), os.Stdin) + if err != nil { + emitf(stderr, "crucible validate: %v\n", err) + return exitError + } + if _, err := quench(ir); err != nil { + emitf(stderr, "crucible validate: %v\n", err) + return exitError + } + emitf(stdout, "ok: %s\n", ir.Name) + return exitOK +} + +// runEject loads an IR and generates typed Go behavior stubs, writing them to +// -o or to stdout. -package overrides the generated package name. +func runEject(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("eject", flag.ContinueOnError) + fs.SetOutput(stderr) + pkg := fs.String("package", "", "generated package name (default: gen's default)") + out := fs.String("o", "", "output file (default: stdout)") + if err := fs.Parse(reorderArgs(args)); err != nil { + return exitUsage + } + if fs.NArg() != 1 { + emitln(stderr, "usage: crucible eject [-package name] [-o outfile]") + return exitUsage + } + + ir, err := loadIR(fs.Arg(0), os.Stdin) + if err != nil { + emitf(stderr, "crucible eject: %v\n", err) + return exitError + } + + var opts []gen.Option + if *pkg != "" { + opts = append(opts, gen.WithPackageName(*pkg)) + } + src, err := gen.Eject[string, string, any](*ir, opts...) + if err != nil { + emitf(stderr, "crucible eject: %v\n", err) + return exitError + } + + if *out == "" { + _, err = stdout.Write(src) + } else { + err = os.WriteFile(*out, src, 0o644) + } + if err != nil { + emitf(stderr, "crucible eject: write output: %v\n", err) + return exitError + } + return exitOK +} + +// parseSingleArg parses a flag set expecting exactly one positional argument (an +// IR path). It returns the exit code and false when parsing fails or the +// argument count is wrong; otherwise it returns (0, true). +func parseSingleArg(fs *flag.FlagSet, args []string, name, argHint string, stderr io.Writer) (int, bool) { + if err := fs.Parse(reorderArgs(args)); err != nil { + return exitUsage, false + } + if fs.NArg() != 1 { + emitf(stderr, "usage: crucible %s %s\n", name, argHint) + return exitUsage, false + } + return exitOK, true +} + +// reorderArgs moves flag tokens ahead of positional arguments so a flag may +// appear after the IR path (e.g. "render ir.json -format dot"). Go's flag +// package stops at the first non-flag token, so without this a trailing flag is +// read as a stray positional. Every value-taking flag in this CLI (-format, +// -package, -o) is moved together with its following value token; a -k=v token +// carries its own value. A bare "--" terminates flag processing, and everything +// after it is treated as positional. +func reorderArgs(args []string) []string { + valueFlags := map[string]bool{"-format": true, "-package": true, "-o": true} + var flags, positional []string + for i := 0; i < len(args); i++ { + a := args[i] + if a == "--" { + positional = append(positional, args[i+1:]...) + break + } + if len(a) > 1 && a[0] == '-' { + flags = append(flags, a) + // Pull the value token along for a space-separated value flag. + if valueFlags[a] && i+1 < len(args) { + flags = append(flags, args[i+1]) + i++ + } + continue + } + positional = append(positional, a) + } + return append(flags, positional...) +} diff --git a/cmd/crucible/load.go b/cmd/crucible/load.go new file mode 100644 index 0000000..aa33ba5 --- /dev/null +++ b/cmd/crucible/load.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "io" + "os" + + "github.com/stablekernel/crucible/state" +) + +// readInput returns the bytes of the named IR file, or of stdin when path is +// "-". It is the single entry point every subcommand uses to read an IR +// argument, so "-" means stdin uniformly. +func readInput(path string, stdin io.Reader) ([]byte, error) { + if path == "-" { + return io.ReadAll(stdin) + } + return os.ReadFile(path) +} + +// loadIR reads and decodes an IR from the named path (or stdin for "-"). The +// machine's state, event, and context types are fixed to string, string, any — +// the headless tool never instantiates the context, so any is sufficient to load +// and inspect the structure. +func loadIR(path string, stdin io.Reader) (*state.IR[string, string, any], error) { + b, err := readInput(path, stdin) + if err != nil { + return nil, err + } + ir, err := state.LoadFromJSON[string, string, any](b) + if err != nil { + return nil, fmt.Errorf("load IR: %w", err) + } + return ir, nil +} + +// quench binds an IR against a stub registry and assembles a *Machine. Quench +// panics on a structural defect the lint rejects (an undeclared transition +// target, for example), so the panic is recovered and returned as an error +// rather than crashing the tool. +func quench(ir *state.IR[string, string, any]) (m *state.Machine[string, string, any], err error) { + defer func() { + if r := recover(); r != nil { + m = nil + err = fmt.Errorf("quench: %v", r) + } + }() + reg := stubRegistry(ir) + return ir.Provide(reg).Quench(), nil +} diff --git a/cmd/crucible/stub.go b/cmd/crucible/stub.go new file mode 100644 index 0000000..d3dd2a1 --- /dev/null +++ b/cmd/crucible/stub.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + + "github.com/stablekernel/crucible/state" +) + +// stubRegistry walks an IR to enumerate every referenced behavior name by kind +// and registers a deterministic no-op for each into a fresh registry. A +// structural IR carries behavior references by name only (state.Ref); the kernel +// binds those names to implementations at Quench time and panics on any unbound +// ref. render, lint, and validate care only about a machine's structure, not its +// behavior, so a no-op stub for every referenced name lets Provide(reg).Quench() +// produce a *Machine without real implementations. +// +// The stubs are total and side-effect free: a guard returns false, an action +// returns a zero effect, a reducer returns its input context unchanged, and a +// service returns nil. None of these run during render/lint/validate (no instance +// is cast and no event is fired), so their bodies only need to satisfy binding. +func stubRegistry(ir *state.IR[string, string, any]) *state.Registry[any] { + var b behaviorNames + for i := range ir.States { + b.walkState(&ir.States[i]) + } + + reg := state.NewRegistry[any]() + for name := range b.guards { + reg.Guard(name, func(state.GuardCtx[any]) bool { return false }) + } + for name := range b.actions { + reg.Action(name, func(state.ActionCtx[any]) (state.Effect, error) { return nil, nil }) + } + for name := range b.reducers { + reg.Reducer(name, func(in state.AssignCtx[any]) any { return in.Entity }) + } + for name := range b.services { + reg.Service(name, func(context.Context, state.ServiceCtx[any]) (any, error) { return nil, nil }) + } + return reg +} + +// behaviorNames accumulates the distinct behavior names referenced by an IR, +// bucketed by the registry slot each kind binds against. +type behaviorNames struct { + guards set + actions set + reducers set + services set +} + +type set map[string]struct{} + +func (s *set) add(name string) { + if name == "" { + return + } + if *s == nil { + *s = set{} + } + (*s)[name] = struct{}{} +} + +// walkState records every behavior reference on a state and recurses through its +// transitions, invocations, children, and regions. The kind mapping follows the +// engine's registries: entry/exit/done actions and transition effects are +// ACTIONS; entry/exit/transition assigns are REDUCERS; transition guards and the +// named-ref leaves of a composite guard expression are GUARDS; an invocation's +// Src is a SERVICE. An invocation's OnDone/OnError are events, not behaviors, so +// they are not enumerated. +func (b *behaviorNames) walkState(s *state.State[string, string, any]) { + for _, r := range s.OnEntry { + b.actions.add(r.Name) + } + for _, r := range s.OnExit { + b.actions.add(r.Name) + } + for _, r := range s.OnDone { + b.actions.add(r.Name) + } + for _, r := range s.OnEntryAssign { + b.reducers.add(r.Name) + } + for _, r := range s.OnExitAssign { + b.reducers.add(r.Name) + } + for i := range s.Transitions { + b.walkTransition(&s.Transitions[i]) + } + for i := range s.Invoke { + b.services.add(s.Invoke[i].Src.Name) + } + for i := range s.Children { + b.walkState(&s.Children[i]) + } + for i := range s.Regions { + for j := range s.Regions[i].States { + b.walkState(&s.Regions[i].States[j]) + } + } +} + +// walkTransition records the guard, effect (action), and assign (reducer) +// references on one edge, including the named-ref leaves of a composite guard +// expression. +func (b *behaviorNames) walkTransition(t *state.Transition[string, string, any]) { + for _, r := range t.Guards { + b.guards.add(r.Name) + } + for _, r := range t.Effects { + b.actions.add(r.Name) + } + for _, r := range t.Assigns { + b.reducers.add(r.Name) + } + if t.GuardExpr != nil { + for _, r := range t.GuardExpr.LeafRefs() { + b.guards.add(r.Name) + } + } +} From 21b16c2b9b1e11c780ace77b309a33fb9c18bf01 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 13 Jun 2026 21:02:02 -0400 Subject: [PATCH 6/6] test: add crucible cli tests and docs Signed-off-by: Joshua Temple --- cmd/crucible/CHANGELOG.md | 16 ++ cmd/crucible/README.md | 87 ++++++++++ cmd/crucible/cmd_test.go | 229 +++++++++++++++++++++++++++ cmd/crucible/stub_test.go | 31 ++++ cmd/crucible/testdata/clean.json | 1 + cmd/crucible/testdata/defect.json | 1 + cmd/crucible/testdata/malformed.json | 1 + cmd/crucible/testdata/new_major.json | 1 + cmd/crucible/testdata/new_minor.json | 1 + cmd/crucible/testdata/old.json | 1 + 10 files changed, 369 insertions(+) create mode 100644 cmd/crucible/CHANGELOG.md create mode 100644 cmd/crucible/README.md create mode 100644 cmd/crucible/cmd_test.go create mode 100644 cmd/crucible/stub_test.go create mode 100644 cmd/crucible/testdata/clean.json create mode 100644 cmd/crucible/testdata/defect.json create mode 100644 cmd/crucible/testdata/malformed.json create mode 100644 cmd/crucible/testdata/new_major.json create mode 100644 cmd/crucible/testdata/new_minor.json create mode 100644 cmd/crucible/testdata/old.json diff --git a/cmd/crucible/CHANGELOG.md b/cmd/crucible/CHANGELOG.md new file mode 100644 index 0000000..82d1b7c --- /dev/null +++ b/cmd/crucible/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to the crucible CLI are documented here. This module is +versioned independently of the `state` module. + +## 0.1.0 + +Initial release. + +- `lint` runs static analysis over an IR and exits non-zero on findings. +- `render` emits a Mermaid or DOT diagram of a machine. +- `diff` classifies the changes between two IRs and recommends a semver bump. +- `validate` confirms an IR loads and assembles. +- `eject` generates typed Go behavior stubs from an IR. +- `version` (and `-version`) prints the CLI version. +- Commands read an IR file path or `-` for stdin. diff --git a/cmd/crucible/README.md b/cmd/crucible/README.md new file mode 100644 index 0000000..4aaeac9 --- /dev/null +++ b/cmd/crucible/README.md @@ -0,0 +1,87 @@ +# crucible + +A headless command-line tool for the crucible state-machine IR. It lints, +renders, diffs, validates, and ejects a machine's serialized IR JSON without +running any behavior. Every command reads an IR JSON file path, or `-` for +stdin. + +## Behavior-free operation + +A serialized IR carries behavior references by name only; the kernel binds those +names to real implementations when it assembles a machine. Commands that need an +assembled machine (`lint`, `render`, `validate`) do not have the host's real +guards, actions, reducers, and services. They register a deterministic no-op stub +for every referenced name first, so the machine assembles from its structure +alone. The stubs never run during these commands (no instance is cast and no +event is fired), so the structural view is exactly what the IR describes. + +## Commands + +### lint + +``` +crucible lint +``` + +Runs every static analysis check and prints the findings. Exits non-zero when +the analysis reports any finding, so it can gate CI. + +### render + +``` +crucible render [-format mermaid|dot] +``` + +Renders the machine as a Mermaid `stateDiagram-v2` (the default) or as Graphviz +DOT. Output is text. For an SVG, pipe the DOT through Graphviz (`crucible render +m.json -format dot | dot -Tsvg`); native SVG rendering is a future addition. + +### diff + +``` +crucible diff +``` + +Classifies the changes between two serialized IRs, prints the recommended semver +bump (`major`, `minor`, or `patch`), and lists the breaking and additive changes +separately. + +### validate + +``` +crucible validate +``` + +Confirms the IR loads and assembles cleanly. A malformed JSON document or a +structural defect the lint rejects exits non-zero with a message on stderr; a +clean machine exits zero. + +### eject + +``` +crucible eject [-package name] [-o outfile] +``` + +Generates typed Go behavior stubs for every referenced guard, action, reducer, +and service, plus a `Provide` function that registers them. Writes to `outfile` +or, by default, to stdout. + +### version + +``` +crucible version +crucible -version +``` + +Prints the CLI version. + +## Exit codes + +- `0` success +- `1` runtime or load error, and lint findings +- `2` usage error + +## Versioning + +The crucible CLI is versioned independently of the `state` module. It is not part +of the `state` v1 freeze, so it can move at its own pace. diff --git a/cmd/crucible/cmd_test.go b/cmd/crucible/cmd_test.go new file mode 100644 index 0000000..f592eed --- /dev/null +++ b/cmd/crucible/cmd_test.go @@ -0,0 +1,229 @@ +package main + +import ( + "bytes" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" +) + +// runCmd invokes the CLI dispatcher in-process and returns the exit code with +// captured stdout and stderr, so every command is tested without a subprocess. +func runCmd(args ...string) (code int, stdout, stderr string) { + var out, errBuf bytes.Buffer + code = run(args, &out, &errBuf) + return code, out.String(), errBuf.String() +} + +func TestRender(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {"mermaid default", []string{"render", "testdata/clean.json"}, "stateDiagram-v2"}, + {"dot flag", []string{"render", "testdata/clean.json", "-format", "dot"}, "digraph"}, + {"flag before path", []string{"render", "-format", "dot", "testdata/clean.json"}, "digraph"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, out, errOut := runCmd(tc.args...) + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + if !strings.Contains(out, tc.want) { + t.Fatalf("render output missing %q:\n%s", tc.want, out) + } + }) + } +} + +func TestRender_UnknownFormat(t *testing.T) { + code, _, errOut := runCmd("render", "testdata/clean.json", "-format", "svg") + if code != exitUsage { + t.Fatalf("exit = %d, want %d", code, exitUsage) + } + if !strings.Contains(errOut, "unknown -format") { + t.Fatalf("stderr missing format error: %s", errOut) + } +} + +func TestDiff(t *testing.T) { + cases := []struct { + name string + old, new string + wantBump string + }{ + {"additive change is minor", "testdata/old.json", "testdata/new_minor.json", "bump: minor"}, + {"breaking change is major", "testdata/old.json", "testdata/new_major.json", "bump: major"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, out, errOut := runCmd("diff", tc.old, tc.new) + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + if !strings.Contains(out, tc.wantBump) { + t.Fatalf("diff output missing %q:\n%s", tc.wantBump, out) + } + }) + } +} + +func TestDiff_SplitsBreakingAndAdditive(t *testing.T) { + code, out, _ := runCmd("diff", "testdata/old.json", "testdata/new_major.json") + if code != exitOK { + t.Fatalf("exit = %d, want %d", code, exitOK) + } + if !strings.Contains(out, "breaking (2)") { + t.Errorf("want 2 breaking changes:\n%s", out) + } + if !strings.Contains(out, "additive (1)") { + t.Errorf("want 1 additive change:\n%s", out) + } +} + +func TestValidate(t *testing.T) { + cases := []struct { + name string + path string + want int + }{ + {"clean IR validates", "testdata/clean.json", exitOK}, + {"malformed JSON fails", "testdata/malformed.json", exitError}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, _, errOut := runCmd("validate", tc.path) + if code != tc.want { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, tc.want, errOut) + } + if tc.want == exitError && strings.TrimSpace(errOut) == "" { + t.Fatalf("expected a clear stderr message on failure") + } + }) + } +} + +func TestLint(t *testing.T) { + cases := []struct { + name string + path string + want int + }{ + {"clean IR has no findings", "testdata/clean.json", exitOK}, + {"defective IR reports findings", "testdata/defect.json", exitFindings}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, out, errOut := runCmd("lint", tc.path) + if code != tc.want { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, tc.want, errOut) + } + if tc.want == exitFindings && !strings.Contains(out, "unreachable_state") { + t.Fatalf("expected an unreachable_state finding:\n%s", out) + } + }) + } +} + +func TestEject_StdoutContainsKeyIdents(t *testing.T) { + code, out, errOut := runCmd("eject", "testdata/clean.json", "-package", "machine") + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + for _, want := range []string{ + "package machine", + "func Provide(reg *state.Registry[", + `reg.Guard("hasItems"`, + `reg.Action("notify"`, + `reg.Service("charge"`, + } { + if !strings.Contains(out, want) { + t.Errorf("ejected source missing %q:\n%s", want, out) + } + } + // The generated source must be parseable Go. + if _, err := parser.ParseFile(token.NewFileSet(), "gen.go", out, parser.AllErrors); err != nil { + t.Fatalf("ejected source does not parse: %v\n%s", err, out) + } +} + +func TestEject_WritesOutfile(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "gen.go") + code, _, errOut := runCmd("eject", "testdata/clean.json", "-package", "machine", "-o", out) + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + b, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read output: %v", err) + } + if !strings.Contains(string(b), "package machine") { + t.Fatalf("outfile missing package decl:\n%s", b) + } +} + +func TestVersion(t *testing.T) { + for _, args := range [][]string{{"version"}, {"-version"}, {"--version"}} { + code, out, _ := runCmd(args...) + if code != exitOK { + t.Fatalf("%v exit = %d, want %d", args, code, exitOK) + } + if strings.TrimSpace(out) != version { + t.Fatalf("%v printed %q, want %q", args, strings.TrimSpace(out), version) + } + } +} + +func TestUsageErrors(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"no args", nil}, + {"unknown subcommand", []string{"frobnicate"}}, + {"lint missing path", []string{"lint"}}, + {"diff one path", []string{"diff", "testdata/old.json"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, _, _ := runCmd(tc.args...) + if code != exitUsage { + t.Fatalf("exit = %d, want %d", code, exitUsage) + } + }) + } +} + +func TestStdinInput(t *testing.T) { + // render reading from "-" exercises the stdin path; swap os.Stdin for the + // duration so the dispatcher's hard-wired os.Stdin reads the fixture. + b, err := os.ReadFile("testdata/clean.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + orig := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = orig }) + go func() { + _, _ = w.Write(b) + _ = w.Close() + }() + + code, out, errOut := runCmd("render", "-") + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + if !strings.Contains(out, "stateDiagram-v2") { + t.Fatalf("stdin render missing diagram:\n%s", out) + } +} diff --git a/cmd/crucible/stub_test.go b/cmd/crucible/stub_test.go new file mode 100644 index 0000000..c066194 --- /dev/null +++ b/cmd/crucible/stub_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "os" + "testing" + + "github.com/stablekernel/crucible/state" +) + +// TestStubRegistry_EnumeratesEveryBehavior loads the clean fixture (which +// references two guards, two actions, one reducer, and one service) and confirms +// Provide(stubRegistry).Quench succeeds, proving every referenced name was +// stubbed. An unstubbed name would panic Quench with an *UnboundRefError. +func TestStubRegistry_EnumeratesEveryBehavior(t *testing.T) { + b, err := os.ReadFile("testdata/clean.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + ir, err := state.LoadFromJSON[string, string, any](b) + if err != nil { + t.Fatalf("load IR: %v", err) + } + + m, err := quench(ir) + if err != nil { + t.Fatalf("quench with stubs: %v", err) + } + if m == nil { + t.Fatal("quench returned a nil machine") + } +} diff --git a/cmd/crucible/testdata/clean.json b/cmd/crucible/testdata/clean.json new file mode 100644 index 0000000..173bf73 --- /dev/null +++ b/cmd/crucible/testdata/clean.json @@ -0,0 +1 @@ +{"schemaVersion":"1.0","name":"orders","states":[{"name":"cart","transitions":[{"from":"cart","to":"paying","on":"checkout","guards":[{"name":"hasItems"}],"assigns":[{"name":"applyTotal"}]}],"onEntry":[{"name":"logEntry"}]},{"name":"paying","transitions":[{"from":"paying","to":"done","on":"paid","guards":[{"name":"isPaid"}],"effects":[{"name":"notify"}]}],"invoke":[{"src":{"name":"charge"},"onDone":"paid","onError":"failed"}]},{"name":"done","isFinal":true}],"initial":"cart","hasInitial":true} diff --git a/cmd/crucible/testdata/defect.json b/cmd/crucible/testdata/defect.json new file mode 100644 index 0000000..1bd1662 --- /dev/null +++ b/cmd/crucible/testdata/defect.json @@ -0,0 +1 @@ +{"schemaVersion":"1.0","name":"withdefect","states":[{"name":"a","transitions":[{"from":"a","to":"b","on":"go"}]},{"name":"b","isFinal":true},{"name":"orphan","transitions":[{"from":"orphan","to":"orphan","on":"noop"}]}],"initial":"a","hasInitial":true} diff --git a/cmd/crucible/testdata/malformed.json b/cmd/crucible/testdata/malformed.json new file mode 100644 index 0000000..49ffb3d --- /dev/null +++ b/cmd/crucible/testdata/malformed.json @@ -0,0 +1 @@ +{ this is not valid json \ No newline at end of file diff --git a/cmd/crucible/testdata/new_major.json b/cmd/crucible/testdata/new_major.json new file mode 100644 index 0000000..bcb39da --- /dev/null +++ b/cmd/crucible/testdata/new_major.json @@ -0,0 +1 @@ +{"schemaVersion":"1.0","name":"flow","states":[{"name":"a","transitions":[{"from":"a","to":"c","on":"go"}]},{"name":"c","isFinal":true}],"initial":"a","hasInitial":true} diff --git a/cmd/crucible/testdata/new_minor.json b/cmd/crucible/testdata/new_minor.json new file mode 100644 index 0000000..12adeb3 --- /dev/null +++ b/cmd/crucible/testdata/new_minor.json @@ -0,0 +1 @@ +{"schemaVersion":"1.0","name":"flow","states":[{"name":"a","transitions":[{"from":"a","to":"b","on":"go"},{"from":"a","to":"a","on":"reset"}]},{"name":"b","isFinal":true}],"initial":"a","hasInitial":true} diff --git a/cmd/crucible/testdata/old.json b/cmd/crucible/testdata/old.json new file mode 100644 index 0000000..d79cf74 --- /dev/null +++ b/cmd/crucible/testdata/old.json @@ -0,0 +1 @@ +{"schemaVersion":"1.0","name":"flow","states":[{"name":"a","transitions":[{"from":"a","to":"b","on":"go"}]},{"name":"b","isFinal":true}],"initial":"a","hasInitial":true}