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
16 changes: 16 additions & 0 deletions cmd/crucible/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Changelog

All notable changes to the crucible CLI are documented here. This module is
versioned independently of the `state` module.

## 0.1.0

Initial release.

- `lint` runs static analysis over an IR and exits non-zero on findings.
- `render` emits a Mermaid or DOT diagram of a machine.
- `diff` classifies the changes between two IRs and recommends a semver bump.
- `validate` confirms an IR loads and assembles.
- `eject` generates typed Go behavior stubs from an IR.
- `version` (and `-version`) prints the CLI version.
- Commands read an IR file path or `-` for stdin.
87 changes: 87 additions & 0 deletions cmd/crucible/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# crucible

A headless command-line tool for the crucible state-machine IR. It lints,
renders, diffs, validates, and ejects a machine's serialized IR JSON without
running any behavior. Every command reads an IR JSON file path, or `-` for
stdin.

## Behavior-free operation

A serialized IR carries behavior references by name only; the kernel binds those
names to real implementations when it assembles a machine. Commands that need an
assembled machine (`lint`, `render`, `validate`) do not have the host's real
guards, actions, reducers, and services. They register a deterministic no-op stub
for every referenced name first, so the machine assembles from its structure
alone. The stubs never run during these commands (no instance is cast and no
event is fired), so the structural view is exactly what the IR describes.

## Commands

### lint

```
crucible lint <ir.json>
```

Runs every static analysis check and prints the findings. Exits non-zero when
the analysis reports any finding, so it can gate CI.

### render

```
crucible render <ir.json> [-format mermaid|dot]
```

Renders the machine as a Mermaid `stateDiagram-v2` (the default) or as Graphviz
DOT. Output is text. For an SVG, pipe the DOT through Graphviz (`crucible render
m.json -format dot | dot -Tsvg`); native SVG rendering is a future addition.

### diff

```
crucible diff <old.json> <new.json>
```

Classifies the changes between two serialized IRs, prints the recommended semver
bump (`major`, `minor`, or `patch`), and lists the breaking and additive changes
separately.

### validate

```
crucible validate <ir.json>
```

Confirms the IR loads and assembles cleanly. A malformed JSON document or a
structural defect the lint rejects exits non-zero with a message on stderr; a
clean machine exits zero.

### eject

```
crucible eject <ir.json> [-package name] [-o outfile]
```

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.

### version

```
crucible version
crucible -version
```

Prints the CLI version.

## Exit codes

- `0` success
- `1` runtime or load error, and lint findings
- `2` usage error

## Versioning

The crucible CLI is versioned independently of the `state` module. It is not part
of the `state` v1 freeze, so it can move at its own pace.
241 changes: 241 additions & 0 deletions cmd/crucible/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
package main

import (
"flag"
"io"
"os"

"github.com/stablekernel/crucible/gen"
"github.com/stablekernel/crucible/state/analysis"
"github.com/stablekernel/crucible/state/evolution"
)

// runLint loads an IR, assembles it with stub behaviors, runs every static
// analysis check, and prints the findings. It exits non-zero when the analysis
// reports any finding so the command can gate CI.
func runLint(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("lint", flag.ContinueOnError)
fs.SetOutput(stderr)
if code, ok := parseSingleArg(fs, args, "lint", "<ir.json>", stderr); !ok {
return code
}

ir, err := loadIR(fs.Arg(0), os.Stdin)
if err != nil {
emitf(stderr, "crucible lint: %v\n", err)
return exitError
}
m, err := quench(ir)
if err != nil {
emitf(stderr, "crucible lint: %v\n", err)
return exitError
}

report := analysis.Analyze(m)
emitln(stdout, report.String())
if len(report.Findings) > 0 {
return exitFindings
}
return exitOK
}

// runRender loads an IR, assembles it with stub behaviors, and prints the
// machine diagram. -format selects mermaid (the default) or dot. SVG output is
// not produced here; pipe the dot text through Graphviz for an image.
func runRender(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("render", flag.ContinueOnError)
fs.SetOutput(stderr)
format := fs.String("format", "mermaid", "diagram format: mermaid or dot")
if err := fs.Parse(reorderArgs(args)); err != nil {
return exitUsage
}
if fs.NArg() != 1 {
emitln(stderr, "usage: crucible render <ir.json> [-format mermaid|dot]")
return exitUsage
}
if *format != "mermaid" && *format != "dot" {
emitf(stderr, "crucible render: unknown -format %q (want mermaid or dot)\n", *format)
return exitUsage
}

ir, err := loadIR(fs.Arg(0), os.Stdin)
if err != nil {
emitf(stderr, "crucible render: %v\n", err)
return exitError
}
m, err := quench(ir)
if err != nil {
emitf(stderr, "crucible render: %v\n", err)
return exitError
}

switch *format {
case "dot":
emit(stdout, m.ToDOT())
default:
emit(stdout, m.ToMermaid())
}
return exitOK
}

// runDiff loads two serialized IRs, classifies the changes between them, and
// prints the recommended semver bump along with the breaking and additive
// changes split apart.
func runDiff(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("diff", flag.ContinueOnError)
fs.SetOutput(stderr)
if err := fs.Parse(args); err != nil {
return exitUsage
}
if fs.NArg() != 2 {
emitln(stderr, "usage: crucible diff <old.json> <new.json>")
return exitUsage
}

oldBytes, err := readInput(fs.Arg(0), os.Stdin)
if err != nil {
emitf(stderr, "crucible diff: read old: %v\n", err)
return exitError
}
newBytes, err := readInput(fs.Arg(1), os.Stdin)
if err != nil {
emitf(stderr, "crucible diff: read new: %v\n", err)
return exitError
}

report, err := evolution.DiffJSON[string, string, any](oldBytes, newBytes)
if err != nil {
emitf(stderr, "crucible diff: %v\n", err)
return exitError
}

emitf(stdout, "bump: %s\n", report.SemverBump())
var breaking, additive []evolution.Change
for _, c := range report.Changes {
if c.Breaking {
breaking = append(breaking, c)
} else {
additive = append(additive, c)
}
}
emitf(stdout, "\nbreaking (%d):\n", len(breaking))
for _, c := range breaking {
emitf(stdout, " %-24s %s: %s\n", c.Kind, c.Path, c.Description)
}
emitf(stdout, "\nadditive (%d):\n", len(additive))
for _, c := range additive {
emitf(stdout, " %-24s %s: %s\n", c.Kind, c.Path, c.Description)
}
return exitOK
}

// runValidate confirms an IR loads and assembles cleanly with stub behaviors. It
// is the well-formedness gate: a malformed JSON document or a structural defect
// the lint rejects exits non-zero with a message on stderr; a clean machine
// exits zero.
func runValidate(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("validate", flag.ContinueOnError)
fs.SetOutput(stderr)
if code, ok := parseSingleArg(fs, args, "validate", "<ir.json>", stderr); !ok {
return code
}

ir, err := loadIR(fs.Arg(0), os.Stdin)
if err != nil {
emitf(stderr, "crucible validate: %v\n", err)
return exitError
}
if _, err := quench(ir); err != nil {
emitf(stderr, "crucible validate: %v\n", err)
return exitError
}
emitf(stdout, "ok: %s\n", ir.Name)
return exitOK
}

// runEject loads an IR and generates typed Go behavior stubs, writing them to
// -o or to stdout. -package overrides the generated package name.
func runEject(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("eject", flag.ContinueOnError)
fs.SetOutput(stderr)
pkg := fs.String("package", "", "generated package name (default: gen's default)")
out := fs.String("o", "", "output file (default: stdout)")
if err := fs.Parse(reorderArgs(args)); err != nil {
return exitUsage
}
if fs.NArg() != 1 {
emitln(stderr, "usage: crucible eject <ir.json> [-package name] [-o outfile]")
return exitUsage
}

ir, err := loadIR(fs.Arg(0), os.Stdin)
if err != nil {
emitf(stderr, "crucible eject: %v\n", err)
return exitError
}

var opts []gen.Option
if *pkg != "" {
opts = append(opts, gen.WithPackageName(*pkg))
}
src, err := gen.Eject[string, string, any](*ir, opts...)
if err != nil {
emitf(stderr, "crucible eject: %v\n", err)
return exitError
}

if *out == "" {
_, err = stdout.Write(src)
} else {
err = os.WriteFile(*out, src, 0o644)
}
if err != nil {
emitf(stderr, "crucible eject: write output: %v\n", err)
return exitError
}
return exitOK
}

// parseSingleArg parses a flag set expecting exactly one positional argument (an
// IR path). It returns the exit code and false when parsing fails or the
// argument count is wrong; otherwise it returns (0, true).
func parseSingleArg(fs *flag.FlagSet, args []string, name, argHint string, stderr io.Writer) (int, bool) {
if err := fs.Parse(reorderArgs(args)); err != nil {
return exitUsage, false
}
if fs.NArg() != 1 {
emitf(stderr, "usage: crucible %s %s\n", name, argHint)
return exitUsage, false
}
return exitOK, true
}

// reorderArgs moves flag tokens ahead of positional arguments so a flag may
// appear after the IR path (e.g. "render ir.json -format dot"). Go's flag
// package stops at the first non-flag token, so without this a trailing flag is
// read as a stray positional. Every value-taking flag in this CLI (-format,
// -package, -o) is moved together with its following value token; a -k=v token
// 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}
var flags, positional []string
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--" {
positional = append(positional, args[i+1:]...)
break
}
if len(a) > 1 && a[0] == '-' {
flags = append(flags, a)
// Pull the value token along for a space-separated value flag.
if valueFlags[a] && i+1 < len(args) {
flags = append(flags, args[i+1])
i++
}
continue
}
positional = append(positional, a)
}
return append(flags, positional...)
}
Loading
Loading