Skip to content

Commit 58e529c

Browse files
committed
test: add crucible cli tests and docs
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent a53b634 commit 58e529c

10 files changed

Lines changed: 369 additions & 0 deletions

File tree

cmd/crucible/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Changelog
2+
3+
All notable changes to the crucible CLI are documented here. This module is
4+
versioned independently of the `state` module.
5+
6+
## 0.1.0
7+
8+
Initial release.
9+
10+
- `lint` runs static analysis over an IR and exits non-zero on findings.
11+
- `render` emits a Mermaid or DOT diagram of a machine.
12+
- `diff` classifies the changes between two IRs and recommends a semver bump.
13+
- `validate` confirms an IR loads and assembles.
14+
- `eject` generates typed Go behavior stubs from an IR.
15+
- `version` (and `-version`) prints the CLI version.
16+
- Commands read an IR file path or `-` for stdin.

cmd/crucible/README.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# crucible
2+
3+
A headless command-line tool for the crucible state-machine IR. It lints,
4+
renders, diffs, validates, and ejects a machine's serialized IR JSON without
5+
running any behavior. Every command reads an IR JSON file path, or `-` for
6+
stdin.
7+
8+
## Behavior-free operation
9+
10+
A serialized IR carries behavior references by name only; the kernel binds those
11+
names to real implementations when it assembles a machine. Commands that need an
12+
assembled machine (`lint`, `render`, `validate`) do not have the host's real
13+
guards, actions, reducers, and services. They register a deterministic no-op stub
14+
for every referenced name first, so the machine assembles from its structure
15+
alone. The stubs never run during these commands (no instance is cast and no
16+
event is fired), so the structural view is exactly what the IR describes.
17+
18+
## Commands
19+
20+
### lint
21+
22+
```
23+
crucible lint <ir.json>
24+
```
25+
26+
Runs every static analysis check and prints the findings. Exits non-zero when
27+
the analysis reports any finding, so it can gate CI.
28+
29+
### render
30+
31+
```
32+
crucible render <ir.json> [-format mermaid|dot]
33+
```
34+
35+
Renders the machine as a Mermaid `stateDiagram-v2` (the default) or as Graphviz
36+
DOT. Output is text. For an SVG, pipe the DOT through Graphviz (`crucible render
37+
m.json -format dot | dot -Tsvg`); native SVG rendering is a future addition.
38+
39+
### diff
40+
41+
```
42+
crucible diff <old.json> <new.json>
43+
```
44+
45+
Classifies the changes between two serialized IRs, prints the recommended semver
46+
bump (`major`, `minor`, or `patch`), and lists the breaking and additive changes
47+
separately.
48+
49+
### validate
50+
51+
```
52+
crucible validate <ir.json>
53+
```
54+
55+
Confirms the IR loads and assembles cleanly. A malformed JSON document or a
56+
structural defect the lint rejects exits non-zero with a message on stderr; a
57+
clean machine exits zero.
58+
59+
### eject
60+
61+
```
62+
crucible eject <ir.json> [-package name] [-o outfile]
63+
```
64+
65+
Generates typed Go behavior stubs for every referenced guard, action, reducer,
66+
and service, plus a `Provide` function that registers them. Writes to `outfile`
67+
or, by default, to stdout.
68+
69+
### version
70+
71+
```
72+
crucible version
73+
crucible -version
74+
```
75+
76+
Prints the CLI version.
77+
78+
## Exit codes
79+
80+
- `0` success
81+
- `1` runtime or load error, and lint findings
82+
- `2` usage error
83+
84+
## Versioning
85+
86+
The crucible CLI is versioned independently of the `state` module. It is not part
87+
of the `state` v1 freeze, so it can move at its own pace.

cmd/crucible/cmd_test.go

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"go/parser"
6+
"go/token"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
)
12+
13+
// runCmd invokes the CLI dispatcher in-process and returns the exit code with
14+
// captured stdout and stderr, so every command is tested without a subprocess.
15+
func runCmd(args ...string) (code int, stdout, stderr string) {
16+
var out, errBuf bytes.Buffer
17+
code = run(args, &out, &errBuf)
18+
return code, out.String(), errBuf.String()
19+
}
20+
21+
func TestRender(t *testing.T) {
22+
cases := []struct {
23+
name string
24+
args []string
25+
want string
26+
}{
27+
{"mermaid default", []string{"render", "testdata/clean.json"}, "stateDiagram-v2"},
28+
{"dot flag", []string{"render", "testdata/clean.json", "-format", "dot"}, "digraph"},
29+
{"flag before path", []string{"render", "-format", "dot", "testdata/clean.json"}, "digraph"},
30+
}
31+
for _, tc := range cases {
32+
t.Run(tc.name, func(t *testing.T) {
33+
code, out, errOut := runCmd(tc.args...)
34+
if code != exitOK {
35+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut)
36+
}
37+
if !strings.Contains(out, tc.want) {
38+
t.Fatalf("render output missing %q:\n%s", tc.want, out)
39+
}
40+
})
41+
}
42+
}
43+
44+
func TestRender_UnknownFormat(t *testing.T) {
45+
code, _, errOut := runCmd("render", "testdata/clean.json", "-format", "svg")
46+
if code != exitUsage {
47+
t.Fatalf("exit = %d, want %d", code, exitUsage)
48+
}
49+
if !strings.Contains(errOut, "unknown -format") {
50+
t.Fatalf("stderr missing format error: %s", errOut)
51+
}
52+
}
53+
54+
func TestDiff(t *testing.T) {
55+
cases := []struct {
56+
name string
57+
old, new string
58+
wantBump string
59+
}{
60+
{"additive change is minor", "testdata/old.json", "testdata/new_minor.json", "bump: minor"},
61+
{"breaking change is major", "testdata/old.json", "testdata/new_major.json", "bump: major"},
62+
}
63+
for _, tc := range cases {
64+
t.Run(tc.name, func(t *testing.T) {
65+
code, out, errOut := runCmd("diff", tc.old, tc.new)
66+
if code != exitOK {
67+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut)
68+
}
69+
if !strings.Contains(out, tc.wantBump) {
70+
t.Fatalf("diff output missing %q:\n%s", tc.wantBump, out)
71+
}
72+
})
73+
}
74+
}
75+
76+
func TestDiff_SplitsBreakingAndAdditive(t *testing.T) {
77+
code, out, _ := runCmd("diff", "testdata/old.json", "testdata/new_major.json")
78+
if code != exitOK {
79+
t.Fatalf("exit = %d, want %d", code, exitOK)
80+
}
81+
if !strings.Contains(out, "breaking (2)") {
82+
t.Errorf("want 2 breaking changes:\n%s", out)
83+
}
84+
if !strings.Contains(out, "additive (1)") {
85+
t.Errorf("want 1 additive change:\n%s", out)
86+
}
87+
}
88+
89+
func TestValidate(t *testing.T) {
90+
cases := []struct {
91+
name string
92+
path string
93+
want int
94+
}{
95+
{"clean IR validates", "testdata/clean.json", exitOK},
96+
{"malformed JSON fails", "testdata/malformed.json", exitError},
97+
}
98+
for _, tc := range cases {
99+
t.Run(tc.name, func(t *testing.T) {
100+
code, _, errOut := runCmd("validate", tc.path)
101+
if code != tc.want {
102+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, tc.want, errOut)
103+
}
104+
if tc.want == exitError && strings.TrimSpace(errOut) == "" {
105+
t.Fatalf("expected a clear stderr message on failure")
106+
}
107+
})
108+
}
109+
}
110+
111+
func TestLint(t *testing.T) {
112+
cases := []struct {
113+
name string
114+
path string
115+
want int
116+
}{
117+
{"clean IR has no findings", "testdata/clean.json", exitOK},
118+
{"defective IR reports findings", "testdata/defect.json", exitFindings},
119+
}
120+
for _, tc := range cases {
121+
t.Run(tc.name, func(t *testing.T) {
122+
code, out, errOut := runCmd("lint", tc.path)
123+
if code != tc.want {
124+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, tc.want, errOut)
125+
}
126+
if tc.want == exitFindings && !strings.Contains(out, "unreachable_state") {
127+
t.Fatalf("expected an unreachable_state finding:\n%s", out)
128+
}
129+
})
130+
}
131+
}
132+
133+
func TestEject_StdoutContainsKeyIdents(t *testing.T) {
134+
code, out, errOut := runCmd("eject", "testdata/clean.json", "-package", "machine")
135+
if code != exitOK {
136+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut)
137+
}
138+
for _, want := range []string{
139+
"package machine",
140+
"func Provide(reg *state.Registry[",
141+
`reg.Guard("hasItems"`,
142+
`reg.Action("notify"`,
143+
`reg.Service("charge"`,
144+
} {
145+
if !strings.Contains(out, want) {
146+
t.Errorf("ejected source missing %q:\n%s", want, out)
147+
}
148+
}
149+
// The generated source must be parseable Go.
150+
if _, err := parser.ParseFile(token.NewFileSet(), "gen.go", out, parser.AllErrors); err != nil {
151+
t.Fatalf("ejected source does not parse: %v\n%s", err, out)
152+
}
153+
}
154+
155+
func TestEject_WritesOutfile(t *testing.T) {
156+
dir := t.TempDir()
157+
out := filepath.Join(dir, "gen.go")
158+
code, _, errOut := runCmd("eject", "testdata/clean.json", "-package", "machine", "-o", out)
159+
if code != exitOK {
160+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut)
161+
}
162+
b, err := os.ReadFile(out)
163+
if err != nil {
164+
t.Fatalf("read output: %v", err)
165+
}
166+
if !strings.Contains(string(b), "package machine") {
167+
t.Fatalf("outfile missing package decl:\n%s", b)
168+
}
169+
}
170+
171+
func TestVersion(t *testing.T) {
172+
for _, args := range [][]string{{"version"}, {"-version"}, {"--version"}} {
173+
code, out, _ := runCmd(args...)
174+
if code != exitOK {
175+
t.Fatalf("%v exit = %d, want %d", args, code, exitOK)
176+
}
177+
if strings.TrimSpace(out) != version {
178+
t.Fatalf("%v printed %q, want %q", args, strings.TrimSpace(out), version)
179+
}
180+
}
181+
}
182+
183+
func TestUsageErrors(t *testing.T) {
184+
cases := []struct {
185+
name string
186+
args []string
187+
}{
188+
{"no args", nil},
189+
{"unknown subcommand", []string{"frobnicate"}},
190+
{"lint missing path", []string{"lint"}},
191+
{"diff one path", []string{"diff", "testdata/old.json"}},
192+
}
193+
for _, tc := range cases {
194+
t.Run(tc.name, func(t *testing.T) {
195+
code, _, _ := runCmd(tc.args...)
196+
if code != exitUsage {
197+
t.Fatalf("exit = %d, want %d", code, exitUsage)
198+
}
199+
})
200+
}
201+
}
202+
203+
func TestStdinInput(t *testing.T) {
204+
// render reading from "-" exercises the stdin path; swap os.Stdin for the
205+
// duration so the dispatcher's hard-wired os.Stdin reads the fixture.
206+
b, err := os.ReadFile("testdata/clean.json")
207+
if err != nil {
208+
t.Fatalf("read fixture: %v", err)
209+
}
210+
r, w, err := os.Pipe()
211+
if err != nil {
212+
t.Fatalf("pipe: %v", err)
213+
}
214+
orig := os.Stdin
215+
os.Stdin = r
216+
t.Cleanup(func() { os.Stdin = orig })
217+
go func() {
218+
_, _ = w.Write(b)
219+
_ = w.Close()
220+
}()
221+
222+
code, out, errOut := runCmd("render", "-")
223+
if code != exitOK {
224+
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut)
225+
}
226+
if !strings.Contains(out, "stateDiagram-v2") {
227+
t.Fatalf("stdin render missing diagram:\n%s", out)
228+
}
229+
}

cmd/crucible/stub_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/stablekernel/crucible/state"
8+
)
9+
10+
// TestStubRegistry_EnumeratesEveryBehavior loads the clean fixture (which
11+
// references two guards, two actions, one reducer, and one service) and confirms
12+
// Provide(stubRegistry).Quench succeeds, proving every referenced name was
13+
// stubbed. An unstubbed name would panic Quench with an *UnboundRefError.
14+
func TestStubRegistry_EnumeratesEveryBehavior(t *testing.T) {
15+
b, err := os.ReadFile("testdata/clean.json")
16+
if err != nil {
17+
t.Fatalf("read fixture: %v", err)
18+
}
19+
ir, err := state.LoadFromJSON[string, string, any](b)
20+
if err != nil {
21+
t.Fatalf("load IR: %v", err)
22+
}
23+
24+
m, err := quench(ir)
25+
if err != nil {
26+
t.Fatalf("quench with stubs: %v", err)
27+
}
28+
if m == nil {
29+
t.Fatal("quench returned a nil machine")
30+
}
31+
}

cmd/crucible/testdata/clean.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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}

cmd/crucible/testdata/defect.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{ this is not valid json
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"schemaVersion":"1.0","name":"flow","states":[{"name":"a","transitions":[{"from":"a","to":"c","on":"go"}]},{"name":"c","isFinal":true}],"initial":"a","hasInitial":true}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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}

cmd/crucible/testdata/old.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"schemaVersion":"1.0","name":"flow","states":[{"name":"a","transitions":[{"from":"a","to":"b","on":"go"}]},{"name":"b","isFinal":true}],"initial":"a","hasInitial":true}

0 commit comments

Comments
 (0)