Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cmd/crucible/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
18 changes: 18 additions & 0 deletions cmd/crucible/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ir.json> -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

```
Expand Down
5 changes: 4 additions & 1 deletion cmd/crucible/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
36 changes: 35 additions & 1 deletion cmd/crucible/load.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -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
}
3 changes: 3 additions & 0 deletions cmd/crucible/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -87,6 +89,7 @@ Commands:
diff <old.json> <new.json> [-format f] [-exit-code] classify changes and recommend a semver bump
validate <ir.json> confirm the IR loads and assembles
eject <ir.json> [-package p] [-o f] generate typed Go behavior stubs
simulate <ir.json> -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.
Expand Down
Loading