From 087c7d82f04ead4343118eb09759447ec7a09f8b Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 17 Jun 2026 12:05:32 -0400 Subject: [PATCH] feat(cmd/crucible): add simulate subcommand to trace events through an IR crucible simulate fires an ordered event sequence against a machine assembled from an IR and prints the per-event step trace (from/to state, outcome, emitted effects) and the final state, in text or -format json. - Events come from -events (comma list) or -events-file (a bare name array or a conformance scenario JSON); exactly one is required. - A headless IR carries no real behavior, so guards return seeded verdicts: -guard name=bool (repeatable), unseeded guards default to false; actions, reducers, and services are no-ops. -initial overrides the declared start state. - A guard-blocked or invalid transition is a normal observable outcome (exit 0); an unknown event or action failure exits non-zero. - Reuses state/conformance.RunAgainst and classifies the result error with errors.As to keep blocked-but-valid traces distinct from real failures. Closes #175. --- cmd/crucible/CHANGELOG.md | 5 + cmd/crucible/README.md | 18 ++ cmd/crucible/cmd.go | 5 +- cmd/crucible/load.go | 36 +++- cmd/crucible/main.go | 3 + cmd/crucible/simulate.go | 309 ++++++++++++++++++++++++++++++++++ cmd/crucible/simulate_test.go | 173 +++++++++++++++++++ 7 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 cmd/crucible/simulate.go create mode 100644 cmd/crucible/simulate_test.go diff --git a/cmd/crucible/CHANGELOG.md b/cmd/crucible/CHANGELOG.md index 7f46d65..2a2ab9b 100644 --- a/cmd/crucible/CHANGELOG.md +++ b/cmd/crucible/CHANGELOG.md @@ -14,6 +14,11 @@ versioned independently of the `state` module. - `diff -format` selects `text` (default) or `json` output. - `diff -exit-code` exits non-zero when the recommended bump is `major` (at least one breaking change), so a diff can gate CI. +- `simulate` fires a sequence of events against a machine from a given state + and prints the step trace. `-events` takes a comma-separated list; `-events-file` + accepts a JSON events file. `-guard name=bool` seeds a guard verdict (unseeded + guards default to false). `-initial` overrides the IR's declared start state. + `-format` selects `text` (default) or `json` output. ## [0.1.0] - 2026-06-13 diff --git a/cmd/crucible/README.md b/cmd/crucible/README.md index 53e3190..6a5b728 100644 --- a/cmd/crucible/README.md +++ b/cmd/crucible/README.md @@ -72,6 +72,24 @@ 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. +### simulate + +``` +crucible simulate -events e1,e2 [-events-file f] [-initial S] [-guard name=bool] [-format text|json] +``` + +Fires a sequence of events against a machine assembled from the IR and prints the +resulting step trace (each event's from/to state, outcome, and emitted effects), +then the final state. The events come from `-events` (a comma-separated list) or +`-events-file` (a JSON file that is either a bare array of event names or a +conformance scenario object); exactly one is required. Since the IR carries no +real behavior, guards return seeded verdicts: `-guard name=bool` (repeatable) +sets a guard's result, and any unseeded guard defaults to `false`; actions, +reducers, and services are no-ops. `-initial` overrides the IR's declared start +state. `-format` selects human-readable `text` (the default) or `json`. A +guard-blocked or invalid transition is a normal observable outcome and exits +zero; an unknown event or an action failure exits non-zero. + ### version ``` diff --git a/cmd/crucible/cmd.go b/cmd/crucible/cmd.go index 45d1995..b6258d5 100644 --- a/cmd/crucible/cmd.go +++ b/cmd/crucible/cmd.go @@ -260,7 +260,10 @@ func parseSingleArg(fs *flag.FlagSet, args []string, name, argHint string, stder // 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} + valueFlags := map[string]bool{ + "-format": true, "-package": true, "-o": true, + "-events": true, "-events-file": true, "-initial": true, "-guard": true, + } var flags, positional []string for i := 0; i < len(args); i++ { a := args[i] diff --git a/cmd/crucible/load.go b/cmd/crucible/load.go index aa33ba5..34a669a 100644 --- a/cmd/crucible/load.go +++ b/cmd/crucible/load.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "io" "os" @@ -39,12 +40,45 @@ func loadIR(path string, stdin io.Reader) (*state.IR[string, string, any], error // 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) { + return quenchWith(ir, stubRegistry(ir)) +} + +// quenchWith binds an IR against a caller-supplied registry and assembles a +// *Machine. Like quench, it recovers the panic Quench raises on a structural +// defect and returns it as an error. simulate uses this to bind a registry whose +// guards return seeded verdicts rather than the always-false stubs. +func quenchWith(ir *state.IR[string, string, any], reg *state.Registry[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 } + +// simulateRegistry walks an IR to enumerate every referenced behavior name, then +// registers behaviors suitable for a structural simulation. Guards return the +// seeded verdict from verdicts (defaulting to false for an unseeded guard), so a +// caller can drive a machine down a chosen path without real implementations. +// Actions, reducers, and services remain total no-ops, matching stubRegistry. +func simulateRegistry(ir *state.IR[string, string, any], verdicts map[string]bool) *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 verdicts[name] }) + } + 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 +} diff --git a/cmd/crucible/main.go b/cmd/crucible/main.go index 0a2c3b6..a08691a 100644 --- a/cmd/crucible/main.go +++ b/cmd/crucible/main.go @@ -50,6 +50,8 @@ func run(args []string, stdout, stderr io.Writer) int { return runValidate(rest, stdout, stderr) case "eject": return runEject(rest, stdout, stderr) + case "simulate": + return runSimulate(rest, stdout, stderr) case "version": emitln(stdout, version) return exitOK @@ -87,6 +89,7 @@ Commands: diff [-format f] [-exit-code] classify changes and recommend a semver bump validate confirm the IR loads and assembles eject [-package p] [-o f] generate typed Go behavior stubs + simulate -events e1,e2 [flags] fire events and print the step trace version print the crucible CLI version Each command reads an IR JSON file path, or - for stdin. diff --git a/cmd/crucible/simulate.go b/cmd/crucible/simulate.go new file mode 100644 index 0000000..e8ed006 --- /dev/null +++ b/cmd/crucible/simulate.go @@ -0,0 +1,309 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/stablekernel/crucible/state" + "github.com/stablekernel/crucible/state/conformance" +) + +// guardFlags collects repeated -guard name=bool tokens into a verdict map. Each +// token seeds the boolean a named guard returns during the simulation; an +// unseeded guard defaults to false. +type guardFlags map[string]bool + +func (g guardFlags) String() string { return fmt.Sprint(map[string]bool(g)) } + +func (g guardFlags) Set(s string) error { + idx := strings.Index(s, "=") + if idx < 1 { + return fmt.Errorf("malformed -guard %q: want name=true|false", s) + } + name := s[:idx] + val, err := strconv.ParseBool(s[idx+1:]) + if err != nil { + return fmt.Errorf("malformed -guard %q: %w", s, err) + } + g[name] = val + return nil +} + +// simulateStepDTO is the JSON form of one fired event's outcome. +type simulateStepDTO struct { + Event string `json:"event"` + From string `json:"from"` + To string `json:"to"` + Outcome string `json:"outcome"` + Effects []string `json:"effects"` +} + +// simulateResultDTO is the JSON form of a whole simulation run. +type simulateResultDTO struct { + InitialState string `json:"initialState"` + Steps []simulateStepDTO `json:"steps"` + FinalState string `json:"finalState"` + Effects []string `json:"effects"` +} + +// collectKnownEvents enumerates every event name referenced by a transition in +// the IR, so the simulation codec can reject an event the machine never declares. +func collectKnownEvents(ir *state.IR[string, string, any]) map[string]bool { + known := make(map[string]bool) + for i := range ir.States { + collectEventsFromState(&ir.States[i], known) + } + return known +} + +func collectEventsFromState(s *state.State[string, string, any], known map[string]bool) { + for _, t := range s.Transitions { + if t.On != "" { + known[t.On] = true + } + } + for i := range s.Children { + collectEventsFromState(&s.Children[i], known) + } + for i := range s.Regions { + for j := range s.Regions[i].States { + collectEventsFromState(&s.Regions[i].States[j], known) + } + } +} + +// parseEventsFile reads an events file in either form: a bare JSON array of event +// names, or a conformance scenario object whose Events carry the names. +func parseEventsFile(data []byte) ([]string, error) { + var arr []string + if err := json.Unmarshal(data, &arr); err == nil { + return arr, nil + } + var sc conformance.Scenario + if err := json.Unmarshal(data, &sc); err != nil { + return nil, fmt.Errorf("events file: expected JSON array or scenario object: %w", err) + } + out := make([]string, len(sc.Events)) + for i, ev := range sc.Events { + out[i] = ev.Event + } + return out, nil +} + +// runSimulate fires a sequence of events against a machine assembled from the IR +// and prints the resulting step trace. Guards return seeded verdicts (-guard +// name=bool, default false); actions, reducers, and services are no-ops. The run +// starts from -initial or the IR's declared initial state. A guard-blocked or +// invalid transition is a normal observable outcome (exit 0); an unknown event or +// an action failure is an error (exit 1). +func runSimulate(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("simulate", flag.ContinueOnError) + fs.SetOutput(stderr) + + eventsFlag := fs.String("events", "", "comma-separated event list") + eventsFile := fs.String("events-file", "", "path to a JSON events file") + initial := fs.String("initial", "", "override the start state") + format := fs.String("format", "text", "output format: text or json") + verdicts := make(guardFlags) + fs.Var(verdicts, "guard", "seed a guard verdict: name=true|false (repeatable)") + + if err := fs.Parse(reorderArgs(args)); err != nil { + return exitUsage + } + if fs.NArg() != 1 { + emitln(stderr, "usage: crucible simulate -events e1,e2 [-initial S] [-guard name=bool] [-format text|json]") + return exitUsage + } + + hasEvents := *eventsFlag != "" + hasEventsFile := *eventsFile != "" + if !hasEvents && !hasEventsFile { + emitln(stderr, "crucible simulate: one of -events or -events-file is required") + return exitUsage + } + if hasEvents && hasEventsFile { + emitln(stderr, "crucible simulate: -events and -events-file are mutually exclusive") + return exitUsage + } + + switch *format { + case "text", "json": + default: + emitf(stderr, "crucible simulate: unknown -format %q (want text or json)\n", *format) + return exitUsage + } + + irPath := fs.Arg(0) + irData, err := loadIR(irPath, os.Stdin) + if err != nil { + emitf(stderr, "crucible simulate: %v\n", err) + return exitError + } + + var events []string + if hasEvents { + events = strings.Split(*eventsFlag, ",") + } else { + b, readErr := readInput(*eventsFile, os.Stdin) + if readErr != nil { + emitf(stderr, "crucible simulate: read events file: %v\n", readErr) + return exitError + } + events, err = parseEventsFile(b) + if err != nil { + emitf(stderr, "crucible simulate: %v\n", err) + return exitError + } + } + + if len(events) == 0 { + emitln(stderr, "crucible simulate: event list must not be empty") + return exitUsage + } + for _, ev := range events { + if ev == "" { + emitln(stderr, "crucible simulate: event names must not be empty") + return exitUsage + } + } + + var startState string + switch { + case *initial != "": + startState = *initial + case irData.HasInitial: + startState = fmt.Sprint(irData.Initial) + default: + emitln(stderr, "crucible simulate: IR has no initial state; use -initial to specify one") + return exitError + } + + sc := conformance.Scenario{ + InitialState: startState, + Events: make([]conformance.Event, len(events)), + } + for i, ev := range events { + sc.Events[i] = conformance.Event{Event: ev} + } + + known := collectKnownEvents(irData) + codec := conformance.EventCodec[string]{ + Named: func(e string) string { return e }, + Resolve: func(n string) (string, bool) { + return n, known[n] + }, + } + + m, err := quenchWith(irData, simulateRegistry(irData, verdicts)) + if err != nil { + emitf(stderr, "crucible simulate: %v\n", err) + return exitError + } + + result := conformance.RunAgainst(m, sc, any(nil), codec, startState) + + exitCode := classifySimulateErr(result.Err) + + if *format == "json" { + if code := emitSimulateJSON(result, startState, stdout, stderr); code != exitOK { + return code + } + } else { + emitSimulateText(result, startState, stdout) + } + + return exitCode +} + +// classifySimulateErr maps a run error to an exit code. A guard-blocked or +// invalid transition is a normal observable outcome (exit 0): the simulation ran, +// it just did not advance. An unknown event, an action failure, or any other +// error is a genuine failure (exit 1). +func classifySimulateErr(err error) int { + if err == nil { + return exitOK + } + var guardFailed *state.GuardFailedError + var invalidTransition *state.InvalidTransitionError + var unknownEvent *conformance.ErrUnknownEvent + var actionFailed *state.ActionFailedError + + switch { + case errors.As(err, &guardFailed): + return exitOK + case errors.As(err, &invalidTransition): + return exitOK + case errors.As(err, &unknownEvent): + return exitError + case errors.As(err, &actionFailed): + return exitError + default: + return exitError + } +} + +// emitSimulateText prints a human-readable step trace: the start state, one line +// per fired event (event, from -> to, outcome, any error, any effects), and the +// final state. +func emitSimulateText(result conformance.ScenarioResult[string], startState string, stdout io.Writer) { + emitf(stdout, "initial: %s\n", startState) + for _, step := range result.Trace.Steps { + effects := "" + if len(step.EffectsEmitted) > 0 { + effects = " effects: " + strings.Join(step.EffectsEmitted, ", ") + } + errSuffix := "" + if step.Err != "" { + errSuffix = " (" + step.Err + ")" + } + emitf(stdout, "%-10s %s -> %-10s %s%s%s\n", + step.Event, + step.FromState, + step.ToState, + step.Outcome, + errSuffix, + effects, + ) + } + emitf(stdout, "final: %s\n", result.FinalState) +} + +// emitSimulateJSON marshals the run to the simulateResultDTO shape, normalizing +// nil effect slices to empty arrays so the JSON is stable. +func emitSimulateJSON(result conformance.ScenarioResult[string], startState string, stdout, stderr io.Writer) int { + dto := simulateResultDTO{ + InitialState: startState, + Steps: make([]simulateStepDTO, len(result.Trace.Steps)), + FinalState: result.FinalState, + Effects: result.Effects, + } + if dto.Effects == nil { + dto.Effects = make([]string, 0) + } + for i, step := range result.Trace.Steps { + effects := step.EffectsEmitted + if effects == nil { + effects = make([]string, 0) + } + dto.Steps[i] = simulateStepDTO{ + Event: step.Event, + From: step.FromState, + To: step.ToState, + Outcome: step.Outcome, + Effects: effects, + } + } + b, err := json.Marshal(dto) + if err != nil { + emitf(stderr, "crucible simulate: marshal JSON: %v\n", err) + return exitError + } + emitf(stdout, "%s\n", b) + return exitOK +} diff --git a/cmd/crucible/simulate_test.go b/cmd/crucible/simulate_test.go new file mode 100644 index 0000000..c76a021 --- /dev/null +++ b/cmd/crucible/simulate_test.go @@ -0,0 +1,173 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSimulate_TextSeededGuards(t *testing.T) { + code, out, errOut := runCmd( + "simulate", "testdata/clean.json", + "-events", "checkout,paid", + "-guard", "hasItems=true", + "-guard", "isPaid=true", + ) + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + for _, want := range []string{"cart -> paying", "paying -> done", "final: done"} { + if !strings.Contains(out, want) { + t.Errorf("output missing %q:\n%s", want, out) + } + } +} + +func TestSimulate_GuardBlocks(t *testing.T) { + code, out, errOut := runCmd("simulate", "testdata/clean.json", "-events", "checkout") + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + if !strings.Contains(out, "GuardFailed") { + t.Errorf("output missing GuardFailed outcome:\n%s", out) + } + if !strings.Contains(out, "cart -> cart") { + t.Errorf("guard block should keep state at cart:\n%s", out) + } + if !strings.Contains(out, "final: cart") { + t.Errorf("final state should be cart:\n%s", out) + } +} + +func TestSimulate_FormatJSON(t *testing.T) { + code, out, errOut := runCmd( + "simulate", "testdata/clean.json", + "-events", "checkout,paid", + "-guard", "hasItems=true", + "-guard", "isPaid=true", + "-format", "json", + ) + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + var dto simulateResultDTO + if err := json.Unmarshal([]byte(out), &dto); err != nil { + t.Fatalf("unmarshal JSON output: %v\n%s", err, out) + } + if dto.FinalState != "done" { + t.Errorf("finalState = %q, want done", dto.FinalState) + } + if len(dto.Steps) != 2 { + t.Fatalf("len(steps) = %d, want 2:\n%s", len(dto.Steps), out) + } + for i, step := range dto.Steps { + if step.Outcome != "Success" { + t.Errorf("step %d outcome = %q, want Success", i, step.Outcome) + } + } +} + +func TestSimulate_EventsFile(t *testing.T) { + dir := t.TempDir() + cases := []struct { + name string + data string + }{ + {"scenario object", `{"events":[{"event":"checkout"},{"event":"paid"}]}`}, + {"bare array", `["checkout","paid"]`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(dir, strings.ReplaceAll(tc.name, " ", "_")+".json") + if err := os.WriteFile(path, []byte(tc.data), 0o644); err != nil { + t.Fatalf("write events file: %v", err) + } + code, out, errOut := runCmd( + "simulate", "testdata/clean.json", + "-events-file", path, + "-guard", "hasItems=true", + "-guard", "isPaid=true", + ) + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + if !strings.Contains(out, "final: done") { + t.Errorf("final state should be done:\n%s", out) + } + }) + } +} + +func TestSimulate_Validation(t *testing.T) { + dir := t.TempDir() + cases := []struct { + name string + args []string + }{ + {"neither events nor events-file", []string{"simulate", "testdata/clean.json"}}, + {"both events and events-file", []string{ + "simulate", "testdata/clean.json", + "-events", "checkout", "-events-file", filepath.Join(dir, "x.json"), + }}, + {"bad guard token", []string{ + "simulate", "testdata/clean.json", + "-events", "checkout", "-guard", "hasItems", + }}, + {"bad format value", []string{ + "simulate", "testdata/clean.json", + "-events", "checkout", "-format", "yaml", + }}, + {"empty events string", []string{ + "simulate", "testdata/clean.json", "-events", "", + }}, + } + 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 TestSimulate_UnknownEvent(t *testing.T) { + code, _, errOut := runCmd("simulate", "testdata/clean.json", "-events", "nope") + if code != exitError { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitError, errOut) + } +} + +func TestSimulate_InitialOverride(t *testing.T) { + code, out, errOut := runCmd( + "simulate", "testdata/clean.json", + "-initial", "paying", + "-events", "paid", + "-guard", "isPaid=true", + ) + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + if !strings.Contains(out, "initial: paying") { + t.Errorf("output should start from paying:\n%s", out) + } + if !strings.Contains(out, "final: done") { + t.Errorf("final state should be done:\n%s", out) + } +} + +func TestSimulate_FlagsAfterPath(t *testing.T) { + code, out, errOut := runCmd( + "simulate", "testdata/clean.json", + "-events", "checkout", + "-guard", "hasItems=true", + ) + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + if !strings.Contains(out, "cart -> paying") { + t.Errorf("output missing transition:\n%s", out) + } +}