From adb7f1972003b0db74fd03b8ae7caa9fc4aaf505 Mon Sep 17 00:00:00 2001 From: muthukrishnan24 Date: Sat, 21 Feb 2026 20:21:46 +0530 Subject: [PATCH 1/7] chore: add commitlint config --- .commitlint.yaml | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .commitlint.yaml diff --git a/.commitlint.yaml b/.commitlint.yaml new file mode 100644 index 0000000..5d7037a --- /dev/null +++ b/.commitlint.yaml @@ -0,0 +1,40 @@ +min-version: v0.11.0 +formatter: default +rules: + - header-min-length + - header-max-length + - body-max-line-length + - footer-max-line-length + - type-enum +severity: + default: error + rules: {} +settings: + body-max-line-length: + argument: 100 + flags: {} + footer-max-line-length: + argument: 100 + flags: {} + header-max-length: + argument: 72 + flags: {} + header-min-length: + argument: 10 + flags: {} + type-enum: + argument: + - feat + - fix + - docs + - style + - refactor + - perf + - test + - build + - ci + - chore + - revert + flags: {} +disable-default-ignores: false +ignores: [] From e052a7593f33f8adc0557e2b36243c979b0c1eef Mon Sep 17 00:00:00 2001 From: muthukrishnan24 Date: Sun, 22 Feb 2026 12:48:28 +0530 Subject: [PATCH 2/7] ci: move to goreleaser v2 --- .github/workflows/go.yml | 2 +- .github/workflows/goreleaser.yml | 10 +++++----- .goreleaser.yml | 11 ++++------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 8cc3580..61be76c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: go-version: "1.24.2" diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index a3bc883..ed8ccb9 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -13,20 +13,20 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: go-version: 1.24.2 - name: Run Tests run: go test ./... -count=1 - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v2 + uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser - version: v1.18.2 - args: release --rm-dist + version: "~> v2" + args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 56dd254..539700b 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,3 +1,5 @@ +version: 2 + before: hooks: - go mod tidy @@ -15,12 +17,7 @@ builds: - -tags=urfave_cli_no_docs archives: - - replacements: - darwin: Darwin - linux: Linux - windows: Windows - 386: i386 - amd64: x86_64 + - name_template: "{{ .ProjectName }}_v{{.Major}}.{{.Minor}}.{{.Patch}}_{{ .Os }}_{{ .Arch }}" files: - LICENSE.md - README.md @@ -30,7 +27,7 @@ checksum: algorithm: sha256 snapshot: - name_template: "{{ .Tag }}" + version_template: "{{ .Tag }}" changelog: sort: asc From fd2f76c8ec3af089fab936b38be01fab44a0de18 Mon Sep 17 00:00:00 2001 From: muthukrishnan24 Date: Sat, 21 Feb 2026 21:01:07 +0530 Subject: [PATCH 3/7] build: update dependencies --- go.sum | 4 ---- 1 file changed, 4 deletions(-) diff --git a/go.sum b/go.sum index 3eba634..698857d 100644 --- a/go.sum +++ b/go.sum @@ -6,12 +6,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= -github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= From b9e5ca27d8de73a717317604aad263c3c32ae9ee Mon Sep 17 00:00:00 2001 From: muthukrishnan24 Date: Sun, 22 Feb 2026 12:38:51 +0530 Subject: [PATCH 4/7] feat: support new rules --- config/default.go | 53 +++ internal/casing/casing.go | 149 ++++++ internal/casing/casing_test.go | 225 ++++++++++ internal/registry/registry.go | 31 +- rule/breaking_change_rule.go | 47 ++ rule/case_rules.go | 128 ++++++ rule/empty_rules.go | 74 +++ rule/footer_enum.go | 1 + rule/footer_type_enum.go | 1 + rule/fullstop_rules.go | 77 ++++ rule/header_trim_rule.go | 23 + rule/leading_blank_rules.go | 65 +++ rule/trailer_rules.go | 64 +++ test/rule_test.go | 795 +++++++++++++++++++++++++++++++++ 14 files changed, 1730 insertions(+), 3 deletions(-) create mode 100644 internal/casing/casing.go create mode 100644 internal/casing/casing_test.go create mode 100644 rule/breaking_change_rule.go create mode 100644 rule/case_rules.go create mode 100644 rule/empty_rules.go create mode 100644 rule/fullstop_rules.go create mode 100644 rule/header_trim_rule.go create mode 100644 rule/leading_blank_rules.go create mode 100644 rule/trailer_rules.go diff --git a/config/default.go b/config/default.go index b29434e..9c5a63e 100644 --- a/config/default.go +++ b/config/default.go @@ -172,6 +172,59 @@ func NewDefault() *lint.Config { (&rule.FooterTypeEnumRule{}).Name(): { Argument: []interface{}{}, }, + + // Case Rules + (&rule.TypeCaseRule{}).Name(): { + Argument: "lower-case", + }, + (&rule.ScopeCaseRule{}).Name(): { + Argument: "lower-case", + }, + (&rule.DescriptionCaseRule{}).Name(): { + Argument: "lower-case", + }, + (&rule.BodyCaseRule{}).Name(): { + Argument: "lower-case", + }, + (&rule.HeaderCaseRule{}).Name(): { + Argument: "lower-case", + }, + + // Full-stop Rules + (&rule.HeaderFullStopRule{}).Name(): { + Argument: ".", + }, + (&rule.BodyFullStopRule{}).Name(): { + Argument: ".", + }, + (&rule.DescriptionFullStopRule{}).Name(): { + Argument: ".", + }, + + // Trailer / Signed-off-by + (&rule.SignedOffByRule{}).Name(): { + Argument: "Signed-off-by", + }, + (&rule.TrailerExistsRule{}).Name(): { + Argument: "Signed-off-by", + }, + + // Empty rules (no argument needed) + (&rule.TypeEmptyRule{}).Name(): {}, + (&rule.ScopeEmptyRule{}).Name(): {}, + (&rule.BodyEmptyRule{}).Name(): {}, + (&rule.FooterEmptyRule{}).Name(): {}, + (&rule.DescriptionEmptyRule{}).Name(): {}, + + // Leading-blank rules (no argument needed) + (&rule.BodyLeadingBlankRule{}).Name(): {}, + (&rule.FooterLeadingBlankRule{}).Name(): {}, + + // Header trim (no argument needed) + (&rule.HeaderTrimRule{}).Name(): {}, + + // Breaking change (no argument needed) + (&rule.BreakingChangeExclamationMarkRule{}).Name(): {}, } def := &lint.Config{ diff --git a/internal/casing/casing.go b/internal/casing/casing.go new file mode 100644 index 0000000..4609f91 --- /dev/null +++ b/internal/casing/casing.go @@ -0,0 +1,149 @@ +// Package casing provides case-format constants and validators used by commit +// lint rules. Each format has a corresponding exported predicate and a shared +// [Check] dispatcher. +package casing + +import ( + "strings" + "unicode" +) + +// Case-format constants. These are the only values accepted by rules that take +// a case argument (e.g. type-case, scope-case, …). +const ( + Lower = "lower-case" // all characters lower-cased, e.g. "feat" + Upper = "upper-case" // all characters upper-cased, e.g. "FEAT" + Camel = "camel-case" // starts lowercase, no separators, e.g. "myFeat" + Kebab = "kebab-case" // lowercase words joined by hyphens, e.g. "my-feat" + Pascal = "pascal-case" // starts uppercase, no separators, e.g. "MyFeat" + Sentence = "sentence-case" // first letter uppercase, rest lowercase, e.g. "My feat" + Snake = "snake-case" // lowercase words joined by underscores, e.g. "my_feat" + Start = "start-case" // every word starts uppercase, e.g. "My Feat" +) + +// All is the ordered list of all valid case formats. +var All = []string{Lower, Upper, Camel, Kebab, Pascal, Sentence, Snake, Start} + +// Check returns true when s conforms to the given caseFormat constant. +// An empty string always returns true (treated as "not present"). +// Returns false for any unrecognised caseFormat value. +func Check(s, caseFormat string) bool { + if s == "" { + return true + } + switch caseFormat { + case Lower: + return s == strings.ToLower(s) + case Upper: + return s == strings.ToUpper(s) + case Camel: + return IsCamelCase(s) + case Kebab: + return IsKebabCase(s) + case Pascal: + return IsPascalCase(s) + case Sentence: + return IsSentenceCase(s) + case Snake: + return IsSnakeCase(s) + case Start: + return IsStartCase(s) + default: + return false + } +} + +// IsCamelCase reports whether s is in camelCase format. +// camelCase: starts with a lowercase letter and contains only letters and +// digits (no separators). Examples: "feat", "myFeature", "parseHTML". +func IsCamelCase(s string) bool { + if s == "" { + return true + } + runes := []rune(s) + if unicode.IsUpper(runes[0]) { + return false + } + for _, r := range runes { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return false + } + } + return true +} + +// IsKebabCase reports whether s is in kebab-case format. +// kebab-case: only lowercase letters, digits, and hyphens. +// Examples: "my-feature", "kebab-case", "v2-api". +func IsKebabCase(s string) bool { + for _, r := range s { + if !unicode.IsLower(r) && !unicode.IsDigit(r) && r != '-' { + return false + } + } + return true +} + +// IsPascalCase reports whether s is in PascalCase format. +// PascalCase: starts with an uppercase letter and contains only letters and +// digits (no separators). Examples: "MyFeature", "ParseHTML", "Feat". +func IsPascalCase(s string) bool { + if s == "" { + return true + } + runes := []rune(s) + if !unicode.IsUpper(runes[0]) { + return false + } + for _, r := range runes { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return false + } + } + return true +} + +// IsSentenceCase reports whether s is in Sentence case format. +// Sentence case: first rune is uppercase; every subsequent letter is lowercase. +// Digits and punctuation are allowed anywhere. +// Examples: "My feature", "Add endpoint", "Fix #123". +func IsSentenceCase(s string) bool { + if s == "" { + return true + } + runes := []rune(s) + if !unicode.IsUpper(runes[0]) { + return false + } + for _, r := range runes[1:] { + if unicode.IsLetter(r) && unicode.IsUpper(r) { + return false + } + } + return true +} + +// IsSnakeCase reports whether s is in snake_case format. +// snake_case: only lowercase letters, digits, and underscores. +// Examples: "my_feature", "snake_case", "v2_api". +func IsSnakeCase(s string) bool { + for _, r := range s { + if !unicode.IsLower(r) && !unicode.IsDigit(r) && r != '_' { + return false + } + } + return true +} + +// IsStartCase reports whether s is in Start Case format. +// Start Case: every whitespace-separated word starts with an uppercase letter. +// Examples: "My Feature", "Add New Endpoint". +func IsStartCase(s string) bool { + for _, w := range strings.Fields(s) { + runes := []rune(w) + if !unicode.IsUpper(runes[0]) { + return false + } + } + return true +} diff --git a/internal/casing/casing_test.go b/internal/casing/casing_test.go new file mode 100644 index 0000000..fa133a6 --- /dev/null +++ b/internal/casing/casing_test.go @@ -0,0 +1,225 @@ +package casing_test + +import ( + "testing" + + "github.com/conventionalcommit/commitlint/internal/casing" +) + +func TestIsCamelCase(t *testing.T) { + for _, tc := range []struct { + in string + want bool + }{ + {"", true}, // empty always passes + {"feat", true}, // all-lowercase single word + {"myFeature", true}, // classic camelCase + {"parseHTML", true}, // acronym tail is fine (still no separator) + {"myFeat123", true}, // digits allowed + {"MyFeature", false}, // starts uppercase → PascalCase, not camelCase + {"my-feature", false}, // hyphen not allowed + {"my_feature", false}, // underscore not allowed + {"my feature", false}, // space not allowed + } { + got := casing.IsCamelCase(tc.in) + if got != tc.want { + t.Errorf("IsCamelCase(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestIsKebabCase(t *testing.T) { + for _, tc := range []struct { + in string + want bool + }{ + {"", true}, + {"feat", true}, + {"my-feature", true}, + {"kebab-case", true}, + {"v2-api", true}, // digit allowed + {"my--feat", true}, // consecutive hyphens: spec doesn't forbid them + {"MyFeature", false}, // uppercase + {"my_feature", false}, // underscore + {"my feature", false}, // space + {"MY-FEAT", false}, // uppercase letters + } { + got := casing.IsKebabCase(tc.in) + if got != tc.want { + t.Errorf("IsKebabCase(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestIsPascalCase(t *testing.T) { + for _, tc := range []struct { + in string + want bool + }{ + {"", true}, + {"Feat", true}, + {"MyFeature", true}, + {"ParseHTML", true}, + {"MyFeat123", true}, // digits ok + {"myFeature", false}, // starts lowercase + {"My-Feature", false}, // hyphen not allowed + {"My Feature", false}, // space not allowed + } { + got := casing.IsPascalCase(tc.in) + if got != tc.want { + t.Errorf("IsPascalCase(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestIsSentenceCase(t *testing.T) { + for _, tc := range []struct { + in string + want bool + }{ + {"", true}, + {"Feat", true}, + {"My feature", true}, + {"Add endpoint", true}, + {"Fix #123", true}, // punctuation and digit allowed + {"feat", false}, // starts lowercase + {"My Feature", false}, // second word capitalised + {"MY FEATURE", false}, // fully uppercased + } { + got := casing.IsSentenceCase(tc.in) + if got != tc.want { + t.Errorf("IsSentenceCase(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestIsSnakeCase(t *testing.T) { + for _, tc := range []struct { + in string + want bool + }{ + {"", true}, + {"feat", true}, + {"my_feature", true}, + {"snake_case", true}, + {"v2_api", true}, // digit allowed + {"MyFeature", false}, // uppercase + {"my-feature", false}, // hyphen + {"my feature", false}, // space + } { + got := casing.IsSnakeCase(tc.in) + if got != tc.want { + t.Errorf("IsSnakeCase(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestIsStartCase(t *testing.T) { + for _, tc := range []struct { + in string + want bool + }{ + {"", true}, + {"Feat", true}, + {"My Feature", true}, + {"Add New Endpoint", true}, + {"my feature", false}, // first word lowercase + {"My feature", false}, // second word lowercase + {"MY FEATURE", true}, // all-uppercase still starts uppercase per word + } { + got := casing.IsStartCase(tc.in) + if got != tc.want { + t.Errorf("IsStartCase(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestCheck_AllFormats(t *testing.T) { + for _, tc := range []struct { + format string + pass []string + fail []string + }{ + { + casing.Lower, + []string{"", "feat", "my feat"}, + []string{"Feat", "FEAT"}, + }, + { + casing.Upper, + []string{"", "FEAT", "MY FEAT"}, + []string{"feat", "Feat"}, + }, + { + casing.Camel, + []string{"", "feat", "myFeature"}, + []string{"MyFeature", "my-feature"}, + }, + { + casing.Kebab, + []string{"", "feat", "my-feature"}, + []string{"MyFeature", "my_feature"}, + }, + { + casing.Pascal, + []string{"", "Feat", "MyFeature"}, + []string{"feat", "my_feature"}, + }, + { + casing.Sentence, + []string{"", "Feat", "My feat"}, + []string{"feat", "My Feat"}, + }, + { + casing.Snake, + []string{"", "feat", "my_feature"}, + []string{"MyFeature", "my-feature"}, + }, + { + casing.Start, + []string{"", "Feat", "My Feature"}, + []string{"feat", "my feature"}, + }, + } { + for _, s := range tc.pass { + if !casing.Check(s, tc.format) { + t.Errorf("Check(%q, %q) = false, want true", s, tc.format) + } + } + for _, s := range tc.fail { + if casing.Check(s, tc.format) { + t.Errorf("Check(%q, %q) = true, want false", s, tc.format) + } + } + } +} + +func TestCheck_UnknownFormat(t *testing.T) { + if casing.Check("anything", "not-a-case") { + t.Error("Check with unknown format should return false") + } +} + +func TestAll_ContainsAllConstants(t *testing.T) { + want := map[string]bool{ + casing.Lower: false, + casing.Upper: false, + casing.Camel: false, + casing.Kebab: false, + casing.Pascal: false, + casing.Sentence: false, + casing.Snake: false, + casing.Start: false, + } + for _, c := range casing.All { + if _, ok := want[c]; !ok { + t.Errorf("casing.All contains unexpected value %q", c) + } + want[c] = true + } + for k, seen := range want { + if !seen { + t.Errorf("casing.All is missing constant %q", k) + } + } +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go index ae45940..b2446e6 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -48,18 +48,43 @@ type registry struct { func newRegistry() *registry { defaultRules := []lint.Rule{ + // Length rules &rule.BodyMinLenRule{}, &rule.BodyMaxLenRule{}, &rule.FooterMinLenRule{}, &rule.FooterMaxLenRule{}, &rule.HeadMaxLenRule{}, &rule.HeadMinLenRule{}, &rule.BodyMaxLineLenRule{}, &rule.FooterMaxLineLenRule{}, + &rule.TypeMaxLenRule{}, &rule.ScopeMaxLenRule{}, &rule.DescriptionMaxLenRule{}, + &rule.TypeMinLenRule{}, &rule.ScopeMinLenRule{}, &rule.DescriptionMinLenRule{}, + // Enum rules &rule.TypeEnumRule{}, &rule.ScopeEnumRule{}, &rule.FooterEnumRule{}, + &rule.FooterTypeEnumRule{}, + + // Charset rules &rule.TypeCharsetRule{}, &rule.ScopeCharsetRule{}, - &rule.TypeMaxLenRule{}, &rule.ScopeMaxLenRule{}, &rule.DescriptionMaxLenRule{}, - &rule.TypeMinLenRule{}, &rule.ScopeMinLenRule{}, &rule.DescriptionMinLenRule{}, + // Case rules + &rule.TypeCaseRule{}, &rule.ScopeCaseRule{}, + &rule.DescriptionCaseRule{}, &rule.BodyCaseRule{}, &rule.HeaderCaseRule{}, - &rule.FooterTypeEnumRule{}, + // Empty rules + &rule.TypeEmptyRule{}, &rule.ScopeEmptyRule{}, + &rule.BodyEmptyRule{}, &rule.FooterEmptyRule{}, &rule.DescriptionEmptyRule{}, + + // Full-stop rules + &rule.HeaderFullStopRule{}, &rule.BodyFullStopRule{}, &rule.DescriptionFullStopRule{}, + + // Leading-blank rules + &rule.BodyLeadingBlankRule{}, &rule.FooterLeadingBlankRule{}, + + // Header trim + &rule.HeaderTrimRule{}, + + // Trailer / signed-off-by rules + &rule.SignedOffByRule{}, &rule.TrailerExistsRule{}, + + // Breaking change + &rule.BreakingChangeExclamationMarkRule{}, } defaultFormatters := []lint.Formatter{ diff --git a/rule/breaking_change_rule.go b/rule/breaking_change_rule.go new file mode 100644 index 0000000..31f163f --- /dev/null +++ b/rule/breaking_change_rule.go @@ -0,0 +1,47 @@ +package rule + +import "github.com/conventionalcommit/commitlint/lint" + +// Compile-time interface check. +var _ lint.Rule = (*BreakingChangeExclamationMarkRule)(nil) + +// BreakingChangeExclamationMarkRule implements the breaking-change-exclamation-mark rule. +// +// It enforces that breaking changes are signalled consistently via an XNOR: +// - PASS when the header contains "!" AND a footer note has token +// "BREAKING CHANGE" or "BREAKING-CHANGE". +// - PASS when neither signal is present. +// - FAIL when exactly one of the two is present. +// +// Detection uses the parsed Notes() (footer tokens), not raw string scanning. +type BreakingChangeExclamationMarkRule struct{} + +func (r *BreakingChangeExclamationMarkRule) Name() string { return "breaking-change-exclamation-mark" } +func (r *BreakingChangeExclamationMarkRule) Apply(_ lint.RuleSetting) error { return nil } +func (r *BreakingChangeExclamationMarkRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + hasExclamation := msg.IsBreakingChange() + + // Detect "BREAKING CHANGE" or "BREAKING-CHANGE" via parsed footer notes. + hasFooterBreaking := false + for _, note := range msg.Notes() { + t := note.Token() + if t == "BREAKING CHANGE" || t == "BREAKING-CHANGE" { + hasFooterBreaking = true + break + } + } + + // XNOR: pass when both present or both absent. + if hasExclamation == hasFooterBreaking { + return nil, true + } + + if hasExclamation && !hasFooterBreaking { + return lint.NewIssue( + "breaking change exclamation mark in header requires BREAKING CHANGE in footer", + ), false + } + return lint.NewIssue( + "BREAKING CHANGE in footer requires exclamation mark in header", + ), false +} diff --git a/rule/case_rules.go b/rule/case_rules.go new file mode 100644 index 0000000..0f6323f --- /dev/null +++ b/rule/case_rules.go @@ -0,0 +1,128 @@ +package rule + +import ( + "fmt" + "slices" + + "github.com/conventionalcommit/commitlint/internal/casing" + "github.com/conventionalcommit/commitlint/lint" +) + +// Compile-time interface checks +var ( + _ lint.Rule = (*TypeCaseRule)(nil) + _ lint.Rule = (*ScopeCaseRule)(nil) + _ lint.Rule = (*DescriptionCaseRule)(nil) + _ lint.Rule = (*BodyCaseRule)(nil) + _ lint.Rule = (*HeaderCaseRule)(nil) +) + +// CaseValues is the ordered list of supported case formats. +// Use the casing.* constants (e.g. casing.Lower, casing.Pascal) when +// referring to individual formats in code. +var CaseValues = casing.All + +func caseIssue(field, caseFormat string) *lint.Issue { + return lint.NewIssue(fmt.Sprintf("%s must be in %s", field, caseFormat)) +} + +func applyCaseArg(dst *string, ruleName string, setting lint.RuleSetting) error { + if err := setStringArg(dst, setting.Argument); err != nil { + return errInvalidArg(ruleName, err) + } + if slices.Contains(casing.All, *dst) { + return nil + } + return errInvalidArg(ruleName, fmt.Errorf("unknown case %q, valid values: %v", *dst, casing.All)) +} + +// TypeCaseRule validates that commit type matches a given case format. +// Argument: one of the casing.* constants (e.g. casing.Lower). +type TypeCaseRule struct{ Case string } + +func (r *TypeCaseRule) Name() string { return "type-case" } +func (r *TypeCaseRule) Apply(s lint.RuleSetting) error { + return applyCaseArg(&r.Case, r.Name(), s) +} + +func (r *TypeCaseRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + if casing.Check(msg.Type(), r.Case) { + return nil, true + } + return caseIssue("type", r.Case), false +} + +// ScopeCaseRule validates that commit scope matches a given case format. +// An empty scope is always accepted (scope is optional by convention). +// Argument: one of the casing.* constants. +type ScopeCaseRule struct{ Case string } + +func (r *ScopeCaseRule) Name() string { return "scope-case" } +func (r *ScopeCaseRule) Apply(s lint.RuleSetting) error { + return applyCaseArg(&r.Case, r.Name(), s) +} + +func (r *ScopeCaseRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + // scope is optional; skip the check when absent + if msg.Scope() == "" { + return nil, true + } + if casing.Check(msg.Scope(), r.Case) { + return nil, true + } + return caseIssue("scope", r.Case), false +} + +// DescriptionCaseRule validates that the commit description (subject) matches a +// given case format. +// Argument: one of the casing.* constants. +type DescriptionCaseRule struct{ Case string } + +func (r *DescriptionCaseRule) Name() string { return "description-case" } +func (r *DescriptionCaseRule) Apply(s lint.RuleSetting) error { + return applyCaseArg(&r.Case, r.Name(), s) +} + +func (r *DescriptionCaseRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + if casing.Check(msg.Description(), r.Case) { + return nil, true + } + return caseIssue("description", r.Case), false +} + +// BodyCaseRule validates that the commit body as a whole matches a given case +// format. An empty body always passes. +// Argument: one of the casing.* constants. +type BodyCaseRule struct{ Case string } + +func (r *BodyCaseRule) Name() string { return "body-case" } +func (r *BodyCaseRule) Apply(s lint.RuleSetting) error { + return applyCaseArg(&r.Case, r.Name(), s) +} + +func (r *BodyCaseRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + body := msg.Body() + if body == "" { + return nil, true + } + if casing.Check(body, r.Case) { + return nil, true + } + return caseIssue("body", r.Case), false +} + +// HeaderCaseRule validates that the commit header matches a given case format. +// Argument: one of the casing.* constants. +type HeaderCaseRule struct{ Case string } + +func (r *HeaderCaseRule) Name() string { return "header-case" } +func (r *HeaderCaseRule) Apply(s lint.RuleSetting) error { + return applyCaseArg(&r.Case, r.Name(), s) +} + +func (r *HeaderCaseRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + if casing.Check(msg.Header(), r.Case) { + return nil, true + } + return caseIssue("header", r.Case), false +} diff --git a/rule/empty_rules.go b/rule/empty_rules.go new file mode 100644 index 0000000..ef70d79 --- /dev/null +++ b/rule/empty_rules.go @@ -0,0 +1,74 @@ +package rule + +import ( + "github.com/conventionalcommit/commitlint/lint" +) + +// Compile-time interface checks +var ( + _ lint.Rule = (*TypeEmptyRule)(nil) + _ lint.Rule = (*ScopeEmptyRule)(nil) + _ lint.Rule = (*BodyEmptyRule)(nil) + _ lint.Rule = (*FooterEmptyRule)(nil) + _ lint.Rule = (*DescriptionEmptyRule)(nil) +) + +// TypeEmptyRule validates that the commit type is not empty. +type TypeEmptyRule struct{} + +func (r *TypeEmptyRule) Name() string { return "type-empty" } +func (r *TypeEmptyRule) Apply(_ lint.RuleSetting) error { return nil } +func (r *TypeEmptyRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + if msg.Type() != "" { + return nil, true + } + return lint.NewIssue("type must not be empty"), false +} + +// ScopeEmptyRule validates that the commit scope is not empty. +type ScopeEmptyRule struct{} + +func (r *ScopeEmptyRule) Name() string { return "scope-empty" } +func (r *ScopeEmptyRule) Apply(_ lint.RuleSetting) error { return nil } +func (r *ScopeEmptyRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + if msg.Scope() != "" { + return nil, true + } + return lint.NewIssue("scope must not be empty"), false +} + +// BodyEmptyRule validates that the commit body is not empty. +type BodyEmptyRule struct{} + +func (r *BodyEmptyRule) Name() string { return "body-empty" } +func (r *BodyEmptyRule) Apply(_ lint.RuleSetting) error { return nil } +func (r *BodyEmptyRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + if msg.Body() != "" { + return nil, true + } + return lint.NewIssue("body must not be empty"), false +} + +// FooterEmptyRule validates that the commit footer is not empty. +type FooterEmptyRule struct{} + +func (r *FooterEmptyRule) Name() string { return "footer-empty" } +func (r *FooterEmptyRule) Apply(_ lint.RuleSetting) error { return nil } +func (r *FooterEmptyRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + if msg.Footer() != "" { + return nil, true + } + return lint.NewIssue("footer must not be empty"), false +} + +// DescriptionEmptyRule validates that the commit description is not empty. +type DescriptionEmptyRule struct{} + +func (r *DescriptionEmptyRule) Name() string { return "description-empty" } +func (r *DescriptionEmptyRule) Apply(_ lint.RuleSetting) error { return nil } +func (r *DescriptionEmptyRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + if msg.Description() != "" { + return nil, true + } + return lint.NewIssue("description must not be empty"), false +} diff --git a/rule/footer_enum.go b/rule/footer_enum.go index 996c541..e9f32e7 100644 --- a/rule/footer_enum.go +++ b/rule/footer_enum.go @@ -8,6 +8,7 @@ import ( "github.com/conventionalcommit/commitlint/lint" ) +// Compile-time interface checks var _ lint.Rule = (*FooterEnumRule)(nil) // FooterEnumRule to validate footer tokens diff --git a/rule/footer_type_enum.go b/rule/footer_type_enum.go index 642aab4..6bb87d7 100644 --- a/rule/footer_type_enum.go +++ b/rule/footer_type_enum.go @@ -10,6 +10,7 @@ import ( "github.com/conventionalcommit/commitlint/lint" ) +// Compile-time interface checks var _ lint.Rule = (*FooterTypeEnumRule)(nil) // FooterTypeEnumRule to validate footer tokens diff --git a/rule/fullstop_rules.go b/rule/fullstop_rules.go new file mode 100644 index 0000000..b63a321 --- /dev/null +++ b/rule/fullstop_rules.go @@ -0,0 +1,77 @@ +package rule + +import ( + "fmt" + "strings" + + "github.com/conventionalcommit/commitlint/lint" +) + +// Compile-time interface checks +var ( + _ lint.Rule = (*HeaderFullStopRule)(nil) + _ lint.Rule = (*BodyFullStopRule)(nil) + _ lint.Rule = (*DescriptionFullStopRule)(nil) +) + +// applyFullStopArg extracts a single character string from setting. +func applyFullStopArg(dst *string, ruleName string, setting lint.RuleSetting) error { + if err := setStringArg(dst, setting.Argument); err != nil { + return errInvalidArg(ruleName, err) + } + return nil +} + +// HeaderFullStopRule checks that the header does NOT end with a given character. +// Default character is ".". +type HeaderFullStopRule struct{ Char string } + +func (r *HeaderFullStopRule) Name() string { return "header-full-stop" } +func (r *HeaderFullStopRule) Apply(s lint.RuleSetting) error { + return applyFullStopArg(&r.Char, r.Name(), s) +} + +func (r *HeaderFullStopRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + if !strings.HasSuffix(msg.Header(), r.Char) { + return nil, true + } + return lint.NewIssue(fmt.Sprintf("header must not end with %q", r.Char)), false +} + +// BodyFullStopRule checks that the body does NOT end with a given character. +type BodyFullStopRule struct{ Char string } + +func (r *BodyFullStopRule) Name() string { return "body-full-stop" } +func (r *BodyFullStopRule) Apply(s lint.RuleSetting) error { + return applyFullStopArg(&r.Char, r.Name(), s) +} + +func (r *BodyFullStopRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + body := msg.Body() + if body == "" { + return nil, true + } + if !strings.HasSuffix(body, r.Char) { + return nil, true + } + return lint.NewIssue(fmt.Sprintf("body must not end with %q", r.Char)), false +} + +// DescriptionFullStopRule checks that the description does NOT end with a given character. +type DescriptionFullStopRule struct{ Char string } + +func (r *DescriptionFullStopRule) Name() string { return "description-full-stop" } +func (r *DescriptionFullStopRule) Apply(s lint.RuleSetting) error { + return applyFullStopArg(&r.Char, r.Name(), s) +} + +func (r *DescriptionFullStopRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + desc := msg.Description() + if desc == "" { + return nil, true + } + if !strings.HasSuffix(desc, r.Char) { + return nil, true + } + return lint.NewIssue(fmt.Sprintf("description must not end with %q", r.Char)), false +} diff --git a/rule/header_trim_rule.go b/rule/header_trim_rule.go new file mode 100644 index 0000000..94e838b --- /dev/null +++ b/rule/header_trim_rule.go @@ -0,0 +1,23 @@ +package rule + +import ( + "strings" + + "github.com/conventionalcommit/commitlint/lint" +) + +// Compile-time interface checks +var _ lint.Rule = (*HeaderTrimRule)(nil) + +// HeaderTrimRule checks that the header has no leading or trailing whitespace. +type HeaderTrimRule struct{} + +func (r *HeaderTrimRule) Name() string { return "header-trim" } +func (r *HeaderTrimRule) Apply(_ lint.RuleSetting) error { return nil } +func (r *HeaderTrimRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + h := msg.Header() + if h == strings.TrimSpace(h) { + return nil, true + } + return lint.NewIssue("header must not have leading or trailing whitespace"), false +} diff --git a/rule/leading_blank_rules.go b/rule/leading_blank_rules.go new file mode 100644 index 0000000..ad0ed5b --- /dev/null +++ b/rule/leading_blank_rules.go @@ -0,0 +1,65 @@ +package rule + +import ( + "strings" + + "github.com/conventionalcommit/commitlint/lint" +) + +// Compile-time interface checks +var ( + _ lint.Rule = (*BodyLeadingBlankRule)(nil) + _ lint.Rule = (*FooterLeadingBlankRule)(nil) +) + +// BodyLeadingBlankRule checks that when a body is present, it begins with a blank line +// (i.e. the raw commit message has an empty line between header and body). +// Per the Conventional Commits spec the parser already trims the leading blank, but we +// can detect its presence via the full commit Message(): the body section should start +// after two newlines (header + blank line + body). +type BodyLeadingBlankRule struct{} + +func (r *BodyLeadingBlankRule) Name() string { return "body-leading-blank" } +func (r *BodyLeadingBlankRule) Apply(_ lint.RuleSetting) error { return nil } +func (r *BodyLeadingBlankRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + body := msg.Body() + if body == "" { + return nil, true + } + // The full message should have "\n\n" separating header from body. + raw := msg.Message() + headerEnd := strings.Index(raw, "\n") + if headerEnd == -1 { + // No newline at all, body is non-empty but raw message has no newline; + // treat as no leading blank. + return lint.NewIssue("body must have a leading blank line"), false + } + rest := raw[headerEnd:] + if strings.HasPrefix(rest, "\n\n") { + return nil, true + } + return lint.NewIssue("body must have a leading blank line"), false +} + +// FooterLeadingBlankRule checks that when a footer is present, it begins with a blank line +// (i.e. there is an empty line between body/header and footer). +type FooterLeadingBlankRule struct{} + +func (r *FooterLeadingBlankRule) Name() string { return "footer-leading-blank" } +func (r *FooterLeadingBlankRule) Apply(_ lint.RuleSetting) error { return nil } +func (r *FooterLeadingBlankRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + footer := msg.Footer() + if footer == "" { + return nil, true + } + raw := msg.Message() + // Footer should be preceded by "\n\n" + footerIdx := strings.LastIndex(raw, footer) + if footerIdx < 2 { + return lint.NewIssue("footer must have a leading blank line"), false + } + if raw[footerIdx-2:footerIdx] == "\n\n" { + return nil, true + } + return lint.NewIssue("footer must have a leading blank line"), false +} diff --git a/rule/trailer_rules.go b/rule/trailer_rules.go new file mode 100644 index 0000000..85261af --- /dev/null +++ b/rule/trailer_rules.go @@ -0,0 +1,64 @@ +package rule + +import ( + "strings" + + "github.com/conventionalcommit/commitlint/lint" +) + +// Compile-time interface checks. +var ( + _ lint.Rule = (*SignedOffByRule)(nil) + _ lint.Rule = (*TrailerExistsRule)(nil) +) + +// SignedOffByRule checks that at least one footer note token matches the +// configured value (default "Signed-off-by"). +type SignedOffByRule struct{ Value string } + +func (r *SignedOffByRule) Name() string { return "signed-off-by" } +func (r *SignedOffByRule) Apply(s lint.RuleSetting) error { + if err := applyStringArg(&r.Value, r.Name(), s); err != nil { + return err + } + // Normalize: strip a trailing ":" so both "Signed-off-by:" and + // "Signed-off-by" match note tokens returned by the parser. + r.Value = strings.TrimSuffix(strings.TrimSpace(r.Value), ":") + return nil +} + +func (r *SignedOffByRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + for _, note := range msg.Notes() { + if note.Token() == r.Value { + return nil, true + } + } + return lint.NewIssue("message must contain trailer " + r.Value), false +} + +// TrailerExistsRule checks that at least one footer note has a token matching +// the configured value. +// +// This is a generalized version of signed-off-by: any trailer token can be +// required. +type TrailerExistsRule struct{ Value string } + +func (r *TrailerExistsRule) Name() string { return "trailer-exists" } +func (r *TrailerExistsRule) Apply(s lint.RuleSetting) error { + if err := applyStringArg(&r.Value, r.Name(), s); err != nil { + return err + } + // Normalize: strip a trailing ":" so both "Co-authored-by:" and + // "Co-authored-by" match note tokens returned by the parser. + r.Value = strings.TrimSuffix(strings.TrimSpace(r.Value), ":") + return nil +} + +func (r *TrailerExistsRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + for _, note := range msg.Notes() { + if note.Token() == r.Value { + return nil, true + } + } + return lint.NewIssue("message must contain trailer " + r.Value), false +} diff --git a/test/rule_test.go b/test/rule_test.go index f0f88ce..53b3e17 100644 --- a/test/rule_test.go +++ b/test/rule_test.go @@ -3,6 +3,7 @@ package test import ( "testing" + "github.com/conventionalcommit/commitlint/internal/casing" "github.com/conventionalcommit/commitlint/lint" "github.com/conventionalcommit/commitlint/rule" ) @@ -552,3 +553,797 @@ func TestIssue_NoInfos(t *testing.T) { t.Errorf("expected 0 infos, got %d", len(issue.Infos())) } } + +// ============================================================ +// Case rules +// ============================================================ + +func TestTypeCaseRule_LowerPass(t *testing.T) { + r := &rule.TypeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "feat"}) + if !ok { + t.Error("lowercase type should pass lower-case rule") + } +} + +func TestTypeCaseRule_LowerFail(t *testing.T) { + r := &rule.TypeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "Feat"}) + if ok { + t.Error("mixed-case type should fail lower-case rule") + } +} + +func TestTypeCaseRule_UpperPass(t *testing.T) { + r := &rule.TypeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Upper}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "FEAT"}) + if !ok { + t.Error("uppercase type should pass upper-case rule") + } +} + +func TestTypeCaseRule_UpperFail(t *testing.T) { + r := &rule.TypeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Upper}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "feat"}) + if ok { + t.Error("lowercase type should fail upper-case rule") + } +} + +func TestTypeCaseRule_CamelPass(t *testing.T) { + r := &rule.TypeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Camel}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "myType"}) + if !ok { + t.Error("camelCase type should pass") + } +} + +func TestTypeCaseRule_PascalPass(t *testing.T) { + r := &rule.TypeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Pascal}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "MyType"}) + if !ok { + t.Error("PascalCase type should pass") + } +} + +func TestTypeCaseRule_KebabPass(t *testing.T) { + r := &rule.TypeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Kebab}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "my-type"}) + if !ok { + t.Error("kebab-case type should pass") + } +} + +func TestTypeCaseRule_SnakePass(t *testing.T) { + r := &rule.TypeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Snake}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "my_type"}) + if !ok { + t.Error("snake_case type should pass") + } +} + +func TestTypeCaseRule_SentencePass(t *testing.T) { + r := &rule.TypeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Sentence}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "Feat"}) + if !ok { + t.Error("Sentence case type should pass") + } +} + +func TestTypeCaseRule_StartPass(t *testing.T) { + r := &rule.TypeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Start}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "My Type"}) + if !ok { + t.Error("Start Case type should pass") + } +} + +func TestTypeCaseRule_BadCaseArg(t *testing.T) { + r := &rule.TypeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: "unknown-case"}); err == nil { + t.Error("unknown case should return error") + } +} + +func TestTypeCaseRule_BadArgType(t *testing.T) { + r := &rule.TypeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: 42}); err == nil { + t.Error("non-string arg should return error") + } +} + +func TestScopeCaseRule_LowerPass(t *testing.T) { + r := &rule.ScopeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{scope: "auth"}) + if !ok { + t.Error("lowercase scope should pass") + } +} + +func TestScopeCaseRule_EmptyScopeAlwaysPasses(t *testing.T) { + r := &rule.ScopeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{scope: ""}) + if !ok { + t.Error("empty scope should always pass scope-case") + } +} + +func TestScopeCaseRule_Fail(t *testing.T) { + r := &rule.ScopeCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{scope: "Auth"}) + if ok { + t.Error("uppercase scope should fail lower-case rule") + } +} + +func TestDescriptionCaseRule_LowerPass(t *testing.T) { + r := &rule.DescriptionCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{description: "add new feature"}) + if !ok { + t.Error("lowercase description should pass") + } +} + +func TestDescriptionCaseRule_Fail(t *testing.T) { + r := &rule.DescriptionCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{description: "Add new feature"}) + if ok { + t.Error("capitalized description should fail lower-case rule") + } +} + +func TestBodyCaseRule_LowerPass(t *testing.T) { + r := &rule.BodyCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: "this is the body"}) + if !ok { + t.Error("lowercase body should pass") + } +} + +func TestBodyCaseRule_EmptyBodyAlwaysPasses(t *testing.T) { + r := &rule.BodyCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: ""}) + if !ok { + t.Error("empty body should pass body-case") + } +} + +func TestBodyCaseRule_Fail(t *testing.T) { + r := &rule.BodyCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: "This is the body"}) + if ok { + t.Error("capitalized body should fail lower-case rule") + } +} + +func TestBodyCaseRule_MultiLine_AllFail(t *testing.T) { + r := &rule.BodyCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + // Entire body fails lower-case (uppercase letters present) + _, ok := r.Validate(&mockCommit{body: "First line capitalized\nSecond line capitalized"}) + if ok { + t.Error("body with uppercase letters should fail lower-case rule") + } +} + +func TestBodyCaseRule_MultiLine_SomeFail(t *testing.T) { + r := &rule.BodyCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + // Whole body contains uppercase, so the entire body fails + _, ok := r.Validate(&mockCommit{body: "first line is good\nSecond line is bad"}) + if ok { + t.Error("body containing an uppercase letter should fail lower-case rule") + } +} + +func TestBodyCaseRule_MultiLine_BlankLineSkipped(t *testing.T) { + r := &rule.BodyCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + // All non-empty content is lowercase; blank line is fine + _, ok := r.Validate(&mockCommit{body: "first line\n\nsecond line"}) + if !ok { + t.Error("all-lowercase body with blank separator should pass lower-case rule") + } +} + +func TestHeaderCaseRule_LowerPass(t *testing.T) { + r := &rule.HeaderCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: "feat: add feature"}) + if !ok { + t.Error("lowercase header should pass") + } +} + +func TestHeaderCaseRule_Fail(t *testing.T) { + r := &rule.HeaderCaseRule{} + if err := r.Apply(lint.RuleSetting{Argument: casing.Lower}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: "Feat: Add feature"}) + if ok { + t.Error("capitalized header should fail lower-case rule") + } +} + +// ============================================================ +// Empty rules +// ============================================================ + +func TestTypeEmptyRule_NonEmptyPasses(t *testing.T) { + r := &rule.TypeEmptyRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "feat"}) + if !ok { + t.Error("non-empty type should pass type-empty rule") + } +} + +func TestTypeEmptyRule_EmptyFails(t *testing.T) { + r := &rule.TypeEmptyRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + issue, ok := r.Validate(&mockCommit{typ: ""}) + if ok { + t.Error("empty type should fail type-empty rule") + } + if issue == nil { + t.Error("expected non-nil issue") + } +} + +func TestScopeEmptyRule_NonEmptyPasses(t *testing.T) { + r := &rule.ScopeEmptyRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{scope: "auth"}) + if !ok { + t.Error("non-empty scope should pass scope-empty rule") + } +} + +func TestScopeEmptyRule_EmptyFails(t *testing.T) { + r := &rule.ScopeEmptyRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{scope: ""}) + if ok { + t.Error("empty scope should fail scope-empty rule") + } +} + +func TestBodyEmptyRule_NonEmptyPasses(t *testing.T) { + r := &rule.BodyEmptyRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: "some body"}) + if !ok { + t.Error("non-empty body should pass body-empty rule") + } +} + +func TestBodyEmptyRule_EmptyFails(t *testing.T) { + r := &rule.BodyEmptyRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: ""}) + if ok { + t.Error("empty body should fail body-empty rule") + } +} + +func TestFooterEmptyRule_NonEmptyPasses(t *testing.T) { + r := &rule.FooterEmptyRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{footer: "Fixes: #123"}) + if !ok { + t.Error("non-empty footer should pass footer-empty rule") + } +} + +func TestFooterEmptyRule_EmptyFails(t *testing.T) { + r := &rule.FooterEmptyRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{footer: ""}) + if ok { + t.Error("empty footer should fail footer-empty rule") + } +} + +func TestDescriptionEmptyRule_NonEmptyPasses(t *testing.T) { + r := &rule.DescriptionEmptyRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{description: "add new feature"}) + if !ok { + t.Error("non-empty description should pass description-empty rule") + } +} + +func TestDescriptionEmptyRule_EmptyFails(t *testing.T) { + r := &rule.DescriptionEmptyRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{description: ""}) + if ok { + t.Error("empty description should fail description-empty rule") + } +} + +// ============================================================ +// Full-stop rules +// ============================================================ + +func TestHeaderFullStop_NoStop_Pass(t *testing.T) { + r := &rule.HeaderFullStopRule{} + if err := r.Apply(lint.RuleSetting{Argument: "."}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: "feat: add new feature"}) + if !ok { + t.Error("header not ending with '.' should pass") + } +} + +func TestHeaderFullStop_WithStop_Fail(t *testing.T) { + r := &rule.HeaderFullStopRule{} + if err := r.Apply(lint.RuleSetting{Argument: "."}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: "feat: add new feature."}) + if ok { + t.Error("header ending with '.' should fail") + } +} + +func TestHeaderFullStop_CustomChar(t *testing.T) { + r := &rule.HeaderFullStopRule{} + if err := r.Apply(lint.RuleSetting{Argument: "!"}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: "feat: urgent!"}) + if ok { + t.Error("header ending with '!' should fail") + } + _, ok2 := r.Validate(&mockCommit{header: "feat: normal"}) + if !ok2 { + t.Error("header not ending with '!' should pass") + } +} + +func TestHeaderFullStop_BadArg(t *testing.T) { + r := &rule.HeaderFullStopRule{} + if err := r.Apply(lint.RuleSetting{Argument: 99}); err == nil { + t.Error("non-string arg should return error") + } +} + +func TestBodyFullStop_NoStop_Pass(t *testing.T) { + r := &rule.BodyFullStopRule{} + if err := r.Apply(lint.RuleSetting{Argument: "."}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: "This is the body"}) + if !ok { + t.Error("body not ending with '.' should pass") + } +} + +func TestBodyFullStop_WithStop_Fail(t *testing.T) { + r := &rule.BodyFullStopRule{} + if err := r.Apply(lint.RuleSetting{Argument: "."}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: "This is the body."}) + if ok { + t.Error("body ending with '.' should fail") + } +} + +func TestBodyFullStop_EmptyBody_Pass(t *testing.T) { + r := &rule.BodyFullStopRule{} + if err := r.Apply(lint.RuleSetting{Argument: "."}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: ""}) + if !ok { + t.Error("empty body should pass body-full-stop") + } +} + +func TestDescriptionFullStop_NoStop_Pass(t *testing.T) { + r := &rule.DescriptionFullStopRule{} + if err := r.Apply(lint.RuleSetting{Argument: "."}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{description: "add new feature"}) + if !ok { + t.Error("description not ending with '.' should pass") + } +} + +func TestDescriptionFullStop_WithStop_Fail(t *testing.T) { + r := &rule.DescriptionFullStopRule{} + if err := r.Apply(lint.RuleSetting{Argument: "."}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{description: "add new feature."}) + if ok { + t.Error("description ending with '.' should fail") + } +} + +func TestDescriptionFullStop_EmptyDescription_Pass(t *testing.T) { + r := &rule.DescriptionFullStopRule{} + if err := r.Apply(lint.RuleSetting{Argument: "."}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{description: ""}) + if !ok { + t.Error("empty description should pass description-full-stop") + } +} + +// ============================================================ +// Leading-blank rules +// ============================================================ + +func TestBodyLeadingBlank_WithBlank_Pass(t *testing.T) { + r := &rule.BodyLeadingBlankRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + // message: header + blank line + body + msg := &mockCommit{ + message: "feat: add feature\n\nThis is the body", + body: "This is the body", + } + _, ok := r.Validate(msg) + if !ok { + t.Error("body with leading blank line should pass") + } +} + +func TestBodyLeadingBlank_WithoutBlank_Fail(t *testing.T) { + r := &rule.BodyLeadingBlankRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + msg := &mockCommit{ + message: "feat: add feature\nThis is the body", + body: "This is the body", + } + _, ok := r.Validate(msg) + if ok { + t.Error("body without leading blank line should fail") + } +} + +func TestBodyLeadingBlank_EmptyBody_Pass(t *testing.T) { + r := &rule.BodyLeadingBlankRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: ""}) + if !ok { + t.Error("empty body should pass body-leading-blank") + } +} + +func TestFooterLeadingBlank_WithBlank_Pass(t *testing.T) { + r := &rule.FooterLeadingBlankRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + msg := &mockCommit{ + message: "feat: add feature\n\nbody text\n\nFixes: #123", + footer: "Fixes: #123", + } + _, ok := r.Validate(msg) + if !ok { + t.Error("footer with leading blank should pass") + } +} + +func TestFooterLeadingBlank_WithoutBlank_Fail(t *testing.T) { + r := &rule.FooterLeadingBlankRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + msg := &mockCommit{ + message: "feat: add feature\nbody text\nFixes: #123", + footer: "Fixes: #123", + } + _, ok := r.Validate(msg) + if ok { + t.Error("footer without leading blank should fail") + } +} + +func TestFooterLeadingBlank_EmptyFooter_Pass(t *testing.T) { + r := &rule.FooterLeadingBlankRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{footer: ""}) + if !ok { + t.Error("empty footer should pass footer-leading-blank") + } +} + +// ============================================================ +// Header-trim rule +// ============================================================ + +func TestHeaderTrim_Clean_Pass(t *testing.T) { + r := &rule.HeaderTrimRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: "feat: add feature"}) + if !ok { + t.Error("clean header should pass header-trim") + } +} + +func TestHeaderTrim_LeadingSpace_Fail(t *testing.T) { + r := &rule.HeaderTrimRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: " feat: add feature"}) + if ok { + t.Error("header with leading space should fail header-trim") + } +} + +func TestHeaderTrim_TrailingSpace_Fail(t *testing.T) { + r := &rule.HeaderTrimRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: "feat: add feature "}) + if ok { + t.Error("header with trailing space should fail header-trim") + } +} + +func TestHeaderTrim_BothSpaces_Fail(t *testing.T) { + r := &rule.HeaderTrimRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: " feat: add feature "}) + if ok { + t.Error("header with both-sides whitespace should fail header-trim") + } +} + +// ============================================================ +// Signed-off-by and trailer-exists rules +// ============================================================ + +func TestSignedOffBy_Present_Pass(t *testing.T) { + r := &rule.SignedOffByRule{} + if err := r.Apply(lint.RuleSetting{Argument: "Signed-off-by:"}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{notes: []lint.Note{&mockNote{token: "Signed-off-by", value: "Jane Doe "}}}) + if !ok { + t.Error("message with Signed-off-by should pass") + } +} + +func TestSignedOffBy_NoColon_Present_Pass(t *testing.T) { + r := &rule.SignedOffByRule{} + if err := r.Apply(lint.RuleSetting{Argument: "Signed-off-by"}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{notes: []lint.Note{&mockNote{token: "Signed-off-by", value: "Jane Doe "}}}) + if !ok { + t.Error("message with Signed-off-by should pass") + } +} + +func TestSignedOffBy_Missing_Fail(t *testing.T) { + r := &rule.SignedOffByRule{} + if err := r.Apply(lint.RuleSetting{Argument: "Signed-off-by:"}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{}) + if ok { + t.Error("commit without Signed-off-by note should fail") + } +} + +func TestSignedOffBy_NoColon_Missing_Fail(t *testing.T) { + r := &rule.SignedOffByRule{} + if err := r.Apply(lint.RuleSetting{Argument: "Signed-off-by"}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{}) + if ok { + t.Error("commit without Signed-off-by note should fail") + } +} + +func TestSignedOffBy_BadArg(t *testing.T) { + r := &rule.SignedOffByRule{} + if err := r.Apply(lint.RuleSetting{Argument: 99}); err == nil { + t.Error("non-string arg should return error") + } +} + +func TestTrailerExists_Present_Pass(t *testing.T) { + r := &rule.TrailerExistsRule{} + if err := r.Apply(lint.RuleSetting{Argument: "Co-authored-by:"}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{notes: []lint.Note{&mockNote{token: "Co-authored-by", value: "Bob "}}}) + if !ok { + t.Error("commit with Co-authored-by note should pass") + } +} + +func TestTrailerExists_Missing_Fail(t *testing.T) { + r := &rule.TrailerExistsRule{} + if err := r.Apply(lint.RuleSetting{Argument: "Co-authored-by:"}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{}) + if ok { + t.Error("commit without Co-authored-by note should fail") + } +} + +// ============================================================ +// Breaking-change-exclamation-mark rule +// ============================================================ + +func TestBreakingChangeExclamation_BothPresent_Pass(t *testing.T) { + r := &rule.BreakingChangeExclamationMarkRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + msg := &mockCommit{ + breaking: true, + notes: []lint.Note{&mockNote{token: "BREAKING CHANGE", value: "removed old endpoint"}}, + } + _, ok := r.Validate(msg) + if !ok { + t.Error("both '!' in header AND BREAKING CHANGE in footer should pass") + } +} + +func TestBreakingChangeExclamation_NeitherPresent_Pass(t *testing.T) { + r := &rule.BreakingChangeExclamationMarkRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + msg := &mockCommit{ + breaking: false, + } + _, ok := r.Validate(msg) + if !ok { + t.Error("neither '!' nor BREAKING CHANGE should pass (XNOR)") + } +} + +func TestBreakingChangeExclamation_OnlyExclamation_Fail(t *testing.T) { + r := &rule.BreakingChangeExclamationMarkRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + msg := &mockCommit{ + breaking: true, + } + _, ok := r.Validate(msg) + if ok { + t.Error("'!' in header without BREAKING CHANGE in footer should fail") + } +} + +func TestBreakingChangeExclamation_OnlyFooter_Fail(t *testing.T) { + r := &rule.BreakingChangeExclamationMarkRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + msg := &mockCommit{ + breaking: false, + notes: []lint.Note{&mockNote{token: "BREAKING CHANGE", value: "some change"}}, + } + _, ok := r.Validate(msg) + if ok { + t.Error("BREAKING CHANGE in footer without '!' in header should fail") + } +} + +func TestBreakingChangeExclamation_BreakingDashChange_Pass(t *testing.T) { + r := &rule.BreakingChangeExclamationMarkRule{} + if err := r.Apply(lint.RuleSetting{}); err != nil { + t.Fatal(err) + } + msg := &mockCommit{ + breaking: true, + notes: []lint.Note{&mockNote{token: "BREAKING-CHANGE", value: "some change"}}, + } + _, ok := r.Validate(msg) + if !ok { + t.Error("BREAKING-CHANGE (with dash) in footer with '!' should pass") + } +} From af7f9a7dee205284a6d05e3530151ec9b07b2ca4 Mon Sep 17 00:00:00 2001 From: muthukrishnan24 Date: Sun, 22 Feb 2026 11:24:35 +0530 Subject: [PATCH 5/7] docs: update README --- README.md | 178 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 131 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index a97c1d6..d027079 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,8 @@ commitlint checks if your commit message meets the [conventional commit format]( - [Commands](#commands) - [config](#config) - [lint](#lint) - - [Precedence](#precedence) - - [Config](#config-1) - - [Message](#message) + - [Config Precedence](#config-precedence) + - [Message Precedence](#message-precedence) - [hook](#hook) - [debug](#debug) - [Default Config](#default-config) @@ -40,6 +39,16 @@ commitlint checks if your commit message meets the [conventional commit format]( - [Custom Ignore Patterns](#custom-ignore-patterns) - [Disabling Default Ignores](#disabling-default-ignores) - [Available Rules](#available-rules) + - [Length rules](#length-rules) + - [Enum / allow-list rules](#enum--allow-list-rules) + - [Charset rules](#charset-rules) + - [Case rules](#case-rules) + - [Empty / presence rules](#empty--presence-rules) + - [Full-stop rules](#full-stop-rules) + - [Leading-blank rules](#leading-blank-rules) + - [Header formatting rules](#header-formatting-rules) + - [Trailer / sign-off rules](#trailer--sign-off-rules) + - [Breaking change rules](#breaking-change-rules) - [Available Formatters](#available-formatters) - [Extensibility](#extensibility) - [FAQ](#faq) @@ -136,11 +145,9 @@ To lint a message, you can use any one of the following - run `echo "message" | commitlint lint` - run `commitlint lint < file` -#### Precedence - `commitlint lint` follows below order for `config` and `message` -##### Config +#### Config Precedence - config file passed to `--config` command-line argument - `COMMITLINT_CONFIG` env variable @@ -151,7 +158,7 @@ To lint a message, you can use any one of the following - commitlint.yaml - [default config](#default-config) -##### Message +#### Message Precedence - `stdin` pipe stream - commit message file passed to `--message` command-line argument @@ -219,7 +226,7 @@ ignores: [] Commonly used commit types from [Conventional Commit Types](https://github.com/commitizen/conventional-commit-types) | Type | Description | -| :------- | :------------------------------------------------------------------------------- | +|:---------|:---------------------------------------------------------------------------------| | feat | A new feature | | fix | A bug fix | | docs | Documentation only changes | @@ -242,21 +249,21 @@ If the **first line** of a commit message matches any ignore pattern, linting is The following patterns are enabled by default (source: [`config/default.go`](config/default.go)): -| Pattern | Matches | -| :--- | :--- | -| `^Merge pull request #\d+` | GitHub pull request merges | -| `^Merge .+ into .+` | Generic merge (X into Y) | -| `^Merge branch '.+'` | `git merge` branch | -| `^Merge tag '.+'` | `git merge` tag | -| `^Merge remote-tracking branch '.+'` | `git merge` remote-tracking branch | -| `^Merged .+ (in\|into) .+` | Azure DevOps / Bitbucket merged | -| `^Merged PR #?\d+` | Azure DevOps pull request | -| `^(R\|r)evert ` | `git revert` | -| `^(R\|r)eapply ` | `git reapply` | -| `^(amend\|fixup\|squash)! ` | `git commit --fixup/--squash/--amend` | -| `^Automatic merge` | Automatic merges | -| `^Auto-merged .+ into .+` | Auto-merged branches | -| `^Initial commit$` | Initial commit (exact match) | +| Pattern | Matches | +|:-------------------------------------|:--------------------------------------| +| `^Merge pull request #\d+` | GitHub pull request merges | +| `^Merge .+ into .+` | Generic merge (X into Y) | +| `^Merge branch '.+'` | `git merge` branch | +| `^Merge tag '.+'` | `git merge` tag | +| `^Merge remote-tracking branch '.+'` | `git merge` remote-tracking branch | +| `^Merged .+ (in\|into) .+` | Azure DevOps / Bitbucket merged | +| `^Merged PR #?\d+` | Azure DevOps pull request | +| `^(R\|r)evert ` | `git revert` | +| `^(R\|r)eapply ` | `git reapply` | +| `^(amend\|fixup\|squash)! ` | `git commit --fixup/--squash/--amend` | +| `^Automatic merge` | Automatic merges | +| `^Auto-merged .+ into .+` | Auto-merged branches | +| `^Initial commit$` | Initial commit (exact match) | ### Custom Ignore Patterns @@ -281,30 +288,107 @@ ignores: ## Available Rules -The list of available lint rules - -| name | argument | flags | description | -| ---------------------- | ------------------------ | ----------------- | --------------------------------------------- | -| header-min-length | int | n/a | checks the min length of header (first line) | -| header-max-length | int | n/a | checks the max length of header (first line) | -| body-max-line-length | int | n/a | checks the max length of each line in body | -| footer-max-line-length | int | n/a | checks the max length of each line in footer | -| type-enum | []string | n/a | restrict type to given list of string | -| scope-enum | []string | allow-empty: bool | restrict scope to given list of string | -| footer-enum | []string | n/a | restrict footer token to given list of string | -| type-min-length | int | n/a | checks the min length of type | -| type-max-length | int | n/a | checks the max length of type | -| scope-min-length | int | n/a | checks the min length of scope | -| scope-max-length | int | n/a | checks the max length of scope | -| description-min-length | int | n/a | checks the min length of description | -| description-max-length | int | n/a | checks the max length of description | -| body-min-length | int | n/a | checks the min length of body | -| body-max-length | int | n/a | checks the max length of body | -| footer-min-length | int | n/a | checks the min length of footer | -| footer-max-length | int | n/a | checks the max length of footer | -| type-charset | string | n/a | restricts type to given charset | -| scope-charset | string | n/a | restricts scope to given charset | -| footer-type-enum | []{token, types, values} | n/a | enforces footer notes for given type | +Rules marked **✅ enabled** are active by default. All others can be opted into via the `rules:` list in your config. + +### Length rules + +| name | argument | flags | description | default | +|:-------------------------|:---------|:------|:----------------------------------|:----------------| +| `header-min-length` | int | n/a | min length of header (first line) | ✅ enabled (10) | +| `header-max-length` | int | n/a | max length of header (first line) | ✅ enabled (72) | +| `body-min-length` | int | n/a | min length of body | N/A | +| `body-max-length` | int | n/a | max length of body | N/A | +| `body-max-line-length` | int | n/a | max length of each line in body | ✅ enabled (100) | +| `footer-min-length` | int | n/a | min length of footer | N/A | +| `footer-max-length` | int | n/a | max length of footer | N/A | +| `footer-max-line-length` | int | n/a | max length of each line in footer | ✅ enabled (100) | +| `type-min-length` | int | n/a | min length of type | N/A | +| `type-max-length` | int | n/a | max length of type | N/A | +| `scope-min-length` | int | n/a | min length of scope | N/A | +| `scope-max-length` | int | n/a | max length of scope | N/A | +| `description-min-length` | int | n/a | min length of description | N/A | +| `description-max-length` | int | n/a | max length of description | N/A | + +### Enum / allow-list rules + +| name | argument | flags | description | default | +|:-------------------|:---------------------------|:--------------------|:----------------------------------------|:----------| +| `type-enum` | `[]string` | n/a | restrict type to given list of strings | ✅ enabled | +| `scope-enum` | `[]string` | `allow-empty: bool` | restrict scope to given list of strings | N/A | +| `footer-enum` | `[]string` | n/a | restrict footer token to given list | N/A | +| `footer-type-enum` | `[]{token, types, values}` | n/a | enforce footer notes for given type | N/A | + +### Charset rules + +| name | argument | flags | description | default | +|:----------------|:---------|:------|:--------------------------------|:--------| +| `type-charset` | string | n/a | restrict type to given charset | N/A | +| `scope-charset` | string | n/a | restrict scope to given charset | N/A | + +### Case rules + +All case rules accept one of: `lower-case`, `upper-case`, `camel-case`, `kebab-case`, `pascal-case`, `sentence-case`, `snake-case`, `start-case`. + +| name | argument | flags | description | default | +|:-------------------|:---------|:------|:-------------------------------------------|:--------| +| `type-case` | string | n/a | enforce case format on type | N/A | +| `scope-case` | string | n/a | enforce case format on scope (skips empty) | N/A | +| `description-case` | string | n/a | enforce case format on description | N/A | +| `body-case` | string | n/a | enforce case format on entire body | N/A | +| `header-case` | string | n/a | enforce case format on full header | N/A | + +### Empty / presence rules + +These rules enforce that a field is **not empty**. + +| name | argument | flags | description | default | +|:--------------------|:---------|:------|:------------------------------|:--------| +| `type-empty` | n/a | n/a | type must not be empty | N/A | +| `scope-empty` | n/a | n/a | scope must not be empty | N/A | +| `body-empty` | n/a | n/a | body must not be empty | N/A | +| `footer-empty` | n/a | n/a | footer must not be empty | N/A | +| `description-empty` | n/a | n/a | description must not be empty | N/A | + +### Full-stop rules + +Check that a field does **not** end with a given character (default `"."`). + +| name | argument | flags | description | default | +|:------------------------|:---------|:------|:-------------------------------------------------|:--------| +| `header-full-stop` | string | n/a | header must not end with given char (e.g. `"."`) | N/A | +| `body-full-stop` | string | n/a | body must not end with given char | N/A | +| `description-full-stop` | string | n/a | description must not end with given char | N/A | + +### Leading-blank rules + +Enforce that a blank line separates commit sections (conventional commits spec). + +| name | argument | flags | description | default | +|:-----------------------|:---------|:------|:----------------------------------------|:--------| +| `body-leading-blank` | n/a | n/a | body must be preceded by a blank line | N/A | +| `footer-leading-blank` | n/a | n/a | footer must be preceded by a blank line | N/A | + +### Header formatting rules + +| name | argument | flags | description | default | +|:--------------|:---------|:------|:----------------------------------------------------|:--------| +| `header-trim` | n/a | n/a | header must not have leading or trailing whitespace | N/A | + +### Trailer / sign-off rules + +The argument is the trailer token. A trailing `:` is accepted and stripped automatically, +so `"Signed-off-by"` and `"Signed-off-by:"` are equivalent. + +| name | argument | flags | description | default | +|:-----------------|:---------|:------|:---------------------------------------------------------------------|:--------| +| `signed-off-by` | string | n/a | commit must have a footer note whose token matches (e.g. `"Signed-off-by"`) | N/A | +| `trailer-exists` | string | n/a | commit must have a footer note whose token matches (e.g. `"Co-authored-by"`) | N/A | + +### Breaking change rules + +| name | argument | flags | description | default | +|:-----------------------------------|:---------|:------|:---------------------------------------------------------------------------------------------------------------|:--------| +| `breaking-change-exclamation-mark` | n/a | n/a | XNOR: either both `!` in header and `BREAKING CHANGE` in footer are present, or neither N/A not just one alone | N/A | ## Available Formatters From 26c58b2658ac893cad054c0403a13a8b57e366f4 Mon Sep 17 00:00:00 2001 From: muthukrishnan24 Date: Sun, 22 Feb 2026 11:25:08 +0530 Subject: [PATCH 6/7] test: update the test case name --- test/config_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/config_test.go b/test/config_test.go index 1c60f76..7ce2f16 100644 --- a/test/config_test.go +++ b/test/config_test.go @@ -161,7 +161,7 @@ func TestConfig_Validate_ValidIgnorePattern(t *testing.T) { } } -func TestConfig_WriteTo(t *testing.T) { +func TestConfig_WriteCompactTo(t *testing.T) { conf := config.NewDefault() var buf bytes.Buffer err := config.WriteCompactTo(&buf, conf) @@ -170,7 +170,7 @@ func TestConfig_WriteTo(t *testing.T) { } output := buf.String() if output == "" { - t.Error("expected non-empty output from WriteTo") + t.Error("expected non-empty output from WriteCompactTo") } // Should only contain settings for the 5 enabled rules, not all 20 @@ -186,7 +186,7 @@ func TestConfig_WriteTo(t *testing.T) { } } -func TestConfig_WriteTo_WithUserIgnores(t *testing.T) { +func TestConfig_WriteCompactTo_WithUserIgnores(t *testing.T) { conf := config.NewDefault() conf.IgnorePatterns = []string{`^WIP `} var buf bytes.Buffer From cbc379cadff4bf80bdb621b556e617cca39e1029 Mon Sep 17 00:00:00 2001 From: muthukrishnan24 Date: Sun, 22 Feb 2026 13:35:44 +0530 Subject: [PATCH 7/7] feat: expose registry to define custom rules, formatters --- README.md | 237 +++++++++++++++++++- config/api.go | 15 ++ config/config.go | 2 +- config/default_test.go | 2 +- config/lint.go | 2 +- internal/registry/registry_test.go | 25 --- {internal/registry => registry}/registry.go | 23 +- registry/registry_test.go | 35 +++ 8 files changed, 301 insertions(+), 40 deletions(-) create mode 100644 config/api.go delete mode 100644 internal/registry/registry_test.go rename {internal/registry => registry}/registry.go (84%) create mode 100644 registry/registry_test.go diff --git a/README.md b/README.md index d027079..64a36c8 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,12 @@ commitlint checks if your commit message meets the [conventional commit format]( - [Trailer / sign-off rules](#trailer--sign-off-rules) - [Breaking change rules](#breaking-change-rules) - [Available Formatters](#available-formatters) - - [Extensibility](#extensibility) + - [Programmatic Usage](#programmatic-usage) + - [One-liner with default config](#one-liner-with-default-config) + - [Full control with default config](#full-control-with-default-config) + - [Lint with a config file](#lint-with-a-config-file) + - [Custom rules](#custom-rules) + - [Custom formatters](#custom-formatters) - [FAQ](#faq) - [License](#license) @@ -223,7 +228,7 @@ ignores: [] ### Commit Types -Commonly used commit types from [Conventional Commit Types](https://github.com/commitizen/conventional-commit-types) +Commonly used commit types | Type | Description | |:---------|:---------------------------------------------------------------------------------| @@ -411,13 +416,237 @@ Total 1 errors, 0 warnings, 0 other severities {"input":"fear: do not fear for commit message","issues":[{"description":"type 'fear' is not allowed, you can use one of [build chore ci docs feat fix perf refactor revert style test]","name":"type-enum","severity":"error"}]} ``` -## Extensibility +## Programmatic Usage + +All public packages are importable. The module path is `github.com/conventionalcommit/commitlint`. + +```bash +go get github.com/conventionalcommit/commitlint@latest +``` + +Key packages: + +| Package | Purpose | +|:--------|:--------| +| `config` | Parse config files, build a `Linter`, access defaults | +| `lint` | Core types: `Linter`, `Rule`, `Formatter`, `Config`, `Result`, `Issue` | +| `registry` | Register and look up custom rules / formatters | +| `rule` | Built-in rule implementations | +| `formatter` | Built-in formatters (`default`, `json`) | + +### One-liner with default config + +The simplest entry point — no config file required: + +```go +package main + +import ( + "fmt" + "github.com/conventionalcommit/commitlint/config" +) + +func main() { + result, err := config.LintMessage("feat: add login page") + if err != nil { + panic(err) + } + + for _, issue := range result.Issues() { + fmt.Printf("%s: %s: %s\n", issue.Severity(), issue.RuleName(), issue.Description()) + } + + if len(result.Issues()) == 0 { + fmt.Println("commit message is valid") + } +} +``` + +### Full control with default config + +Build the linter yourself for more control (e.g. to swap the formatter): + +```go +package main + +import ( + "fmt" + "github.com/conventionalcommit/commitlint/config" + "github.com/conventionalcommit/commitlint/formatter" +) + +func main() { + conf := config.NewDefault() + // optionally customise conf here + + linter, err := config.NewLinter(conf) + if err != nil { + panic(err) + } + + result, err := linter.ParseAndLint("feat: add login page") + if err != nil { + panic(err) + } + + out, err := (&formatter.JSONFormatter{}).Format(result) + if err != nil { + panic(err) + } + fmt.Println(out) +} +``` + +### Lint with a config file + +Load a `.commitlint.yaml` and lint against it: + +```go +package main + +import ( + "fmt" + "github.com/conventionalcommit/commitlint/config" +) + +func main() { + conf, err := config.Parse(".commitlint.yaml") + if err != nil { + panic(err) + } + + linter, err := config.NewLinter(conf) + if err != nil { + panic(err) + } + + result, err := linter.ParseAndLint("feat: add login page") + if err != nil { + panic(err) + } + + for _, issue := range result.Issues() { + fmt.Printf("%s: %s\n", issue.RuleName(), issue.Description()) + } +} +``` + +### Custom rules + +Implement the `lint.Rule` interface and register it before building a linter: + +```go +package main + +import ( + "fmt" + "github.com/conventionalcommit/commitlint/config" + "github.com/conventionalcommit/commitlint/lint" + "github.com/conventionalcommit/commitlint/registry" +) + +// NoWIPRule rejects commit messages whose description starts with "WIP". +type NoWIPRule struct{} + +func (r *NoWIPRule) Name() string { return "no-wip" } +func (r *NoWIPRule) Apply(setting lint.RuleSetting) error { return nil } +func (r *NoWIPRule) Validate(commit lint.Commit) (*lint.Issue, error) { + if len(commit.Description()) >= 3 && commit.Description()[:3] == "WIP" { + return lint.NewIssue("description must not start with WIP"), nil + } + return nil, nil +} + +func main() { + if err := registry.RegisterRule(&NoWIPRule{}); err != nil { + panic(err) + } + + conf := config.NewDefault() + conf.Rules = append(conf.Rules, "no-wip") + conf.Settings["no-wip"] = lint.RuleSetting{} + + linter, err := config.NewLinter(conf) + if err != nil { + panic(err) + } + + result, err := linter.ParseAndLint("feat: WIP do not merge") + if err != nil { + panic(err) + } + + for _, issue := range result.Issues() { + fmt.Printf("%s: %s\n", issue.RuleName(), issue.Description()) + } +} +``` + +### Custom formatters + +Implement `lint.Formatter` and register it: + +```go +package main + +import ( + "fmt" + "strings" + "github.com/conventionalcommit/commitlint/config" + "github.com/conventionalcommit/commitlint/lint" + "github.com/conventionalcommit/commitlint/registry" +) + +type SimpleFormatter struct{} + +func (f *SimpleFormatter) Name() string { return "simple" } +func (f *SimpleFormatter) Format(result *lint.Result) (string, error) { + if len(result.Issues()) == 0 { + return "ok", nil + } + var sb strings.Builder + for _, issue := range result.Issues() { + fmt.Fprintf(&sb, "[%s] %s: %s\n", issue.Severity(), issue.RuleName(), issue.Description()) + } + return sb.String(), nil +} + +func main() { + if err := registry.RegisterFormatter(&SimpleFormatter{}); err != nil { + panic(err) + } + + conf := config.NewDefault() + conf.Formatter = "simple" + + format, err := config.GetFormatter(conf) + if err != nil { + panic(err) + } + + linter, err := config.NewLinter(conf) + if err != nil { + panic(err) + } + + result, err := linter.ParseAndLint("bad message") + if err != nil { + panic(err) + } + + out, err := format.Format(result) + if err != nil { + panic(err) + } + fmt.Print(out) +} +``` ## FAQ - How to have custom config for each repository? - Place `.commitlint.yaml` file in repo root directory. linter follows [config precedence](#precedence). + Place `.commitlint.yaml` file in repo root directory. linter follows [config precedence](#config-precedence). To create a sample config, run `commitlint config create` (or `commitlint config create --all` to include all available settings) diff --git a/config/api.go b/config/api.go new file mode 100644 index 0000000..ae9a54e --- /dev/null +++ b/config/api.go @@ -0,0 +1,15 @@ +package config + +import "github.com/conventionalcommit/commitlint/lint" + +// LintMessage lints commitMsg using the default configuration. +// It is the simplest entry point for programmatic use: no config file is needed. +// +// For custom configuration use Parse or NewDefault, then NewLinter. +func LintMessage(commitMsg string) (*lint.Result, error) { + linter, err := NewLinter(NewDefault()) + if err != nil { + return nil, err + } + return linter.ParseAndLint(commitMsg) +} diff --git a/config/config.go b/config/config.go index 8583ebf..a1c58a2 100644 --- a/config/config.go +++ b/config/config.go @@ -14,8 +14,8 @@ import ( "github.com/conventionalcommit/commitlint/formatter" "github.com/conventionalcommit/commitlint/internal" - "github.com/conventionalcommit/commitlint/internal/registry" "github.com/conventionalcommit/commitlint/lint" + "github.com/conventionalcommit/commitlint/registry" ) // Parse parse given file in confPath, and return Config instance, error if any diff --git a/config/default_test.go b/config/default_test.go index 1356046..c8b68cd 100644 --- a/config/default_test.go +++ b/config/default_test.go @@ -3,7 +3,7 @@ package config import ( "testing" - "github.com/conventionalcommit/commitlint/internal/registry" + "github.com/conventionalcommit/commitlint/registry" ) func TestDefaultLint(t *testing.T) { diff --git a/config/lint.go b/config/lint.go index 03d949d..cfb9e40 100644 --- a/config/lint.go +++ b/config/lint.go @@ -3,8 +3,8 @@ package config import ( "fmt" - "github.com/conventionalcommit/commitlint/internal/registry" "github.com/conventionalcommit/commitlint/lint" + "github.com/conventionalcommit/commitlint/registry" ) // NewLinter returns Linter for given confFilePath diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go deleted file mode 100644 index 68c5579..0000000 --- a/internal/registry/registry_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package registry - -import "testing" - -func TestDefaultRules(t *testing.T) { - m := make(map[string]struct{}) - for _, r := range globalRegistry.Rules() { - _, ok := m[r.Name()] - if ok { - t.Errorf("error: %s rule name already exists", r.Name()) - } - m[r.Name()] = struct{}{} - } -} - -func TestDefaultFormatters(t *testing.T) { - m := make(map[string]struct{}) - for _, r := range globalRegistry.Formatters() { - _, ok := m[r.Name()] - if ok { - t.Errorf("error: %s formatter name already exists", r.Name()) - } - m[r.Name()] = struct{}{} - } -} diff --git a/internal/registry/registry.go b/registry/registry.go similarity index 84% rename from internal/registry/registry.go rename to registry/registry.go index b2446e6..7b854ec 100644 --- a/internal/registry/registry.go +++ b/registry/registry.go @@ -1,4 +1,6 @@ -// Package registry contains registered rules and formatters +// Package registry holds the global registry of rules and formatters. +// External packages can call RegisterRule and RegisterFormatter to extend +// commitlint with custom rules or formatters before building a linter. package registry import ( @@ -12,33 +14,38 @@ import ( var globalRegistry = newRegistry() -// RegisterRule registers a custom rule -// if rule already exists, returns error +// RegisterRule registers a custom rule. +// Returns an error if a rule with the same name is already registered. func RegisterRule(r lint.Rule) error { return globalRegistry.RegisterRule(r) } -// RegisterFormatter registers a custom formatter -// if formatter already exists, returns error +// RegisterFormatter registers a custom formatter. +// Returns an error if a formatter with the same name is already registered. func RegisterFormatter(format lint.Formatter) error { return globalRegistry.RegisterFormatter(format) } -// GetRule returns Rule with given name +// GetRule returns the Rule registered under name, and whether it was found. func GetRule(name string) (lint.Rule, bool) { return globalRegistry.GetRule(name) } -// GetFormatter returns Formatter with given name +// GetFormatter returns the Formatter registered under name, and whether it was found. func GetFormatter(name string) (lint.Formatter, bool) { return globalRegistry.GetFormatter(name) } -// Rules returns all registered rules +// Rules returns all registered rules. func Rules() []lint.Rule { return globalRegistry.Rules() } +// Formatters returns all registered formatters. +func Formatters() []lint.Formatter { + return globalRegistry.Formatters() +} + type registry struct { mut *sync.Mutex diff --git a/registry/registry_test.go b/registry/registry_test.go new file mode 100644 index 0000000..54ce06e --- /dev/null +++ b/registry/registry_test.go @@ -0,0 +1,35 @@ +package registry + +import "testing" + +func TestDefaultRulesNoDuplicates(t *testing.T) { + m := make(map[string]struct{}) + for _, r := range Rules() { + if _, ok := m[r.Name()]; ok { + t.Errorf("duplicate rule name: %s", r.Name()) + } + m[r.Name()] = struct{}{} + } +} + +func TestDefaultFormattersNoDuplicates(t *testing.T) { + m := make(map[string]struct{}) + for _, f := range Formatters() { + if _, ok := m[f.Name()]; ok { + t.Errorf("duplicate formatter name: %s", f.Name()) + } + m[f.Name()] = struct{}{} + } +} + +func TestRegisterCustomRule(t *testing.T) { + // Registering an already-registered rule must return an error. + rules := Rules() + if len(rules) == 0 { + t.Fatal("expected at least one default rule") + } + err := RegisterRule(rules[0]) + if err == nil { + t.Errorf("expected error when registering duplicate rule %q", rules[0].Name()) + } +}