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
8 changes: 8 additions & 0 deletions cmd/crucible/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ versioned independently of the `state` module.

## [Unreleased]

### Added

- `lint -format` selects the output format: `text` (default), `json`, or
`sarif` (SARIF 2.1.0) for machine-readable CI ingestion.
- `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.

## [0.1.0] - 2026-06-13

Initial release.
Expand Down
16 changes: 11 additions & 5 deletions cmd/crucible/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@ event is fired), so the structural view is exactly what the IR describes.
### lint

```
crucible lint <ir.json>
crucible lint <ir.json> [-format text|json|sarif]
```

Runs every static analysis check and prints the findings. Exits non-zero when
the analysis reports any finding, so it can gate CI.
the analysis reports any finding, so it can gate CI. `-format` selects the
output: human-readable `text` (the default), `json`, or `sarif` (SARIF 2.1.0)
for ingestion by code-scanning tools. SARIF findings carry the IR path as a
physical location unless the IR was read from stdin (`-`).

### render

Expand All @@ -39,12 +42,15 @@ m.json -format dot | dot -Tsvg`); native SVG rendering is a future addition.
### diff

```
crucible diff <old.json> <new.json>
crucible diff <old.json> <new.json> [-format text|json] [-exit-code]
```

Classifies the changes between two serialized IRs, prints the recommended semver
bump (`major`, `minor`, or `patch`), and lists the breaking and additive changes
separately.
separately. `-format` selects human-readable `text` (the default) or `json`
(SARIF is not applicable to diffs). With `-exit-code`, the command exits non-zero
when the recommended bump is `major` (at least one breaking change), so a diff
can gate CI.

### validate

Expand Down Expand Up @@ -78,7 +84,7 @@ Prints the CLI version.
## Exit codes

- `0` success
- `1` runtime or load error, and lint findings
- `1` runtime or load error, lint findings, and `diff -exit-code` on a breaking change
- `2` usage error

## Versioning
Expand Down
82 changes: 62 additions & 20 deletions cmd/crucible/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,23 @@ import (
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
format := fs.String("format", "text", "output format: text, json, or sarif")
if err := fs.Parse(reorderArgs(args)); err != nil {
return exitUsage
}
if fs.NArg() != 1 {
emitln(stderr, "usage: crucible lint <ir.json> [-format text|json|sarif]")
return exitUsage
}
switch *format {
case "text", "json", "sarif":
default:
emitf(stderr, "crucible lint: unknown -format %q (want text, json, or sarif)\n", *format)
return exitUsage
}

ir, err := loadIR(fs.Arg(0), os.Stdin)
irPath := fs.Arg(0)
ir, err := loadIR(irPath, os.Stdin)
if err != nil {
emitf(stderr, "crucible lint: %v\n", err)
return exitError
Expand All @@ -32,7 +44,15 @@ func runLint(args []string, stdout, stderr io.Writer) int {
}

report := analysis.Analyze(m)
emitln(stdout, report.String())
switch *format {
case "text":
emitln(stdout, report.String())
default:
if err := formatLint(report, *format, irPath, version, stdout); err != nil {
emitf(stderr, "crucible lint: %v\n", err)
return exitError
}
}
if len(report.Findings) > 0 {
return exitFindings
}
Expand Down Expand Up @@ -84,11 +104,22 @@ func runRender(args []string, stdout, stderr io.Writer) int {
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 {
format := fs.String("format", "text", "output format: text or json")
exitCode := fs.Bool("exit-code", false, "exit nonzero on breaking (major) changes")
if err := fs.Parse(reorderArgs(args)); err != nil {
return exitUsage
}
if fs.NArg() != 2 {
emitln(stderr, "usage: crucible diff <old.json> <new.json>")
emitln(stderr, "usage: crucible diff <old.json> <new.json> [-format text|json] [-exit-code]")
return exitUsage
}
switch *format {
case "text", "json":
case "sarif":
emitln(stderr, "crucible diff: -format sarif is not supported for diff")
return exitUsage
default:
emitf(stderr, "crucible diff: unknown -format %q (want text or json)\n", *format)
return exitUsage
}

Expand All @@ -109,22 +140,33 @@ func runDiff(args []string, stdout, stderr io.Writer) int {
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)
if *format == "json" {
if err := formatDiff(report, *format, stdout); err != nil {
emitf(stderr, "crucible diff: %v\n", err)
return exitError
}
} else {
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)
}
}
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)

if *exitCode && report.SemverBump() == evolution.Major {
return exitBreaking
}
return exitOK
}
Expand Down
115 changes: 115 additions & 0 deletions cmd/crucible/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package main

import (
"encoding/json"
"fmt"
"io"

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

// lintFindingJSON is the JSON DTO for a single lint finding.
type lintFindingJSON struct {
Kind string `json:"kind"`
Severity string `json:"severity"`
State string `json:"state"`
Transition string `json:"transition"`
Message string `json:"message"`
}

// lintReportJSON is the JSON DTO for a lint report.
type lintReportJSON struct {
Findings []lintFindingJSON `json:"findings"`
}

// diffChangeJSON is the JSON DTO for a single diff change.
type diffChangeJSON struct {
Kind string `json:"kind"`
Path string `json:"path"`
Description string `json:"description"`
Breaking bool `json:"breaking"`
}

// diffReportJSON is the JSON DTO for a diff report.
type diffReportJSON struct {
Bump string `json:"bump"`
Breaking int `json:"breaking"`
Changes []diffChangeJSON `json:"changes"`
}

// toLintReportJSON maps an analysis.Report to a lintReportJSON DTO.
func toLintReportJSON(r analysis.Report) lintReportJSON {
findings := make([]lintFindingJSON, 0, len(r.Findings))
for _, f := range r.Findings {
findings = append(findings, lintFindingJSON{
Kind: string(f.Kind),
Severity: string(f.Severity),
State: f.State,
Transition: f.Transition,
Message: f.Message,
})
}
return lintReportJSON{Findings: findings}
}

// toDiffReportJSON maps an evolution.Report to a diffReportJSON DTO.
func toDiffReportJSON(r evolution.Report) diffReportJSON {
changes := make([]diffChangeJSON, 0, len(r.Changes))
breaking := 0
for _, c := range r.Changes {
if c.Breaking {
breaking++
}
changes = append(changes, diffChangeJSON{
Kind: string(c.Kind),
Path: c.Path,
Description: c.Description,
Breaking: c.Breaking,
})
}
return diffReportJSON{
Bump: string(r.SemverBump()),
Breaking: breaking,
Changes: changes,
}
}

// formatLint writes the lint report in the requested format to w. irPath is the
// IR's source path, recorded as a SARIF physical location ("-" for stdin is
// omitted). version stamps the SARIF tool driver.
func formatLint(r analysis.Report, format, irPath, version string, w io.Writer) error {
switch format {
case "json":
b, err := json.MarshalIndent(toLintReportJSON(r), "", " ")
if err != nil {
return fmt.Errorf("marshal lint report: %w", err)
}
emitln(w, string(b))
return nil
case "sarif":
b, err := lintToSARIF(r, irPath, version)
if err != nil {
return fmt.Errorf("build sarif output: %w", err)
}
emitln(w, string(b))
return nil
default:
return fmt.Errorf("unknown format %q", format)
}
}

// formatDiff writes the diff report in the requested format to w.
func formatDiff(r evolution.Report, format string, w io.Writer) error {
switch format {
case "json":
b, err := json.MarshalIndent(toDiffReportJSON(r), "", " ")
if err != nil {
return fmt.Errorf("marshal diff report: %w", err)
}
emitln(w, string(b))
return nil
default:
return fmt.Errorf("unknown format %q", format)
}
}
Loading