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()) + } +}