Skip to content

Commit a53b634

Browse files
committed
feat: implement crucible cli subcommands
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent c8bc912 commit a53b634

3 files changed

Lines changed: 412 additions & 0 deletions

File tree

cmd/crucible/cmd.go

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"io"
6+
"os"
7+
8+
"github.com/stablekernel/crucible/gen"
9+
"github.com/stablekernel/crucible/state/analysis"
10+
"github.com/stablekernel/crucible/state/evolution"
11+
)
12+
13+
// runLint loads an IR, assembles it with stub behaviors, runs every static
14+
// analysis check, and prints the findings. It exits non-zero when the analysis
15+
// reports any finding so the command can gate CI.
16+
func runLint(args []string, stdout, stderr io.Writer) int {
17+
fs := flag.NewFlagSet("lint", flag.ContinueOnError)
18+
fs.SetOutput(stderr)
19+
if code, ok := parseSingleArg(fs, args, "lint", "<ir.json>", stderr); !ok {
20+
return code
21+
}
22+
23+
ir, err := loadIR(fs.Arg(0), os.Stdin)
24+
if err != nil {
25+
emitf(stderr, "crucible lint: %v\n", err)
26+
return exitError
27+
}
28+
m, err := quench(ir)
29+
if err != nil {
30+
emitf(stderr, "crucible lint: %v\n", err)
31+
return exitError
32+
}
33+
34+
report := analysis.Analyze(m)
35+
emitln(stdout, report.String())
36+
if len(report.Findings) > 0 {
37+
return exitFindings
38+
}
39+
return exitOK
40+
}
41+
42+
// runRender loads an IR, assembles it with stub behaviors, and prints the
43+
// machine diagram. -format selects mermaid (the default) or dot. SVG output is
44+
// not produced here; pipe the dot text through Graphviz for an image.
45+
func runRender(args []string, stdout, stderr io.Writer) int {
46+
fs := flag.NewFlagSet("render", flag.ContinueOnError)
47+
fs.SetOutput(stderr)
48+
format := fs.String("format", "mermaid", "diagram format: mermaid or dot")
49+
if err := fs.Parse(reorderArgs(args)); err != nil {
50+
return exitUsage
51+
}
52+
if fs.NArg() != 1 {
53+
emitln(stderr, "usage: crucible render <ir.json> [-format mermaid|dot]")
54+
return exitUsage
55+
}
56+
if *format != "mermaid" && *format != "dot" {
57+
emitf(stderr, "crucible render: unknown -format %q (want mermaid or dot)\n", *format)
58+
return exitUsage
59+
}
60+
61+
ir, err := loadIR(fs.Arg(0), os.Stdin)
62+
if err != nil {
63+
emitf(stderr, "crucible render: %v\n", err)
64+
return exitError
65+
}
66+
m, err := quench(ir)
67+
if err != nil {
68+
emitf(stderr, "crucible render: %v\n", err)
69+
return exitError
70+
}
71+
72+
switch *format {
73+
case "dot":
74+
emit(stdout, m.ToDOT())
75+
default:
76+
emit(stdout, m.ToMermaid())
77+
}
78+
return exitOK
79+
}
80+
81+
// runDiff loads two serialized IRs, classifies the changes between them, and
82+
// prints the recommended semver bump along with the breaking and additive
83+
// changes split apart.
84+
func runDiff(args []string, stdout, stderr io.Writer) int {
85+
fs := flag.NewFlagSet("diff", flag.ContinueOnError)
86+
fs.SetOutput(stderr)
87+
if err := fs.Parse(args); err != nil {
88+
return exitUsage
89+
}
90+
if fs.NArg() != 2 {
91+
emitln(stderr, "usage: crucible diff <old.json> <new.json>")
92+
return exitUsage
93+
}
94+
95+
oldBytes, err := readInput(fs.Arg(0), os.Stdin)
96+
if err != nil {
97+
emitf(stderr, "crucible diff: read old: %v\n", err)
98+
return exitError
99+
}
100+
newBytes, err := readInput(fs.Arg(1), os.Stdin)
101+
if err != nil {
102+
emitf(stderr, "crucible diff: read new: %v\n", err)
103+
return exitError
104+
}
105+
106+
report, err := evolution.DiffJSON[string, string, any](oldBytes, newBytes)
107+
if err != nil {
108+
emitf(stderr, "crucible diff: %v\n", err)
109+
return exitError
110+
}
111+
112+
emitf(stdout, "bump: %s\n", report.SemverBump())
113+
var breaking, additive []evolution.Change
114+
for _, c := range report.Changes {
115+
if c.Breaking {
116+
breaking = append(breaking, c)
117+
} else {
118+
additive = append(additive, c)
119+
}
120+
}
121+
emitf(stdout, "\nbreaking (%d):\n", len(breaking))
122+
for _, c := range breaking {
123+
emitf(stdout, " %-24s %s: %s\n", c.Kind, c.Path, c.Description)
124+
}
125+
emitf(stdout, "\nadditive (%d):\n", len(additive))
126+
for _, c := range additive {
127+
emitf(stdout, " %-24s %s: %s\n", c.Kind, c.Path, c.Description)
128+
}
129+
return exitOK
130+
}
131+
132+
// runValidate confirms an IR loads and assembles cleanly with stub behaviors. It
133+
// is the well-formedness gate: a malformed JSON document or a structural defect
134+
// the lint rejects exits non-zero with a message on stderr; a clean machine
135+
// exits zero.
136+
func runValidate(args []string, stdout, stderr io.Writer) int {
137+
fs := flag.NewFlagSet("validate", flag.ContinueOnError)
138+
fs.SetOutput(stderr)
139+
if code, ok := parseSingleArg(fs, args, "validate", "<ir.json>", stderr); !ok {
140+
return code
141+
}
142+
143+
ir, err := loadIR(fs.Arg(0), os.Stdin)
144+
if err != nil {
145+
emitf(stderr, "crucible validate: %v\n", err)
146+
return exitError
147+
}
148+
if _, err := quench(ir); err != nil {
149+
emitf(stderr, "crucible validate: %v\n", err)
150+
return exitError
151+
}
152+
emitf(stdout, "ok: %s\n", ir.Name)
153+
return exitOK
154+
}
155+
156+
// runEject loads an IR and generates typed Go behavior stubs, writing them to
157+
// -o or to stdout. -package overrides the generated package name.
158+
func runEject(args []string, stdout, stderr io.Writer) int {
159+
fs := flag.NewFlagSet("eject", flag.ContinueOnError)
160+
fs.SetOutput(stderr)
161+
pkg := fs.String("package", "", "generated package name (default: gen's default)")
162+
out := fs.String("o", "", "output file (default: stdout)")
163+
if err := fs.Parse(reorderArgs(args)); err != nil {
164+
return exitUsage
165+
}
166+
if fs.NArg() != 1 {
167+
emitln(stderr, "usage: crucible eject <ir.json> [-package name] [-o outfile]")
168+
return exitUsage
169+
}
170+
171+
ir, err := loadIR(fs.Arg(0), os.Stdin)
172+
if err != nil {
173+
emitf(stderr, "crucible eject: %v\n", err)
174+
return exitError
175+
}
176+
177+
var opts []gen.Option
178+
if *pkg != "" {
179+
opts = append(opts, gen.WithPackageName(*pkg))
180+
}
181+
src, err := gen.Eject[string, string, any](*ir, opts...)
182+
if err != nil {
183+
emitf(stderr, "crucible eject: %v\n", err)
184+
return exitError
185+
}
186+
187+
if *out == "" {
188+
_, err = stdout.Write(src)
189+
} else {
190+
err = os.WriteFile(*out, src, 0o644)
191+
}
192+
if err != nil {
193+
emitf(stderr, "crucible eject: write output: %v\n", err)
194+
return exitError
195+
}
196+
return exitOK
197+
}
198+
199+
// parseSingleArg parses a flag set expecting exactly one positional argument (an
200+
// IR path). It returns the exit code and false when parsing fails or the
201+
// argument count is wrong; otherwise it returns (0, true).
202+
func parseSingleArg(fs *flag.FlagSet, args []string, name, argHint string, stderr io.Writer) (int, bool) {
203+
if err := fs.Parse(reorderArgs(args)); err != nil {
204+
return exitUsage, false
205+
}
206+
if fs.NArg() != 1 {
207+
emitf(stderr, "usage: crucible %s %s\n", name, argHint)
208+
return exitUsage, false
209+
}
210+
return exitOK, true
211+
}
212+
213+
// reorderArgs moves flag tokens ahead of positional arguments so a flag may
214+
// appear after the IR path (e.g. "render ir.json -format dot"). Go's flag
215+
// package stops at the first non-flag token, so without this a trailing flag is
216+
// read as a stray positional. Every value-taking flag in this CLI (-format,
217+
// -package, -o) is moved together with its following value token; a -k=v token
218+
// carries its own value. A bare "--" terminates flag processing, and everything
219+
// after it is treated as positional.
220+
func reorderArgs(args []string) []string {
221+
valueFlags := map[string]bool{"-format": true, "-package": true, "-o": true}
222+
var flags, positional []string
223+
for i := 0; i < len(args); i++ {
224+
a := args[i]
225+
if a == "--" {
226+
positional = append(positional, args[i+1:]...)
227+
break
228+
}
229+
if len(a) > 1 && a[0] == '-' {
230+
flags = append(flags, a)
231+
// Pull the value token along for a space-separated value flag.
232+
if valueFlags[a] && i+1 < len(args) {
233+
flags = append(flags, args[i+1])
234+
i++
235+
}
236+
continue
237+
}
238+
positional = append(positional, a)
239+
}
240+
return append(flags, positional...)
241+
}

cmd/crucible/load.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"os"
7+
8+
"github.com/stablekernel/crucible/state"
9+
)
10+
11+
// readInput returns the bytes of the named IR file, or of stdin when path is
12+
// "-". It is the single entry point every subcommand uses to read an IR
13+
// argument, so "-" means stdin uniformly.
14+
func readInput(path string, stdin io.Reader) ([]byte, error) {
15+
if path == "-" {
16+
return io.ReadAll(stdin)
17+
}
18+
return os.ReadFile(path)
19+
}
20+
21+
// loadIR reads and decodes an IR from the named path (or stdin for "-"). The
22+
// machine's state, event, and context types are fixed to string, string, any —
23+
// the headless tool never instantiates the context, so any is sufficient to load
24+
// and inspect the structure.
25+
func loadIR(path string, stdin io.Reader) (*state.IR[string, string, any], error) {
26+
b, err := readInput(path, stdin)
27+
if err != nil {
28+
return nil, err
29+
}
30+
ir, err := state.LoadFromJSON[string, string, any](b)
31+
if err != nil {
32+
return nil, fmt.Errorf("load IR: %w", err)
33+
}
34+
return ir, nil
35+
}
36+
37+
// quench binds an IR against a stub registry and assembles a *Machine. Quench
38+
// panics on a structural defect the lint rejects (an undeclared transition
39+
// target, for example), so the panic is recovered and returned as an error
40+
// rather than crashing the tool.
41+
func quench(ir *state.IR[string, string, any]) (m *state.Machine[string, string, any], err error) {
42+
defer func() {
43+
if r := recover(); r != nil {
44+
m = nil
45+
err = fmt.Errorf("quench: %v", r)
46+
}
47+
}()
48+
reg := stubRegistry(ir)
49+
return ir.Provide(reg).Quench(), nil
50+
}

0 commit comments

Comments
 (0)