From 26953d80f8f64af5f96e524700ac5e895857a545 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 17 Jun 2026 10:23:48 -0400 Subject: [PATCH] feat(cmd/crucible): machine-readable lint/diff output and diff exit-code gate lint gains -format text|json|sarif; diff gains -format text|json and an opt-in -exit-code that exits non-zero when the recommended bump is major (at least one breaking change), so lint and diff results can drive CI gates and code-scanning. - JSON is built from CLI-local DTOs; the state/analysis and state/evolution types are unchanged. SARIF is 2.1.0 (lint only) and anchors the IR path as a physical location except for stdin input. - Exit codes preserved: lint still exits non-zero on findings in every format. - Flags are accepted in any position; both subcommands route through reorderArgs. Closes #172. --- cmd/crucible/CHANGELOG.md | 8 ++ cmd/crucible/README.md | 16 ++- cmd/crucible/cmd.go | 82 +++++++++---- cmd/crucible/format.go | 115 ++++++++++++++++++ cmd/crucible/format_test.go | 233 ++++++++++++++++++++++++++++++++++++ cmd/crucible/main.go | 7 +- cmd/crucible/sarif.go | 122 +++++++++++++++++++ 7 files changed, 556 insertions(+), 27 deletions(-) create mode 100644 cmd/crucible/format.go create mode 100644 cmd/crucible/format_test.go create mode 100644 cmd/crucible/sarif.go diff --git a/cmd/crucible/CHANGELOG.md b/cmd/crucible/CHANGELOG.md index 55771fd..7f46d65 100644 --- a/cmd/crucible/CHANGELOG.md +++ b/cmd/crucible/CHANGELOG.md @@ -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. diff --git a/cmd/crucible/README.md b/cmd/crucible/README.md index 4aaeac9..53e3190 100644 --- a/cmd/crucible/README.md +++ b/cmd/crucible/README.md @@ -20,11 +20,14 @@ event is fired), so the structural view is exactly what the IR describes. ### lint ``` -crucible lint +crucible lint [-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 @@ -39,12 +42,15 @@ m.json -format dot | dot -Tsvg`); native SVG rendering is a future addition. ### diff ``` -crucible diff +crucible diff [-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 @@ -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 diff --git a/cmd/crucible/cmd.go b/cmd/crucible/cmd.go index 05c70f3..45d1995 100644 --- a/cmd/crucible/cmd.go +++ b/cmd/crucible/cmd.go @@ -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", "", 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 [-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 @@ -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 } @@ -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 ") + emitln(stderr, "usage: crucible diff [-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 } @@ -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 } diff --git a/cmd/crucible/format.go b/cmd/crucible/format.go new file mode 100644 index 0000000..1258a02 --- /dev/null +++ b/cmd/crucible/format.go @@ -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) + } +} diff --git a/cmd/crucible/format_test.go b/cmd/crucible/format_test.go new file mode 100644 index 0000000..446eba8 --- /dev/null +++ b/cmd/crucible/format_test.go @@ -0,0 +1,233 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +// TestLint_FormatJSON confirms -format json emits a machine-readable report: +// an empty findings array for a clean IR (exit 0) and populated findings for a +// defective IR (exit 1). +func TestLint_FormatJSON(t *testing.T) { + t.Run("clean IR has empty findings", func(t *testing.T) { + code, out, errOut := runCmd("lint", "testdata/clean.json", "-format", "json") + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + var got lintReportJSON + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal lint json: %v\n%s", err, out) + } + if len(got.Findings) != 0 { + t.Fatalf("want 0 findings, got %d: %+v", len(got.Findings), got.Findings) + } + }) + + t.Run("defective IR reports findings", func(t *testing.T) { + code, out, errOut := runCmd("lint", "testdata/defect.json", "-format", "json") + if code != exitFindings { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitFindings, errOut) + } + var got lintReportJSON + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal lint json: %v\n%s", err, out) + } + if len(got.Findings) == 0 { + t.Fatal("want findings for a defective IR, got none") + } + var sawUnreachable bool + for _, f := range got.Findings { + if f.Kind == "unreachable_state" { + sawUnreachable = true + if f.Severity != "error" { + t.Errorf("unreachable_state severity = %q, want error", f.Severity) + } + if f.State == "" { + t.Error("unreachable_state finding has empty state") + } + } + } + if !sawUnreachable { + t.Errorf("want an unreachable_state finding, got %+v", got.Findings) + } + }) +} + +// TestLint_FormatSARIF confirms -format sarif emits a valid SARIF 2.1.0 log: +// the version, tool name, and result rule/level are mapped from findings, and a +// stdin source ("-") omits the physical location. +func TestLint_FormatSARIF(t *testing.T) { + t.Run("defective IR from a file", func(t *testing.T) { + code, out, errOut := runCmd("lint", "testdata/defect.json", "-format", "sarif") + if code != exitFindings { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitFindings, errOut) + } + var root sarifRoot + if err := json.Unmarshal([]byte(out), &root); err != nil { + t.Fatalf("unmarshal sarif: %v\n%s", err, out) + } + if root.Version != "2.1.0" { + t.Errorf("version = %q, want 2.1.0", root.Version) + } + if len(root.Runs) != 1 { + t.Fatalf("want 1 run, got %d", len(root.Runs)) + } + run := root.Runs[0] + if run.Tool.Driver.Name != "crucible" { + t.Errorf("driver name = %q, want crucible", run.Tool.Driver.Name) + } + if run.Tool.Driver.Version != version { + t.Errorf("driver version = %q, want %q", run.Tool.Driver.Version, version) + } + if len(run.Results) == 0 { + t.Fatal("want results for a defective IR, got none") + } + first := run.Results[0] + if first.RuleID != "unreachable_state" { + t.Errorf("results[0].ruleId = %q, want unreachable_state", first.RuleID) + } + if first.Level != "error" { + t.Errorf("results[0].level = %q, want error", first.Level) + } + if len(first.Locations) == 0 || first.Locations[0].PhysicalLocation == nil { + t.Fatal("file-sourced finding should carry a physical location") + } + if got := first.Locations[0].PhysicalLocation.ArtifactLocation.URI; got != "testdata/defect.json" { + t.Errorf("artifact uri = %q, want testdata/defect.json", got) + } + }) + + t.Run("stdin source omits physical location", func(t *testing.T) { + b, err := os.ReadFile("testdata/defect.json") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + orig := os.Stdin + os.Stdin = r + t.Cleanup(func() { os.Stdin = orig }) + go func() { + _, _ = w.Write(b) + _ = w.Close() + }() + + code, out, errOut := runCmd("lint", "-", "-format", "sarif") + if code != exitFindings { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitFindings, errOut) + } + var root sarifRoot + if err := json.Unmarshal([]byte(out), &root); err != nil { + t.Fatalf("unmarshal sarif: %v\n%s", err, out) + } + if len(root.Runs) == 0 || len(root.Runs[0].Results) == 0 { + t.Fatal("want results from stdin, got none") + } + for i, res := range root.Runs[0].Results { + for j, loc := range res.Locations { + if loc.PhysicalLocation != nil { + t.Errorf("results[%d].locations[%d] has a physical location for stdin input", i, j) + } + } + } + }) +} + +// TestDiff_FormatJSON confirms -format json emits the recommended bump and a +// breaking count alongside the per-change list. +func TestDiff_FormatJSON(t *testing.T) { + cases := []struct { + name string + old, new string + wantBump string + wantBreaking int + }{ + {"additive change is minor", "testdata/old.json", "testdata/new_minor.json", "minor", 0}, + {"breaking change is major", "testdata/old.json", "testdata/new_major.json", "major", 2}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, out, errOut := runCmd("diff", tc.old, tc.new, "-format", "json") + if code != exitOK { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, errOut) + } + var got diffReportJSON + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("unmarshal diff json: %v\n%s", err, out) + } + if got.Bump != tc.wantBump { + t.Errorf("bump = %q, want %q", got.Bump, tc.wantBump) + } + if got.Breaking != tc.wantBreaking { + t.Errorf("breaking = %d, want %d", got.Breaking, tc.wantBreaking) + } + // The breaking count must equal the breaking changes in the list. + var listed int + for _, c := range got.Changes { + if c.Breaking { + listed++ + } + } + if listed != got.Breaking { + t.Errorf("breaking count %d disagrees with %d breaking changes listed", got.Breaking, listed) + } + }) + } +} + +// TestDiff_ExitCode confirms -exit-code returns exitBreaking only for a major +// (breaking) diff, exits zero for a compatible diff, and that without the flag +// even a breaking diff exits zero. +func TestDiff_ExitCode(t *testing.T) { + cases := []struct { + name string + args []string + wantCode int + }{ + {"breaking diff with -exit-code", []string{"diff", "testdata/old.json", "testdata/new_major.json", "-exit-code"}, exitBreaking}, + {"compatible diff with -exit-code", []string{"diff", "testdata/old.json", "testdata/new_minor.json", "-exit-code"}, exitOK}, + {"breaking diff without -exit-code", []string{"diff", "testdata/old.json", "testdata/new_major.json"}, exitOK}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, _, errOut := runCmd(tc.args...) + if code != tc.wantCode { + t.Fatalf("exit = %d, want %d (stderr: %s)", code, tc.wantCode, errOut) + } + }) + } +} + +// TestFormat_UnknownValue confirms unknown or unsupported -format values are +// rejected with a usage exit code for both lint and diff, including when the +// flag trails the positional arguments. +func TestFormat_UnknownValue(t *testing.T) { + cases := []struct { + name string + args []string + }{ + {"lint unknown format", []string{"lint", "testdata/clean.json", "-format", "zzz"}}, + {"diff unknown format", []string{"diff", "testdata/old.json", "testdata/new_minor.json", "-format", "zzz"}}, + {"diff sarif unsupported", []string{"diff", "testdata/old.json", "testdata/new_minor.json", "-format", "sarif"}}, + {"diff flag after paths still validated", []string{"diff", "testdata/old.json", "testdata/new_minor.json", "-format", "json"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + code, _, _ := runCmd(tc.args...) + // The last case is valid (json after paths) and must succeed; all + // others are usage errors. + if tc.name == "diff flag after paths still validated" { + if code != exitOK { + t.Fatalf("exit = %d, want %d", code, exitOK) + } + return + } + if code != exitUsage { + t.Fatalf("exit = %d, want %d", code, exitUsage) + } + }) + } +} diff --git a/cmd/crucible/main.go b/cmd/crucible/main.go index 50f4e05..0a2c3b6 100644 --- a/cmd/crucible/main.go +++ b/cmd/crucible/main.go @@ -20,6 +20,9 @@ const ( exitError = 1 exitUsage = 2 exitFindings = 1 + // exitBreaking is returned by diff -exit-code when the recommended bump is + // major (at least one breaking change). It shares the value of exitError. + exitBreaking = 1 ) func main() { @@ -79,9 +82,9 @@ Usage: crucible [arguments] Commands: - lint run static analysis and report findings + lint [-format f] run static analysis; -format text (default), json, or sarif render [-format f] render the machine as mermaid (default) or dot - diff classify changes and recommend a semver bump + diff [-format f] [-exit-code] classify changes and recommend a semver bump validate confirm the IR loads and assembles eject [-package p] [-o f] generate typed Go behavior stubs version print the crucible CLI version diff --git a/cmd/crucible/sarif.go b/cmd/crucible/sarif.go new file mode 100644 index 0000000..6db71f3 --- /dev/null +++ b/cmd/crucible/sarif.go @@ -0,0 +1,122 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/stablekernel/crucible/state/analysis" +) + +// sarifRoot is the top-level SARIF 2.1.0 log object. +type sarifRoot struct { + Version string `json:"version"` + Schema string `json:"$schema"` + Runs []sarifRun `json:"runs"` +} + +// sarifRun is a single analysis run. +type sarifRun struct { + Tool sarifTool `json:"tool"` + Results []sarifResult `json:"results"` +} + +// sarifTool names the analysis tool that produced the run. +type sarifTool struct { + Driver sarifDriver `json:"driver"` +} + +// sarifDriver identifies the tool's driver component. +type sarifDriver struct { + Name string `json:"name"` + InformationURI string `json:"informationUri"` + Version string `json:"version"` +} + +// sarifResult is a single finding. +type sarifResult struct { + RuleID string `json:"ruleId"` + Level string `json:"level"` + Message sarifMessage `json:"message"` + Locations []sarifLocation `json:"locations"` +} + +// sarifMessage carries a finding's human-readable text. +type sarifMessage struct { + Text string `json:"text"` +} + +// sarifLocation locates a finding logically (state/transition names) and, +// when the IR came from a file, physically. +type sarifLocation struct { + LogicalLocations []sarifLogicalLocation `json:"logicalLocations"` + PhysicalLocation *sarifPhysicalLocation `json:"physicalLocation,omitempty"` +} + +// sarifLogicalLocation names a logical program element (a state or transition). +type sarifLogicalLocation struct { + Name string `json:"name"` + Kind string `json:"kind"` +} + +// sarifPhysicalLocation points at the IR artifact on disk. +type sarifPhysicalLocation struct { + ArtifactLocation sarifArtifactLocation `json:"artifactLocation"` +} + +// sarifArtifactLocation is the artifact URI. +type sarifArtifactLocation struct { + URI string `json:"uri"` +} + +// lintToSARIF converts an analysis.Report to a SARIF 2.1.0 JSON byte slice. +// irPath records the source artifact (omitted when "-" for stdin); version +// stamps the tool driver. +func lintToSARIF(r analysis.Report, irPath, version string) ([]byte, error) { + results := make([]sarifResult, 0, len(r.Findings)) + for _, f := range r.Findings { + level := "warning" + if f.Severity == analysis.SeverityError { + level = "error" + } + + locs := []sarifLogicalLocation{{Name: f.State, Kind: "state"}} + if f.Transition != "" { + locs = append(locs, sarifLogicalLocation{Name: f.Transition, Kind: "transition"}) + } + + loc := sarifLocation{LogicalLocations: locs} + if irPath != "-" { + loc.PhysicalLocation = &sarifPhysicalLocation{ + ArtifactLocation: sarifArtifactLocation{URI: irPath}, + } + } + + results = append(results, sarifResult{ + RuleID: string(f.Kind), + Level: level, + Message: sarifMessage{Text: f.Message}, + Locations: []sarifLocation{loc}, + }) + } + + root := sarifRoot{ + Version: "2.1.0", + Schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + Runs: []sarifRun{{ + Tool: sarifTool{ + Driver: sarifDriver{ + Name: "crucible", + InformationURI: "https://github.com/stablekernel/crucible", + Version: version, + }, + }, + Results: results, + }}, + } + + b, err := json.MarshalIndent(root, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal sarif: %w", err) + } + return b, nil +}