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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
83 changes: 67 additions & 16 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)`)
}

Expand All @@ -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)

Expand All @@ -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 {
Expand Down
146 changes: 146 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading