From fc17ac06da799de98a8579bdddb6bc2f38f2dc4d Mon Sep 17 00:00:00 2001 From: Jack Sullivan Date: Thu, 30 Jul 2026 10:11:10 -0700 Subject: [PATCH] feat: add --json output lint.Problem already carried `json:"rule"` and `json:"message"` tags, but nothing ever emitted them. A linter that runs in CI should be able to hand its results to something other than a human, so finish the API the types already declared. Collect results into a Report and render it as either text or JSON, so both formats derive from one structure and cannot disagree about what was found or what the exit code should be. Text output is unchanged, verified byte-for-byte against the previous binary. The payload reports `blocking` separately from `failed`: in warn mode a run has problems but does not fail, and a consumer needs to tell those apart. Empty problem lists marshal as [] rather than null so callers can iterate without a nil check. Adds the first tests for package main, covering the JSON shape as a CLI contract, the [] guarantee, and the three text renderings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01VSrrciEDBTuFNMtKocScML --- README.md | 35 ++++++++++++ main.go | 83 +++++++++++++++++++++++------ main_test.go | 146 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 248 insertions(+), 16 deletions(-) create mode 100644 main_test.go diff --git a/README.md b/README.md index c93125e..5b0c75e 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,41 @@ $ commitlint lint "Add gateway" The same in `--mode warn` prints the finding but exits 0. +## JSON output + +`--json` emits the same run as a structured document, for CI annotations, +dashboards, or `jq`. Exit codes are unchanged. + +``` +$ commitlint lint --json "Add gateway" +{ + "conforms": false, + "checked": 1, + "failed": 1, + "blocking": true, + "results": [ + { + "label": "message", + "subject": "Add gateway", + "problems": [ + { + "rule": "format", + "message": "subject must be `type(scope)?: description`, got \"Add gateway\"" + } + ] + } + ] +} +``` + +`blocking` is what drove the exit code: it is `false` in `--mode warn` even when +`failed` is non-zero, so a consumer can distinguish "found problems" from +"failed the run". `problems` is always an array, never `null`. + +```sh +commitlint lint --json --range origin/main..HEAD | jq -r '.results[].problems[].rule' +``` + ## Modes - `--mode block` (default): violations fail the run (exit 1). diff --git a/main.go b/main.go index ec59900..f8b599e 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ // --max-subject-length / COMMITLINT_MAX_SUBJECT_LENGTH / "max_subject_length" // --allow-revert-prefix / COMMITLINT_ALLOW_REVERT_PREFIX / "allow_revert_prefix" // --no-strict / COMMITLINT_NO_STRICT / "no_strict" keep git '#' comments +// --json: emit results as JSON instead of text; exit codes are unchanged // --mode / COMMITLINT_MODE: block (exit 1 on problems) or warn (always exit 0) // --config: JSON config path (default: .commitlint.json if present) package main @@ -70,6 +71,7 @@ flags: --max-subject-length N max subject line length (default 72) --allow-revert-prefix accept git's default 'Revert "..."' subjects --no-strict keep git's '#' comment lines instead of stripping them + --json emit results as JSON (exit codes are unchanged) --config PATH JSON config file (default: .commitlint.json if present)`) } @@ -87,6 +89,7 @@ func runLint(args []string) int { fs.Var(&noStrict, "no-strict", "") maxLen := fs.String("max-subject-length", "", "") configPath := fs.String("config", "", "") + jsonOut := fs.Bool("json", false, "") fs.Usage = usage fs.Parse(args) @@ -109,29 +112,77 @@ func runLint(args []string) int { return 2 } - failed := 0 + report := Report{Results: make([]Result, 0, len(messages))} for i, msg := range messages { problems := lint.Message(msg, cfg) - if len(problems) == 0 { - continue + if problems == nil { + problems = []lint.Problem{} // marshal as [], not null + } + report.Results = append(report.Results, Result{ + Label: labels[i], + Subject: strings.SplitN(msg, "\n", 2)[0], + Problems: problems, + }) + if len(problems) > 0 { + report.Failed++ } - failed++ - subject := strings.SplitN(msg, "\n", 2)[0] - fmt.Printf("✗ %s: %q\n", labels[i], subject) - for _, p := range problems { - fmt.Printf(" %s\n", p) + } + report.Checked = len(messages) + report.Conforms = report.Failed == 0 + // warn mode reports what it found but never fails the run. + report.Blocking = blockMode && !report.Conforms + + if *jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + fmt.Fprintln(os.Stderr, "commitlint:", err) + return 2 } + } else { + report.writeText(os.Stdout) } - if failed == 0 { - fmt.Printf("✓ %d message(s) conform to Conventional Commits\n", len(messages)) - return 0 + if report.Blocking { + return 1 + } + return 0 +} + +// Report is the full outcome of one lint run. It is the --json payload, so +// field names and shapes are part of the CLI's contract. +type Report struct { + Conforms bool `json:"conforms"` + Checked int `json:"checked"` + Failed int `json:"failed"` + Blocking bool `json:"blocking"` // false in warn mode even when failed > 0 + Results []Result `json:"results"` +} + +// Result is one linted message and everything found wrong with it. +type Result struct { + Label string `json:"label"` // where the message came from + Subject string `json:"subject"` + Problems []lint.Problem `json:"problems"` +} + +func (r Report) writeText(w io.Writer) { + for _, res := range r.Results { + if len(res.Problems) == 0 { + continue + } + fmt.Fprintf(w, "✗ %s: %q\n", res.Label, res.Subject) + for _, p := range res.Problems { + fmt.Fprintf(w, " %s\n", p) + } } - if !blockMode { - fmt.Printf("⚠ %d of %d message(s) do not conform (warn mode; not failing)\n", failed, len(messages)) - return 0 + switch { + case r.Conforms: + fmt.Fprintf(w, "✓ %d message(s) conform to Conventional Commits\n", r.Checked) + case !r.Blocking: + fmt.Fprintf(w, "⚠ %d of %d message(s) do not conform (warn mode; not failing)\n", r.Failed, r.Checked) + default: + fmt.Fprintf(w, "✗ %d of %d message(s) do not conform\n", r.Failed, r.Checked) } - fmt.Printf("✗ %d of %d message(s) do not conform\n", failed, len(messages)) - return 1 } type flagOverrides struct { diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..f89a691 --- /dev/null +++ b/main_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/DivergentCodes/commitlint/lint" +) + +func report(t *testing.T, results ...Result) Report { + t.Helper() + r := Report{Results: results, Checked: len(results)} + for _, res := range results { + if len(res.Problems) > 0 { + r.Failed++ + } + } + r.Conforms = r.Failed == 0 + return r +} + +// The --json payload is a CLI contract: field names and shapes must not drift. +func TestReportJSONShape(t *testing.T) { + r := report(t, Result{ + Label: "message", + Subject: "Add gateway", + Problems: []lint.Problem{{Rule: "format", Message: "bad subject"}}, + }) + r.Blocking = true + + data, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got map[string]any + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, key := range []string{"conforms", "checked", "failed", "blocking", "results"} { + if _, ok := got[key]; !ok { + t.Errorf("missing top-level key %q in %s", key, data) + } + } + if got["conforms"] != false || got["blocking"] != true { + t.Errorf("conforms/blocking wrong: %s", data) + } + res := got["results"].([]any)[0].(map[string]any) + for _, key := range []string{"label", "subject", "problems"} { + if _, ok := res[key]; !ok { + t.Errorf("missing result key %q in %s", key, data) + } + } + p := res["problems"].([]any)[0].(map[string]any) + if p["rule"] != "format" || p["message"] != "bad subject" { + t.Errorf("problem fields wrong: %s", data) + } +} + +// A conforming message must serialize problems as [], not null, so consumers +// can iterate without a nil check. +func TestReportEmptyProblemsMarshalAsArray(t *testing.T) { + r := report(t, Result{Label: "message", Subject: "feat: ok", Problems: []lint.Problem{}}) + data, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !bytes.Contains(data, []byte(`"problems":[]`)) { + t.Errorf("want empty array, got %s", data) + } + if bytes.Contains(data, []byte("null")) { + t.Errorf("payload contains null: %s", data) + } +} + +// Text rendering is the default output and must not regress. +func TestWriteText(t *testing.T) { + cases := []struct { + name string + build func() Report + want []string + }{ + { + "conforming", + func() Report { + return report(t, Result{Label: "message", Subject: "feat: ok"}) + }, + []string{"✓ 1 message(s) conform"}, + }, + { + "blocking violation", + func() Report { + r := report(t, Result{ + Label: "message", + Subject: "Add gateway", + Problems: []lint.Problem{{Rule: "format", Message: "bad subject"}}, + }) + r.Blocking = true + return r + }, + []string{`✗ message: "Add gateway"`, "format: bad subject", "✗ 1 of 1 message(s) do not conform"}, + }, + { + "warn mode does not claim to fail", + func() Report { + return report(t, Result{ + Label: "message", + Subject: "Add gateway", + Problems: []lint.Problem{{Rule: "format", Message: "bad subject"}}, + }) + }, + []string{"⚠ 1 of 1 message(s) do not conform (warn mode; not failing)"}, + }, + } + for _, c := range cases { + var buf bytes.Buffer + c.build().writeText(&buf) + for _, want := range c.want { + if !strings.Contains(buf.String(), want) { + t.Errorf("%s: output missing %q\ngot:\n%s", c.name, want, buf.String()) + } + } + } +} + +// Conforming messages are summarized, not listed line by line. +func TestWriteTextSkipsPassingMessages(t *testing.T) { + r := report(t, + Result{Label: "commit aaa", Subject: "feat: fine"}, + Result{ + Label: "commit bbb", + Subject: "broken", + Problems: []lint.Problem{{Rule: "format", Message: "bad"}}, + }, + ) + r.Blocking = true + var buf bytes.Buffer + r.writeText(&buf) + if strings.Contains(buf.String(), "feat: fine") { + t.Errorf("passing message should not be printed:\n%s", buf.String()) + } + if !strings.Contains(buf.String(), "broken") { + t.Errorf("failing message should be printed:\n%s", buf.String()) + } +}