Skip to content

Commit 8e11e3f

Browse files
feat(cmd/crucible): add simulate subcommand to trace events through an IR (#184)
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.
1 parent fa42b23 commit 8e11e3f

7 files changed

Lines changed: 547 additions & 2 deletions

File tree

cmd/crucible/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ versioned independently of the `state` module.
1414
- `diff -format` selects `text` (default) or `json` output.
1515
- `diff -exit-code` exits non-zero when the recommended bump is `major`
1616
(at least one breaking change), so a diff can gate CI.
17+
- `simulate` fires a sequence of events against a machine from a given state
18+
and prints the step trace. `-events` takes a comma-separated list; `-events-file`
19+
accepts a JSON events file. `-guard name=bool` seeds a guard verdict (unseeded
20+
guards default to false). `-initial` overrides the IR's declared start state.
21+
`-format` selects `text` (default) or `json` output.
1722

1823
## [0.1.0] - 2026-06-13
1924

cmd/crucible/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ Generates typed Go behavior stubs for every referenced guard, action, reducer,
7272
and service, plus a `Provide` function that registers them. Writes to `outfile`
7373
or, by default, to stdout.
7474

75+
### simulate
76+
77+
```
78+
crucible simulate <ir.json> -events e1,e2 [-events-file f] [-initial S] [-guard name=bool] [-format text|json]
79+
```
80+
81+
Fires a sequence of events against a machine assembled from the IR and prints the
82+
resulting step trace (each event's from/to state, outcome, and emitted effects),
83+
then the final state. The events come from `-events` (a comma-separated list) or
84+
`-events-file` (a JSON file that is either a bare array of event names or a
85+
conformance scenario object); exactly one is required. Since the IR carries no
86+
real behavior, guards return seeded verdicts: `-guard name=bool` (repeatable)
87+
sets a guard's result, and any unseeded guard defaults to `false`; actions,
88+
reducers, and services are no-ops. `-initial` overrides the IR's declared start
89+
state. `-format` selects human-readable `text` (the default) or `json`. A
90+
guard-blocked or invalid transition is a normal observable outcome and exits
91+
zero; an unknown event or an action failure exits non-zero.
92+
7593
### version
7694

7795
```

cmd/crucible/cmd.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,10 @@ func parseSingleArg(fs *flag.FlagSet, args []string, name, argHint string, stder
260260
// carries its own value. A bare "--" terminates flag processing, and everything
261261
// after it is treated as positional.
262262
func reorderArgs(args []string) []string {
263-
valueFlags := map[string]bool{"-format": true, "-package": true, "-o": true}
263+
valueFlags := map[string]bool{
264+
"-format": true, "-package": true, "-o": true,
265+
"-events": true, "-events-file": true, "-initial": true, "-guard": true,
266+
}
264267
var flags, positional []string
265268
for i := 0; i < len(args); i++ {
266269
a := args[i]

cmd/crucible/load.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"context"
45
"fmt"
56
"io"
67
"os"
@@ -39,12 +40,45 @@ func loadIR(path string, stdin io.Reader) (*state.IR[string, string, any], error
3940
// target, for example), so the panic is recovered and returned as an error
4041
// rather than crashing the tool.
4142
func quench(ir *state.IR[string, string, any]) (m *state.Machine[string, string, any], err error) {
43+
return quenchWith(ir, stubRegistry(ir))
44+
}
45+
46+
// quenchWith binds an IR against a caller-supplied registry and assembles a
47+
// *Machine. Like quench, it recovers the panic Quench raises on a structural
48+
// defect and returns it as an error. simulate uses this to bind a registry whose
49+
// guards return seeded verdicts rather than the always-false stubs.
50+
func quenchWith(ir *state.IR[string, string, any], reg *state.Registry[any]) (m *state.Machine[string, string, any], err error) {
4251
defer func() {
4352
if r := recover(); r != nil {
4453
m = nil
4554
err = fmt.Errorf("quench: %v", r)
4655
}
4756
}()
48-
reg := stubRegistry(ir)
4957
return ir.Provide(reg).Quench(), nil
5058
}
59+
60+
// simulateRegistry walks an IR to enumerate every referenced behavior name, then
61+
// registers behaviors suitable for a structural simulation. Guards return the
62+
// seeded verdict from verdicts (defaulting to false for an unseeded guard), so a
63+
// caller can drive a machine down a chosen path without real implementations.
64+
// Actions, reducers, and services remain total no-ops, matching stubRegistry.
65+
func simulateRegistry(ir *state.IR[string, string, any], verdicts map[string]bool) *state.Registry[any] {
66+
var b behaviorNames
67+
for i := range ir.States {
68+
b.walkState(&ir.States[i])
69+
}
70+
reg := state.NewRegistry[any]()
71+
for name := range b.guards {
72+
reg.Guard(name, func(state.GuardCtx[any]) bool { return verdicts[name] })
73+
}
74+
for name := range b.actions {
75+
reg.Action(name, func(state.ActionCtx[any]) (state.Effect, error) { return nil, nil })
76+
}
77+
for name := range b.reducers {
78+
reg.Reducer(name, func(in state.AssignCtx[any]) any { return in.Entity })
79+
}
80+
for name := range b.services {
81+
reg.Service(name, func(context.Context, state.ServiceCtx[any]) (any, error) { return nil, nil })
82+
}
83+
return reg
84+
}

cmd/crucible/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ func run(args []string, stdout, stderr io.Writer) int {
5050
return runValidate(rest, stdout, stderr)
5151
case "eject":
5252
return runEject(rest, stdout, stderr)
53+
case "simulate":
54+
return runSimulate(rest, stdout, stderr)
5355
case "version":
5456
emitln(stdout, version)
5557
return exitOK
@@ -87,6 +89,7 @@ Commands:
8789
diff <old.json> <new.json> [-format f] [-exit-code] classify changes and recommend a semver bump
8890
validate <ir.json> confirm the IR loads and assembles
8991
eject <ir.json> [-package p] [-o f] generate typed Go behavior stubs
92+
simulate <ir.json> -events e1,e2 [flags] fire events and print the step trace
9093
version print the crucible CLI version
9194
9295
Each command reads an IR JSON file path, or - for stdin.

0 commit comments

Comments
 (0)