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: [] diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..61be76c --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,33 @@ +name: Go + +on: + push: + branches: ["master"] + pull_request: + branches: ["master"] + types: + - opened + - synchronize + - reopened + - review_requested + pull_request_review: + types: + - submitted + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24.2" + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index f602f0e..ed8ccb9 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -3,7 +3,7 @@ name: goreleaser on: push: tags: - - '*' + - "*" permissions: contents: write @@ -12,22 +12,21 @@ jobs: goreleaser: runs-on: ubuntu-latest steps: - - - name: Checkout - uses: actions/checkout@v2 + - name: Checkout + uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v2 + - name: Set up Go + uses: actions/setup-go@v5 with: - go-version: 1.18 - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v2 + go-version: 1.24.2 + - name: Run Tests + run: go test ./... -count=1 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser - version: latest - args: release --rm-dist + version: "~> v2" + args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml index 27db33a..539700b 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,50 +1,45 @@ +version: 2 + before: hooks: - go mod tidy builds: - - - env: + - env: - CGO_ENABLED=0 goos: - linux - windows - darwin ldflags: - - -s -w -X github.com/conventionalcommit/commitlint/internal.version={{.Version}} -X github.com/conventionalcommit/commitlint/internal.commit={{.FullCommit}} -X github.com/conventionalcommit/commitlint/internal.buildTime={{.Date}} + - -s -w -X github.com/conventionalcommit/commitlint/internal.version=v{{.Major}}.{{.Minor}}.{{.Patch}} -X github.com/conventionalcommit/commitlint/internal.commit={{.FullCommit}} -X github.com/conventionalcommit/commitlint/internal.buildTime={{.Date}} flags: - -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 checksum: - name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" + name_template: "{{ .ProjectName }}_v{{.Major}}.{{.Minor}}.{{.Patch}}_checksums.txt" algorithm: sha256 snapshot: - name_template: "{{ .Tag }}" + version_template: "{{ .Tag }}" changelog: sort: asc filters: exclude: - - '^docs:' - - '^test:' - - '^style:' - - '^chore:' - - '^refactor:' - - '^build:' - - '^ci:' + - "^docs:" + - "^test:" + - "^style:" + - "^chore:" + - "^refactor:" + - "^build:" + - "^ci:" release: draft: true diff --git a/README.md b/README.md index c7b8063..64a36c8 100644 --- a/README.md +++ b/README.md @@ -23,20 +23,39 @@ commitlint checks if your commit message meets the [conventional commit format]( - [Using go](#using-go) - [Setup](#setup) - [Manual](#manual) + - [Remove](#remove) - [Quick Test](#quick-test) - [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) - [Commit Types](#commit-types) + - [Ignore Patterns](#ignore-patterns) + - [Default Ignore Patterns](#default-ignore-patterns) + - [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) + - [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) @@ -75,13 +94,29 @@ commitlint init --global --hookspath /path/to/hooks ### Manual -- run `commitlint hook create` to create `.commitlint/hooks` containing git hooks - - pass `--hookspath` to customize the hooks output path +- run `commitlint hook` to create `.commitlint/hooks` containing git hooks + - pass `--hookspath` or `-p` to customize the hooks output path - To enable in single repo - run `git config core.hooksPath /path/to/.commitlint/hooks` - To enable globally - run `git config --global core.hooksPath /path/to/.commitlint/hooks` +### Remove + +- To remove hooks from a single repository + +```bash +commitlint remove +``` + +- To remove hooks globally + +```bash +commitlint remove --global +``` + +Both commands ask for confirmation before unsetting `core.hooksPath` in git config. Hook files are left intact. + ## Quick Test - Valid commit message @@ -102,9 +137,11 @@ echo "fear: do not fear for commit message" | commitlint lint ### config -- To create config file, run `commitlint config create` this will create `commitlint.yaml` +- To create a config file, run `commitlint config create`, this will create `.commitlint.yaml` with only the enabled rules and their settings (compact format) + +- To create a config file with **all** rules and settings written out (including disabled ones), run `commitlint config create --all` -- To validate config file, run `commitlint config check --config=/path/to/conf.yaml` +- To validate a config file, run `commitlint config check /path/to/conf.yaml` ### lint @@ -113,11 +150,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 @@ -128,7 +163,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 @@ -136,7 +171,9 @@ To lint a message, you can use any one of the following ### hook -- To create hook files, run `commitlint hook create` +- To create hook files, run `commitlint hook` + - pass `--hookspath` or `-p` to customize the hooks output directory + - pass `--replace` or `-r` to overwrite existing hook files ### debug @@ -147,7 +184,7 @@ To lint a message, you can use any one of the following ## Default Config ```yaml -version: v0.9.0 +min-version: v0.11.0 formatter: default rules: - header-min-length @@ -157,15 +194,20 @@ rules: - type-enum severity: default: error + rules: {} settings: body-max-line-length: - argument: 72 + argument: 100 + flags: {} footer-max-line-length: + argument: 100 + flags: {} + header-max-length: argument: 72 + flags: {} header-min-length: argument: 10 - header-max-length: - argument: 50 + flags: {} type-enum: argument: - feat @@ -179,14 +221,17 @@ settings: - ci - chore - revert + flags: {} +disable-default-ignores: false +ignores: [] ``` ### Commit Types -Commonly used commit types from [Conventional Commit Types](https://github.com/commitizen/conventional-commit-types) +Commonly used commit types | Type | Description | -| :------- | :------------------------------------------------------------------------------- | +|:---------|:---------------------------------------------------------------------------------| | feat | A new feature | | fix | A bug fix | | docs | Documentation only changes | @@ -199,32 +244,156 @@ Commonly used commit types from [Conventional Commit Types](https://github.com/c | chore | Other changes that don't modify src or test files | | revert | Reverts a previous commit | +## Ignore Patterns + +commitlint automatically skips linting for commit messages generated by git (merges, reverts, fixups, etc.). +If the **first line** of a commit message matches any ignore pattern, linting is skipped entirely. + +### Default Ignore Patterns + +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) | + +### Custom Ignore Patterns + +Add your own patterns in the config file under `ignores:`. User-defined patterns are +**additive**, they are checked alongside the built-in defaults. + +```yaml +ignores: + - "^WIP " + - "^TICKET-\\d+" +``` + +### Disabling Default Ignores + +If you want **only** your custom patterns (no built-in defaults), set `disable-default-ignores: true`: + +```yaml +disable-default-ignores: true +ignores: + - "^WIP " +``` + ## 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 @@ -247,20 +416,256 @@ 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` + To create a sample config, run `commitlint config create` (or `commitlint config create --all` to include all available settings) - How can I skip lint check for a commit? use `--no-verify` flag with `git commit` which skips commit hooks +- How does commitlint handle merge / revert commits? + + commitlint ships with [built-in ignore patterns](#default-ignore-patterns) that + automatically skip linting for merge commits, reverts, fixups, squashes, and other + git-generated messages. You can add your own patterns with the `ignores` config key, + or disable the defaults with `disable-default-ignores: true`. + +- Can I use the old `version` config key? + + Yes. The `version` key is still accepted for backward compatibility, but new config + files should use `min-version` instead. + ## License All packages are licensed under [MIT License](LICENSE.md) 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 a3202c2..a1c58a2 100644 --- a/config/config.go +++ b/config/config.go @@ -7,14 +7,15 @@ import ( "io" "os" "path/filepath" + "regexp" "golang.org/x/mod/semver" yaml "gopkg.in/yaml.v2" "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 @@ -26,8 +27,7 @@ func Parse(confPath string) (*lint.Config, error) { } conf := &lint.Config{ - MinVersion: internal.Version(), - Formatter: (&formatter.DefaultFormatter{}).Name(), + Formatter: (&formatter.DefaultFormatter{}).Name(), Severity: lint.SeverityConfig{ Default: lint.SeverityError, }, @@ -38,6 +38,20 @@ func Parse(confPath string) (*lint.Config, error) { return nil, fmt.Errorf("config file error: %w", err) } + // Backward compatibility: accept old "version" key + if conf.MinVersion == "" && conf.DeprecatedVersion != "" { + conf.MinVersion = conf.DeprecatedVersion + } + conf.DeprecatedVersion = "" + + // Default to current version if neither key was provided + if conf.MinVersion == "" { + conf.MinVersion = internal.Version() + } + + // Always set the built-in default patterns + conf.DefaultIgnorePatterns = DefaultIgnorePatterns() + if conf.Formatter == "" { return nil, errors.New("config error: formatter is empty") } @@ -52,7 +66,7 @@ func Parse(confPath string) (*lint.Config, error) { // Validate validates given config instance, it checks the following // If formatters, rules are registered/known // If arguments to rules are valid -// If version is valid and atleast minimum than commitlint version used +// If version is valid and at least minimum than commitlint version used func Validate(conf *lint.Config) []error { var errs []error @@ -78,7 +92,7 @@ func Validate(conf *lint.Config) []error { for ruleName, sev := range conf.Severity.Rules { // Check Severity Level of rule config if !isSeverityValid(sev) { - errs = append(errs, fmt.Errorf("unknown default severity level '%s' for rule '%s'", ruleName, sev)) + errs = append(errs, fmt.Errorf("unknown severity level '%s' for rule '%s'", sev, ruleName)) } } @@ -91,6 +105,16 @@ func Validate(conf *lint.Config) []error { } } + // Check for duplicate rules + ruleSeen := make(map[string]struct{}, len(conf.Rules)) + for _, ruleName := range conf.Rules { + if _, exists := ruleSeen[ruleName]; exists { + errs = append(errs, fmt.Errorf("duplicate rule '%s' in rules list", ruleName)) + } else { + ruleSeen[ruleName] = struct{}{} + } + } + for ruleName, ruleSetting := range conf.Settings { // Check if rule is registered ruleData, ok := registry.GetRule(ruleName) @@ -104,6 +128,15 @@ func Validate(conf *lint.Config) []error { errs = append(errs, err) } } + + // Validate ignore patterns (both default and user-defined) + for _, pattern := range conf.EffectiveIgnorePatterns() { + _, err := regexp.Compile(pattern) + if err != nil { + errs = append(errs, fmt.Errorf("invalid ignore pattern %q: %w", pattern, err)) + } + } + return errs } @@ -126,7 +159,8 @@ func LookupAndParse() (*lint.Config, error) { return conf, nil } -// WriteTo writes config in yaml format to given io.Writer +// WriteTo writes config in yaml format to given io.Writer, including all +// settings and every field even if empty or zero-valued. func WriteTo(w io.Writer, conf *lint.Config) (retErr error) { enc := yaml.NewEncoder(w) defer func() { @@ -138,6 +172,30 @@ func WriteTo(w io.Writer, conf *lint.Config) (retErr error) { return enc.Encode(conf) } +// WriteCompactTo writes config in yaml format to given io.Writer. +// Only settings for enabled rules are written, keeping the output compact. +func WriteCompactTo(w io.Writer, conf *lint.Config) error { + // Build a compact copy: only settings for enabled rules + compact := *conf + if len(compact.Rules) > 0 && len(compact.Settings) > 0 { + enabled := make(map[string]struct{}, len(compact.Rules)) + for _, r := range compact.Rules { + enabled[r] = struct{}{} + } + filtered := make(map[string]lint.RuleSetting, len(compact.Rules)) + for name, setting := range compact.Settings { + if _, ok := enabled[name]; ok { + filtered[name] = setting + } + } + compact.Settings = filtered + } + + enc := yaml.NewEncoder(w) + defer enc.Close() + return enc.Encode(&compact) +} + func isValidVersion(versionNo string) error { if versionNo == "" { return errors.New("version is empty") diff --git a/config/default.go b/config/default.go index 9fa1f62..9c5a63e 100644 --- a/config/default.go +++ b/config/default.go @@ -7,6 +7,51 @@ import ( "github.com/conventionalcommit/commitlint/rule" ) +const ( + DefaultTypeCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + DefaultScopeCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/," +) + +// DefaultIgnorePatterns returns the default list of ignore patterns +// These patterns match commit messages auto-generated by git commands +// like merge, revert, fixup, squash, etc. +func DefaultIgnorePatterns() []string { + return []string{ + // GitHub / GitLab merge + `^Merge pull request #\d+`, + `^Merge .+ into .+`, + `^Merge branch '.+'`, + `^Merge tag '.+'`, + `^Merge remote-tracking branch '.+'`, + + // Azure DevOps / Bitbucket merge + `^Merged .+ (in|into) .+`, + `^Merged PR #?\d+`, + + // Revert and Reapply + `^(R|r)evert `, + `^(R|r)eapply `, + + // Fixup, Amend, Squash (git commit --fixup/--squash) + `^(amend|fixup|squash)! `, + + // Automatic merges + `^Automatic merge`, + `^Auto-merged .+ into .+`, + + // Initial commit + `^Initial commit$`, + } +} + +// DefaultTypeEnums returns the default list of type enums +func DefaultTypeEnums() []interface{} { + return []interface{}{ + "feat", "fix", "docs", "style", "refactor", "perf", + "test", "build", "ci", "chore", "revert", + } +} + // NewDefault returns default config func NewDefault() *lint.Config { // Enabled Rules @@ -32,25 +77,22 @@ func NewDefault() *lint.Config { // Header Max Len Rule (&rule.HeadMaxLenRule{}).Name(): { - Argument: 50, + Argument: 72, }, // Body Max Line Rule (&rule.BodyMaxLineLenRule{}).Name(): { - Argument: 72, + Argument: 100, }, // Footer Max Line Rule (&rule.FooterMaxLineLenRule{}).Name(): { - Argument: 72, + Argument: 100, }, // Types Enum Rule (&rule.TypeEnumRule{}).Name(): { - Argument: []interface{}{ - "feat", "fix", "docs", "style", "refactor", "perf", - "test", "build", "ci", "chore", "revert", - }, + Argument: DefaultTypeEnums(), }, // Scope Enum Rule @@ -113,12 +155,12 @@ func NewDefault() *lint.Config { // Type Charset Rule (&rule.TypeCharsetRule{}).Name(): { - Argument: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", + Argument: DefaultTypeCharset, }, // Scope Charset Rule (&rule.ScopeCharsetRule{}).Name(): { - Argument: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ/,", + Argument: DefaultScopeCharset, }, // Footer Enum Rule @@ -128,16 +170,70 @@ func NewDefault() *lint.Config { // Footer Type Enum Rule (&rule.FooterTypeEnumRule{}).Name(): { - Argument: []map[interface{}]interface{}{}, + 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{ - MinVersion: internal.Version(), - Formatter: (&formatter.DefaultFormatter{}).Name(), - Rules: rules, - Severity: severity, - Settings: settings, + MinVersion: internal.Version(), + Formatter: (&formatter.DefaultFormatter{}).Name(), + Rules: rules, + Severity: severity, + Settings: settings, + DefaultIgnorePatterns: DefaultIgnorePatterns(), } return def } 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/go.mod b/go.mod index d7c1e90..d2a9878 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,18 @@ module github.com/conventionalcommit/commitlint -go 1.17 +go 1.24.0 + +toolchain go1.24.2 require ( - github.com/conventionalcommit/parser v0.7.1 - github.com/urfave/cli/v2 v2.11.1 - golang.org/x/mod v0.5.1 + github.com/conventionalcommit/parser v0.8.0 + github.com/urfave/cli/v2 v2.27.7 + golang.org/x/mod v0.33.0 gopkg.in/yaml.v2 v2.4.0 ) require ( - github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect ) diff --git a/go.sum b/go.sum index 503a67e..698857d 100644 --- a/go.sum +++ b/go.sum @@ -1,31 +1,16 @@ -github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/conventionalcommit/parser v0.7.1 h1:oAzcrEqyyGnzCeNOBqGx2qnxISxneUuBiu320chjzMU= -github.com/conventionalcommit/parser v0.7.1/go.mod h1:k3teTA7nWpRrk7sjAihpAXm+1QLu1OscGrxclMHgEyc= -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/conventionalcommit/parser v0.8.0 h1:4/M/JveDEIeRa+WvaeqUWAMeE5A/hRpVT2GGEAgsy90= +github.com/conventionalcommit/parser v0.8.0/go.mod h1:k3teTA7nWpRrk7sjAihpAXm+1QLu1OscGrxclMHgEyc= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/urfave/cli/v2 v2.11.1 h1:UKK6SP7fV3eKOefbS87iT9YHefv7iB/53ih6e+GNAsE= -github.com/urfave/cli/v2 v2.11.1/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= -golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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-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.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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/cmd/cli.go b/internal/cmd/cli.go deleted file mode 100644 index 76b0a99..0000000 --- a/internal/cmd/cli.go +++ /dev/null @@ -1,225 +0,0 @@ -// Package cmd contains commitlint cli -package cmd - -import ( - "fmt" - - cli "github.com/urfave/cli/v2" - - "github.com/conventionalcommit/commitlint/internal" -) - -// newCliApp returns commitlint cli.App -func newCliApp() *cli.App { - cmds := []*cli.Command{ - newInitCmd(), - newLintCmd(), - newConfigCmd(), - newHookCmd(), - newDebugCmd(), - } - - app := &cli.App{ - Name: "commitlint", - Usage: "linter for conventional commits", - Commands: cmds, - Version: internal.FullVersion(), - } - return app -} - -func newLintCmd() *cli.Command { - return &cli.Command{ - Name: "lint", - Usage: "Check commit message against lint rules", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "config", - Aliases: []string{"c"}, - Value: "", - Usage: "optional config file `conf.yaml`", - }, - &cli.StringFlag{ - Name: "message", - Aliases: []string{"m", "msg"}, - Value: "", - Usage: "path to commit message `FILE`", - }, - }, - Action: func(ctx *cli.Context) error { - confFilePath := ctx.String("config") - fileInput := ctx.String("message") - return lintMsg(confFilePath, fileInput) - }, - } -} - -func newInitCmd() *cli.Command { - confFlag := newConfFlag() - replaceFlag := newReplaceFlag() - hooksFlag := newHooksPathFlag() - - globalFlag := &cli.BoolFlag{ - Name: "global", - Aliases: []string{"g"}, - Usage: "Sets git hook in global config", - } - - return &cli.Command{ - Name: "init", - Usage: "Setup commitlint for git repos", - Flags: []cli.Flag{globalFlag, confFlag, replaceFlag, hooksFlag}, - Action: func(ctx *cli.Context) error { - confPath := ctx.String("config") - isGlobal := ctx.Bool("global") - isReplace := ctx.Bool("replace") - hooksPath := ctx.String("hookspath") - - err := initLint(confPath, hooksPath, isGlobal, isReplace) - if err != nil { - if isHookExists(err) { - fmt.Println("commitlint init failed") - fmt.Println("run with --replace to replace existing files") - return nil - } - return err - } - - fmt.Println("commitlint init successfully") - return nil - }, - } -} - -func newConfigCmd() *cli.Command { - createCmd := &cli.Command{ - Name: "create", - Usage: "Creates default config in current directory", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "replace", - Aliases: []string{"r"}, - Usage: "Replace conf file if already exists", - Value: false, - }, - &cli.StringFlag{ - Name: "file", - Usage: "Config file name", - Value: ".commitlint.yaml", - }, - }, - Action: func(ctx *cli.Context) error { - isReplace := ctx.Bool("replace") - fileName := ctx.String("file") - err := configCreate(fileName, isReplace) - if err != nil { - if isConfExists(err) { - fmt.Println("config create failed") - fmt.Println("run with --replace to replace existing file") - return nil - } - return err - } - fmt.Println("config file created") - return nil - }, - } - - checkCmd := &cli.Command{ - Name: "check", - Usage: "Checks if given config is valid", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "config", - Aliases: []string{"c"}, - Usage: "config file `conf.yaml`", - Required: true, - }, - }, - Action: func(ctx *cli.Context) error { - confFile := ctx.String("config") - errs := configCheck(confFile) - if len(errs) == 0 { - fmt.Printf("%s config is valid\n", confFile) - return nil - } - if len(errs) == 1 { - return errs[0] - } - merr := multiError(errs) - return &merr - }, - } - - return &cli.Command{ - Name: "config", - Usage: "Manage commitlint config", - Subcommands: []*cli.Command{createCmd, checkCmd}, - } -} - -func newHookCmd() *cli.Command { - replaceFlag := newReplaceFlag() - hooksFlag := newHooksPathFlag() - - createCmd := &cli.Command{ - Name: "create", - Usage: "Creates git hook files in current directory", - Flags: []cli.Flag{replaceFlag, hooksFlag}, - Action: func(ctx *cli.Context) error { - isReplace := ctx.Bool("replace") - hooksPath := ctx.String("hookspath") - err := hookCreate(hooksPath, isReplace) - if err != nil { - if isHookExists(err) { - fmt.Println("create failed. hook files already exists") - fmt.Println("run with --replace to replace existing hook files") - return nil - } - return err - } - fmt.Println("hooks created") - return nil - }, - } - - return &cli.Command{ - Name: "hook", - Usage: "Manage commitlint git hooks", - Subcommands: []*cli.Command{createCmd}, - } -} - -func newDebugCmd() *cli.Command { - return &cli.Command{ - Name: "debug", - Usage: "prints useful information for debugging", - Action: func(ctx *cli.Context) error { - return printDebug() - }, - } -} - -func newConfFlag() *cli.StringFlag { - return &cli.StringFlag{ - Name: "config", - Aliases: []string{"c"}, - Value: "", - Usage: "Optional config file `conf.yaml` which will be passed to 'commitlint lint'. Check config precedence", - } -} - -func newHooksPathFlag() *cli.StringFlag { - return &cli.StringFlag{ - Name: "hookspath", - Value: "", - Usage: "Optional hookspath to install git hooks", - } -} - -func newReplaceFlag() *cli.BoolFlag { - return &cli.BoolFlag{ - Name: "replace", - Usage: "Replace hook files if already exists", - } -} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 3e45424..ffdb4eb 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -1,8 +1,11 @@ package cmd import ( + "fmt" "os" - "strings" + + "github.com/conventionalcommit/commitlint/internal" + cli "github.com/urfave/cli/v2" ) // Run runs commitlint cli with os.Args @@ -10,20 +13,226 @@ func Run() error { return newCliApp().Run(os.Args) } -type multiError []error +// newCliApp returns commitlint cli.App +func newCliApp() *cli.App { + cmds := []*cli.Command{ + newInitCmd(), + newRemoveCmd(), + newLintCmd(), + newConfigCmd(), + newHookCmd(), + newDebugCmd(), + } + + app := &cli.App{ + Name: "commitlint", + Usage: "Lint commit messages using Conventional Commits rules", + Commands: cmds, + Version: internal.FullVersion(), + } + return app +} + +func newLintCmd() *cli.Command { + return &cli.Command{ + Name: "lint", + Usage: "Check a commit message", + Description: "Reads from stdin (piped), or --message file, or .git/COMMIT_EDITMSG (in that order).", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Usage: "Use this config `FILE` instead of auto-detected one", + }, + &cli.StringFlag{ + Name: "message", + Aliases: []string{"m", "msg"}, + Usage: "Read commit message from `FILE`", + }, + }, + Action: func(ctx *cli.Context) error { + confFilePath := ctx.String("config") + fileInput := ctx.String("message") + return lintMsg(confFilePath, fileInput) + }, + } +} + +func newInitCmd() *cli.Command { + return &cli.Command{ + Name: "init", + Usage: "Set up commitlint for a git repository", + Description: "Creates the commit-msg hook and points git to it.\nUse --global to apply across all your repositories.", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "global", + Aliases: []string{"g"}, + Usage: "Set up for all repositories (uses global git config)", + }, + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Usage: "Pass a config `FILE` to the hook", + }, + &cli.BoolFlag{ + Name: "replace", + Aliases: []string{"r"}, + Usage: "Overwrite existing hook files", + }, + &cli.StringFlag{ + Name: "hookspath", + Aliases: []string{"p"}, + Usage: "Where to write hook files (default: .commitlint/hooks)", + }, + }, + Action: func(ctx *cli.Context) error { + confPath := ctx.String("config") + isGlobal := ctx.Bool("global") + isReplace := ctx.Bool("replace") + hooksPath := ctx.String("hookspath") + + err := initLint(confPath, hooksPath, isGlobal, isReplace) + if err != nil { + if isHookExists(err) { + fmt.Println("commitlint init failed: hook files already exist") + fmt.Println("use --replace to overwrite them") + return nil + } + return err + } + + fmt.Println("commitlint init successfully") + return nil + }, + } +} + +func newConfigCmd() *cli.Command { + createCmd := &cli.Command{ + Name: "create", + Usage: "Create a default config file", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "replace", + Aliases: []string{"r"}, + Usage: "Overwrite if the file already exists", + }, + &cli.StringFlag{ + Name: "file", + Usage: "Output file name", + Value: ".commitlint.yaml", + }, + &cli.BoolFlag{ + Name: "all", + Usage: "Write all settings (not just enabled rules)", + }, + }, + Action: func(ctx *cli.Context) error { + isReplace := ctx.Bool("replace") + fileName := ctx.String("file") + all := ctx.Bool("all") + err := configCreate(fileName, isReplace, all) + if err != nil { + if isConfExists(err) { + fmt.Println("config file already exists") + fmt.Println("use --replace to overwrite it") + return nil + } + return err + } + fmt.Println("config file created") + return nil + }, + } + + checkCmd := &cli.Command{ + Name: "check", + Usage: "Check if a config file is valid", + ArgsUsage: "", + Action: func(ctx *cli.Context) error { + confFile := ctx.Args().First() + if confFile == "" { + return fmt.Errorf("please provide a config file path\n\nUsage: commitlint config check ") + } + errs := configCheck(confFile) + if len(errs) == 0 { + fmt.Printf("%s: valid\n", confFile) + return nil + } + if len(errs) == 1 { + return errs[0] + } + merr := multiError(errs) + return &merr + }, + } + + return &cli.Command{ + Name: "config", + Usage: "Manage configuration", + Subcommands: []*cli.Command{createCmd, checkCmd}, + } +} + +func newHookCmd() *cli.Command { + return &cli.Command{ + Name: "hook", + Usage: "Create the commit-msg git hook", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "replace", + Aliases: []string{"r"}, + Usage: "Overwrite existing hook files", + }, + &cli.StringFlag{ + Name: "hookspath", + Aliases: []string{"p"}, + Usage: "Where to write hook files (default: .commitlint/hooks)", + }, + }, + Action: func(ctx *cli.Context) error { + isReplace := ctx.Bool("replace") + hooksPath := ctx.String("hookspath") + err := hookCreate(hooksPath, isReplace) + if err != nil { + if isHookExists(err) { + fmt.Println("hook files already exist") + fmt.Println("use --replace to overwrite them") + return nil + } + return err + } + fmt.Println("hooks created") + return nil + }, + } +} -func (m *multiError) Error() string { - errs := make([]string, len(*m)) - for i, err := range *m { - errs[i] = err.Error() +func newRemoveCmd() *cli.Command { + return &cli.Command{ + Name: "remove", + Usage: "Remove commitlint from git config", + Description: "Unset git's core.hooksPath so commits are no longer linted.\nHook files are left intact.\nUse --global to remove globally configured hooks.", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "global", + Aliases: []string{"g"}, + Usage: "Remove from global git config", + }, + }, + Action: func(ctx *cli.Context) error { + isGlobal := ctx.Bool("global") + return removeLint(isGlobal) + }, } - return strings.Join(errs, "\n") } -func (m *multiError) Errors() []error { - errs := make([]error, len(*m)) - for _, err := range *m { - errs = append(errs, err) +func newDebugCmd() *cli.Command { + return &cli.Command{ + Name: "debug", + Usage: "Show debug info (version, hooks, config)", + Action: func(ctx *cli.Context) error { + return printDebug() + }, } - return errs } diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 6ed43cd..003fdd6 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -9,7 +9,7 @@ import ( ) // configCreate is the callback function for create config command -func configCreate(fileName string, isReplace bool) (retErr error) { +func configCreate(fileName string, isReplace bool, all bool) (retErr error) { outPath := filepath.Join(".", fileName) // if config file already exists skip creating or overwriting it if _, err := os.Stat(outPath); !os.IsNotExist(err) { @@ -39,7 +39,10 @@ func configCreate(fileName string, isReplace bool) (retErr error) { }() defConf := config.NewDefault() - return config.WriteTo(w, defConf) + if all { + return config.WriteTo(w, defConf) + } + return config.WriteCompactTo(w, defConf) } // configCheck is the callback function for check/verify command diff --git a/internal/cmd/hook.go b/internal/cmd/hook.go index 1d46c8c..a3bc60d 100644 --- a/internal/cmd/hook.go +++ b/internal/cmd/hook.go @@ -109,9 +109,9 @@ func getRepoRootDir() (string, error) { } func isHookExists(err error) bool { - return err == errHooksExist + return errors.Is(err, errHooksExist) } func isConfExists(err error) bool { - return err == errConfigExist + return errors.Is(err, errConfigExist) } diff --git a/internal/cmd/lint.go b/internal/cmd/lint.go index f2bafdd..46a3197 100644 --- a/internal/cmd/lint.go +++ b/internal/cmd/lint.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "unicode" cli "github.com/urfave/cli/v2" @@ -46,7 +47,9 @@ func runLint(confFilePath, fileInput string) (lintResult string, hasError bool, return "", false, err } - result, err := linter.ParseAndLint(commitMsg) + cleanMsg := cleanupMsg(commitMsg) + + result, err := linter.ParseAndLint(cleanMsg) if err != nil { return "", false, err } @@ -115,6 +118,56 @@ func getCommitMsg(fileInput string) (string, error) { return string(inBytes), nil } +func trimRightSpace(s string) string { + return strings.TrimRightFunc(s, unicode.IsSpace) +} + +func cleanupMsg(dirtyMsg string) string { + // commit msg cleanup in git is configurable: https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---cleanupltmodegt + // For now we do a combination of the "scissors" behavior and the "strip" behavior + // * remove the scissors line and everything below + // * strip leading and trailing empty lines + // * strip commentary (lines stating with commentChar '#') + // * strip trailing whitespace + // * collapse consecutive empty lines + // TODO: check via "git config --get" if any of those two hardcoded constants was reconfigured + // TODO: find out if commit messages on windows actually + + gitCommentChar := "#" + scissors := gitCommentChar + " ------------------------ >8 ------------------------" + + cleanMsg := "" + lastLine := "" + for _, line := range strings.Split(dirtyMsg, "\n") { + if line == scissors { + // remove everything below scissors (including the scissors line) + break + } + if strings.HasPrefix(line, gitCommentChar) { + // strip commentary + continue + } + line = trimRightSpace(line) + // strip trailing whitespace + if lastLine == "" && line == "" { + // strip leading empty lines + // collapse consecutive empty lines + continue + } + if cleanMsg == "" { + cleanMsg = line + } else { + cleanMsg += "\n" + line + } + lastLine = line + } + if lastLine == "" { + // strip trailing empty line + cleanMsg = strings.TrimSuffix(cleanMsg, "\n") + } + return cleanMsg +} + func readStdInPipe() (string, error) { stat, err := os.Stdin.Stat() if err != nil { diff --git a/internal/cmd/remove.go b/internal/cmd/remove.go new file mode 100644 index 0000000..f62a459 --- /dev/null +++ b/internal/cmd/remove.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// removeLint is the callback function for the uninstall command. +func removeLint(isGlobal bool) error { + return gitRemoveHook(isGlobal) +} + +// gitRemoveHook removes commitlint from git config by unsetting core.hooksPath. +// It prompts for confirmation before making any changes. +// Hook files are left intact - the user can remove them manually if needed. +func gitRemoveHook(isGlobal bool) error { + scope := "local" + if isGlobal { + scope = "global" + } + confirmed, err := promptConfirm(fmt.Sprintf("Unset core.hooksPath from %s git config?", scope)) + if err != nil { + return err + } + if !confirmed { + fmt.Println("aborted") + return nil + } + + if err := unsetGitConf(isGlobal); err != nil { + return fmt.Errorf("could not unset git core.hooksPath: %w", err) + } + + fmt.Println("commitlint hook removed successfully") + fmt.Println("note: hook files were not removed - delete them manually if no longer needed") + return nil +} + +// unsetGitConf removes the core.hooksPath entry from git config (local or global). +func unsetGitConf(isGlobal bool) error { + args := []string{"config"} + if isGlobal { + args = append(args, "--global") + } + args = append(args, "--unset", "core.hooksPath") + + cmd := exec.Command("git", args...) + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// promptConfirm prints prompt and waits for the user to type y/yes or anything else. +// Returns true only when the user confirms with "y" or "yes" (case-insensitive). +func promptConfirm(prompt string) (bool, error) { + fmt.Printf("%s [y/N]: ", prompt) + var response string + _, err := fmt.Scanln(&response) + if err != nil { + // empty Enter is reported by fmt.Scanln as "unexpected newline" + if err.Error() == "unexpected newline" { + return false, nil + } + return false, err + } + response = strings.TrimSpace(strings.ToLower(response)) + return response == "y" || response == "yes", nil +} diff --git a/internal/cmd/util.go b/internal/cmd/util.go new file mode 100644 index 0000000..8dfdc91 --- /dev/null +++ b/internal/cmd/util.go @@ -0,0 +1,21 @@ +package cmd + +import "strings" + +type multiError []error + +func (m *multiError) Error() string { + errs := make([]string, len(*m)) + for i, err := range *m { + errs[i] = err.Error() + } + return strings.Join(errs, "\n") +} + +func (m *multiError) Errors() []error { + errs := make([]error, len(*m)) + for i, err := range *m { + errs[i] = err + } + return errs +} diff --git a/internal/config.go b/internal/config.go index f12bcff..47fbed4 100644 --- a/internal/config.go +++ b/internal/config.go @@ -39,8 +39,8 @@ func (c ConfigType) String() string { // LookupConfigPath returns config file path following below order // 1. env path -// 2. commitlint.yaml in current directory -// 3. use default config +// 2. commitlint.yaml in current directory +// 3. use default config func LookupConfigPath() (confPath string, typ ConfigType, err error) { envConf := os.Getenv(CommitlintConfigEnv) if envConf != "" { 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/lint/config.go b/lint/config.go index 9d00904..a574cc0 100644 --- a/lint/config.go +++ b/lint/config.go @@ -3,20 +3,24 @@ package lint // RuleSetting represent config for a rule type RuleSetting struct { Argument interface{} `yaml:"argument"` - Flags map[string]interface{} `yaml:"flags,omitempty"` + Flags map[string]interface{} `yaml:"flags"` } // SeverityConfig represent severity levels for rules type SeverityConfig struct { Default Severity `yaml:"default"` - Rules map[string]Severity `yaml:"rules,omitempty"` + Rules map[string]Severity `yaml:"rules"` } // Config represent linter config type Config struct { // MinVersion is the minimum version of commitlint required // should be in semver format - MinVersion string `yaml:"version"` + MinVersion string `yaml:"min-version"` + + // DeprecatedVersion is the old "version" key, kept for backward compatibility. + // Use MinVersion ("min-version") in new config files. + DeprecatedVersion string `yaml:"version,omitempty"` // Formatter of the lint result Formatter string `yaml:"formatter"` @@ -29,6 +33,32 @@ type Config struct { // Settings is rule name to rule settings Settings map[string]RuleSetting `yaml:"settings"` + + // DisableDefaultIgnores disables the built-in ignore patterns + // (merge, revert, fixup, squash, etc.) when set to true. + DisableDefaultIgnores bool `yaml:"disable-default-ignores"` + + // IgnorePatterns is a list of user-defined regex patterns. + // If the first line of the commit message matches any pattern, + // linting is skipped. These are added on top of the default + // patterns (unless DisableDefaultIgnores is true). + IgnorePatterns []string `yaml:"ignores"` + + // DefaultIgnorePatterns holds the built-in patterns (set by config package). + // Not serialized to YAML - users never set this directly. + DefaultIgnorePatterns []string `yaml:"-"` +} + +// EffectiveIgnorePatterns returns the combined list of patterns the linter should use. +// If DisableDefaultIgnores is true, only user-defined patterns are returned. +func (c *Config) EffectiveIgnorePatterns() []string { + if c.DisableDefaultIgnores { + return c.IgnorePatterns + } + combined := make([]string, 0, len(c.DefaultIgnorePatterns)+len(c.IgnorePatterns)) + combined = append(combined, c.DefaultIgnorePatterns...) + combined = append(combined, c.IgnorePatterns...) + return combined } // GetRule returns RuleConfig for given rule name diff --git a/lint/linter.go b/lint/linter.go index df9927a..5e9a8d6 100644 --- a/lint/linter.go +++ b/lint/linter.go @@ -1,26 +1,43 @@ // Package lint provides a simple linter for conventional commits package lint +import ( + "fmt" + "regexp" + "strings" +) + // Linter is linter for commit message type Linter struct { conf *Config rules []Rule - parser Parser + parser Parser + ignorePatterns []*regexp.Regexp } // New returns a new Linter instance with given config and rules func New(conf *Config, rules []Rule) (*Linter, error) { + compiled, err := compilePatterns(conf.EffectiveIgnorePatterns()) + if err != nil { + return nil, err + } + l := &Linter{ - conf: conf, - rules: rules, - parser: newParser(), + conf: conf, + rules: rules, + parser: newParser(), + ignorePatterns: compiled, } return l, nil } // ParseAndLint checks the given commitMsg string against rules func (l *Linter) ParseAndLint(commitMsg string) (*Result, error) { + if l.isIgnored(commitMsg) { + return newResult(commitMsg), nil + } + msg, err := l.parser.Parse(commitMsg) if err != nil { issues := l.parserErrorRule(commitMsg, err) @@ -29,6 +46,34 @@ func (l *Linter) ParseAndLint(commitMsg string) (*Result, error) { return l.Lint(msg) } +// isIgnored checks if the first line of the commit message +// matches any of the configured ignore patterns +func (l *Linter) isIgnored(commitMsg string) bool { + if len(l.ignorePatterns) == 0 { + return false + } + + firstLine := strings.Split(commitMsg, "\n")[0] + for _, re := range l.ignorePatterns { + if re.MatchString(firstLine) { + return true + } + } + return false +} + +func compilePatterns(patterns []string) ([]*regexp.Regexp, error) { + compiled := make([]*regexp.Regexp, 0, len(patterns)) + for _, p := range patterns { + re, err := regexp.Compile(p) + if err != nil { + return nil, fmt.Errorf("invalid ignore pattern %q: %w", p, err) + } + compiled = append(compiled, re) + } + return compiled, nil +} + // Lint checks the given Commit against rules func (l *Linter) Lint(msg Commit) (*Result, error) { issues := make([]*Issue, 0, len(l.rules)) diff --git a/internal/registry/registry.go b/registry/registry.go similarity index 69% rename from internal/registry/registry.go rename to registry/registry.go index ae45940..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 @@ -48,18 +55,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/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()) + } +} diff --git a/rule/body_max_length.go b/rule/body_max_length.go deleted file mode 100644 index a43be05..0000000 --- a/rule/body_max_length.go +++ /dev/null @@ -1,27 +0,0 @@ -package rule - -import "github.com/conventionalcommit/commitlint/lint" - -var _ lint.Rule = (*BodyMaxLenRule)(nil) - -// BodyMaxLenRule to validate max length of body -type BodyMaxLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *BodyMaxLenRule) Name() string { return "body-max-length" } - -// Apply sets the needed argument for the rule -func (r *BodyMaxLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates BodyMaxLenRule -func (r *BodyMaxLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMaxLen("body", r.CheckLen, msg.Body()) -} diff --git a/rule/body_max_line_length.go b/rule/body_max_line_length.go deleted file mode 100644 index 2edab55..0000000 --- a/rule/body_max_line_length.go +++ /dev/null @@ -1,29 +0,0 @@ -package rule - -import ( - "github.com/conventionalcommit/commitlint/lint" -) - -var _ lint.Rule = (*BodyMaxLineLenRule)(nil) - -// BodyMaxLineLenRule to validate max line length of body -type BodyMaxLineLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *BodyMaxLineLenRule) Name() string { return "body-max-line-length" } - -// Apply sets the needed argument for the rule -func (r *BodyMaxLineLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates BodyMaxLineLenRule rule -func (r *BodyMaxLineLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMaxLineLength("body", r.CheckLen, msg.Body()) -} diff --git a/rule/body_min_length.go b/rule/body_min_length.go deleted file mode 100644 index ed787b7..0000000 --- a/rule/body_min_length.go +++ /dev/null @@ -1,27 +0,0 @@ -package rule - -import "github.com/conventionalcommit/commitlint/lint" - -var _ lint.Rule = (*BodyMinLenRule)(nil) - -// BodyMinLenRule to validate min length of body -type BodyMinLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *BodyMinLenRule) Name() string { return "body-min-length" } - -// Apply sets the needed argument for the rule -func (r *BodyMinLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates BodyMinLenRule -func (r *BodyMinLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMinLen("body", r.CheckLen, msg.Body()) -} 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/charset_rules.go b/rule/charset_rules.go new file mode 100644 index 0000000..a2bce84 --- /dev/null +++ b/rule/charset_rules.go @@ -0,0 +1,55 @@ +package rule + +import "github.com/conventionalcommit/commitlint/lint" + +// Compile-time interface checks +var ( + _ lint.Rule = (*TypeCharsetRule)(nil) + _ lint.Rule = (*ScopeCharsetRule)(nil) +) + +// applyStringArg is a shared helper that extracts a string argument from a RuleSetting. +func applyStringArg(dst *string, ruleName string, setting lint.RuleSetting) error { + if err := setStringArg(dst, setting.Argument); err != nil { + return errInvalidArg(ruleName, err) + } + return nil +} + +// TypeCharsetRule to validate charset of type +type TypeCharsetRule struct{ Charset string } + +func (r *TypeCharsetRule) Name() string { return "type-charset" } +func (r *TypeCharsetRule) Apply(s lint.RuleSetting) error { + return applyStringArg(&r.Charset, r.Name(), s) +} + +func (r *TypeCharsetRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + invalidChars, isValid := validateCharset(r.Charset, msg.Type()) + if isValid { + return nil, true + } + return lint.NewIssue( + "type can only have chars ["+r.Charset+"]", + "invalid characters ["+invalidChars+"]", + ), false +} + +// ScopeCharsetRule to validate charset of scope +type ScopeCharsetRule struct{ Charset string } + +func (r *ScopeCharsetRule) Name() string { return "scope-charset" } +func (r *ScopeCharsetRule) Apply(s lint.RuleSetting) error { + return applyStringArg(&r.Charset, r.Name(), s) +} + +func (r *ScopeCharsetRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + invalidChars, isValid := validateCharset(r.Charset, msg.Scope()) + if isValid { + return nil, true + } + return lint.NewIssue( + "scope can only have these chars ["+r.Charset+"]", + "invalid characters ["+invalidChars+"]", + ), false +} diff --git a/rule/desc_max_length.go b/rule/desc_max_length.go deleted file mode 100644 index 9403d5a..0000000 --- a/rule/desc_max_length.go +++ /dev/null @@ -1,27 +0,0 @@ -package rule - -import "github.com/conventionalcommit/commitlint/lint" - -var _ lint.Rule = (*DescriptionMaxLenRule)(nil) - -// DescriptionMaxLenRule to validate max length of type -type DescriptionMaxLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *DescriptionMaxLenRule) Name() string { return "description-max-length" } - -// Apply sets the needed argument for the rule -func (r *DescriptionMaxLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates DescriptionMaxLenRule -func (r *DescriptionMaxLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMaxLen("description", r.CheckLen, msg.Description()) -} diff --git a/rule/desc_min_length.go b/rule/desc_min_length.go deleted file mode 100644 index c425ca6..0000000 --- a/rule/desc_min_length.go +++ /dev/null @@ -1,27 +0,0 @@ -package rule - -import "github.com/conventionalcommit/commitlint/lint" - -var _ lint.Rule = (*DescriptionMinLenRule)(nil) - -// DescriptionMinLenRule to validate min length of description -type DescriptionMinLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *DescriptionMinLenRule) Name() string { return "description-min-length" } - -// Apply sets the needed argument for the rule -func (r *DescriptionMinLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates DescriptionMinLenRule -func (r *DescriptionMinLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMinLen("description", r.CheckLen, msg.Description()) -} 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_max_length.go b/rule/footer_max_length.go deleted file mode 100644 index d2cee1b..0000000 --- a/rule/footer_max_length.go +++ /dev/null @@ -1,27 +0,0 @@ -package rule - -import "github.com/conventionalcommit/commitlint/lint" - -var _ lint.Rule = (*FooterMaxLenRule)(nil) - -// FooterMaxLenRule to validate max length of footer -type FooterMaxLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *FooterMaxLenRule) Name() string { return "footer-max-length" } - -// Apply sets the needed argument for the rule -func (r *FooterMaxLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates FooterMaxLenRule -func (r *FooterMaxLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMaxLen("footer", r.CheckLen, msg.Footer()) -} diff --git a/rule/footer_max_line_length.go b/rule/footer_max_line_length.go deleted file mode 100644 index 8a0ffce..0000000 --- a/rule/footer_max_line_length.go +++ /dev/null @@ -1,27 +0,0 @@ -package rule - -import "github.com/conventionalcommit/commitlint/lint" - -var _ lint.Rule = (*FooterMaxLineLenRule)(nil) - -// FooterMaxLineLenRule to validate max line length of footer -type FooterMaxLineLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *FooterMaxLineLenRule) Name() string { return "footer-max-line-length" } - -// Apply sets the needed argument for the rule -func (r *FooterMaxLineLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates FooterMaxLineLenRule rule -func (r *FooterMaxLineLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMaxLineLength("footer", r.CheckLen, msg.Footer()) -} diff --git a/rule/footer_min_length.go b/rule/footer_min_length.go deleted file mode 100644 index c1ff5ae..0000000 --- a/rule/footer_min_length.go +++ /dev/null @@ -1,27 +0,0 @@ -package rule - -import "github.com/conventionalcommit/commitlint/lint" - -var _ lint.Rule = (*FooterMinLenRule)(nil) - -// FooterMinLenRule to validate min length of footer -type FooterMinLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *FooterMinLenRule) Name() string { return "footer-min-length" } - -// Apply sets the needed argument for the rule -func (r *FooterMinLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates FooterMinLenRule -func (r *FooterMinLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMinLen("footer", r.CheckLen, msg.Footer()) -} 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_max_length.go b/rule/header_max_length.go deleted file mode 100644 index 4db70d5..0000000 --- a/rule/header_max_length.go +++ /dev/null @@ -1,27 +0,0 @@ -package rule - -import "github.com/conventionalcommit/commitlint/lint" - -var _ lint.Rule = (*HeadMaxLenRule)(nil) - -// HeadMaxLenRule to validate max length of header -type HeadMaxLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *HeadMaxLenRule) Name() string { return "header-max-length" } - -// Apply sets the needed argument for the rule -func (r *HeadMaxLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates HeadMaxLenRule -func (r *HeadMaxLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMaxLen("header", r.CheckLen, msg.Header()) -} diff --git a/rule/header_min_length.go b/rule/header_min_length.go deleted file mode 100644 index 6411f84..0000000 --- a/rule/header_min_length.go +++ /dev/null @@ -1,29 +0,0 @@ -package rule - -import ( - "github.com/conventionalcommit/commitlint/lint" -) - -var _ lint.Rule = (*HeadMinLenRule)(nil) - -// HeadMinLenRule to validate min length of header -type HeadMinLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *HeadMinLenRule) Name() string { return "header-min-length" } - -// Apply sets the needed argument for the rule -func (r *HeadMinLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates HeadMinLenRule -func (r *HeadMinLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMinLen("header", r.CheckLen, msg.Header()) -} 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/length_rules.go b/rule/length_rules.go new file mode 100644 index 0000000..2e34931 --- /dev/null +++ b/rule/length_rules.go @@ -0,0 +1,209 @@ +package rule + +import "github.com/conventionalcommit/commitlint/lint" + +// Compile-time interface checks +var ( + _ lint.Rule = (*HeadMinLenRule)(nil) + _ lint.Rule = (*HeadMaxLenRule)(nil) + _ lint.Rule = (*BodyMinLenRule)(nil) + _ lint.Rule = (*BodyMaxLenRule)(nil) + _ lint.Rule = (*BodyMaxLineLenRule)(nil) + _ lint.Rule = (*FooterMinLenRule)(nil) + _ lint.Rule = (*FooterMaxLenRule)(nil) + _ lint.Rule = (*FooterMaxLineLenRule)(nil) + _ lint.Rule = (*TypeMinLenRule)(nil) + _ lint.Rule = (*TypeMaxLenRule)(nil) + _ lint.Rule = (*ScopeMinLenRule)(nil) + _ lint.Rule = (*ScopeMaxLenRule)(nil) + _ lint.Rule = (*DescriptionMinLenRule)(nil) + _ lint.Rule = (*DescriptionMaxLenRule)(nil) +) + +// applyIntArg is a shared helper that extracts an int argument from a RuleSetting. +func applyIntArg(dst *int, ruleName string, setting lint.RuleSetting) error { + if err := setIntArg(dst, setting.Argument); err != nil { + return errInvalidArg(ruleName, err) + } + return nil +} + +// --- Header --- + +// HeadMinLenRule to validate min length of header +type HeadMinLenRule struct{ CheckLen int } + +func (r *HeadMinLenRule) Name() string { return "header-min-length" } +func (r *HeadMinLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *HeadMinLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMinLen("header", r.CheckLen, msg.Header()) +} + +// HeadMaxLenRule to validate max length of header +type HeadMaxLenRule struct{ CheckLen int } + +func (r *HeadMaxLenRule) Name() string { return "header-max-length" } +func (r *HeadMaxLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *HeadMaxLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMaxLen("header", r.CheckLen, msg.Header()) +} + +// --- Body --- + +// BodyMinLenRule to validate min length of body +type BodyMinLenRule struct{ CheckLen int } + +func (r *BodyMinLenRule) Name() string { return "body-min-length" } +func (r *BodyMinLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *BodyMinLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMinLen("body", r.CheckLen, msg.Body()) +} + +// BodyMaxLenRule to validate max length of body +type BodyMaxLenRule struct{ CheckLen int } + +func (r *BodyMaxLenRule) Name() string { return "body-max-length" } +func (r *BodyMaxLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *BodyMaxLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMaxLen("body", r.CheckLen, msg.Body()) +} + +// BodyMaxLineLenRule to validate max line length of body +type BodyMaxLineLenRule struct{ CheckLen int } + +func (r *BodyMaxLineLenRule) Name() string { return "body-max-line-length" } +func (r *BodyMaxLineLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *BodyMaxLineLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMaxLineLength("body", r.CheckLen, msg.Body()) +} + +// --- Footer --- + +// FooterMinLenRule to validate min length of footer +type FooterMinLenRule struct{ CheckLen int } + +func (r *FooterMinLenRule) Name() string { return "footer-min-length" } +func (r *FooterMinLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *FooterMinLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMinLen("footer", r.CheckLen, msg.Footer()) +} + +// FooterMaxLenRule to validate max length of footer +type FooterMaxLenRule struct{ CheckLen int } + +func (r *FooterMaxLenRule) Name() string { return "footer-max-length" } +func (r *FooterMaxLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *FooterMaxLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMaxLen("footer", r.CheckLen, msg.Footer()) +} + +// FooterMaxLineLenRule to validate max line length of footer +type FooterMaxLineLenRule struct{ CheckLen int } + +func (r *FooterMaxLineLenRule) Name() string { return "footer-max-line-length" } +func (r *FooterMaxLineLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *FooterMaxLineLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMaxLineLength("footer", r.CheckLen, msg.Footer()) +} + +// --- Type --- + +// TypeMinLenRule to validate min length of type +type TypeMinLenRule struct{ CheckLen int } + +func (r *TypeMinLenRule) Name() string { return "type-min-length" } +func (r *TypeMinLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *TypeMinLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMinLen("type", r.CheckLen, msg.Type()) +} + +// TypeMaxLenRule to validate max length of type +type TypeMaxLenRule struct{ CheckLen int } + +func (r *TypeMaxLenRule) Name() string { return "type-max-length" } +func (r *TypeMaxLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *TypeMaxLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMaxLen("type", r.CheckLen, msg.Type()) +} + +// --- Scope --- + +// ScopeMinLenRule to validate min length of scope +type ScopeMinLenRule struct{ CheckLen int } + +func (r *ScopeMinLenRule) Name() string { return "scope-min-length" } +func (r *ScopeMinLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *ScopeMinLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMinLen("scope", r.CheckLen, msg.Scope()) +} + +// ScopeMaxLenRule to validate max length of scope +type ScopeMaxLenRule struct{ CheckLen int } + +func (r *ScopeMaxLenRule) Name() string { return "scope-max-length" } +func (r *ScopeMaxLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *ScopeMaxLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMaxLen("scope", r.CheckLen, msg.Scope()) +} + +// --- Description --- + +// DescriptionMinLenRule to validate min length of description +type DescriptionMinLenRule struct{ CheckLen int } + +func (r *DescriptionMinLenRule) Name() string { return "description-min-length" } +func (r *DescriptionMinLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *DescriptionMinLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMinLen("description", r.CheckLen, msg.Description()) +} + +// DescriptionMaxLenRule to validate max length of description +type DescriptionMaxLenRule struct{ CheckLen int } + +func (r *DescriptionMaxLenRule) Name() string { return "description-max-length" } +func (r *DescriptionMaxLenRule) Apply(s lint.RuleSetting) error { + return applyIntArg(&r.CheckLen, r.Name(), s) +} + +func (r *DescriptionMaxLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { + return validateMaxLen("description", r.CheckLen, msg.Description()) +} diff --git a/rule/rule.go b/rule/rule.go index 5c39ac1..3523415 100644 --- a/rule/rule.go +++ b/rule/rule.go @@ -14,7 +14,7 @@ func errInvalidArg(ruleName string, err error) error { } func errNeedAtleastOneArg(ruleName, msg string) error { - return fmt.Errorf("%s: need atleast one argument for %s", ruleName, msg) + return fmt.Errorf("%s: need at least one argument for %s", ruleName, msg) } func errMissingArg(ruleName, argName string) error { @@ -26,7 +26,7 @@ func errInvalidFlag(ruleName, flagName string, err error) error { } func formMinLenMsg(typ string, actualLen, expectedLen int) string { - return fmt.Sprintf("%s length is %d, should have atleast %d chars", typ, actualLen, expectedLen) + return fmt.Sprintf("%s length is %d, should have at least %d chars", typ, actualLen, expectedLen) } func formMaxLenDesc(typ string, actualLen, expectedLen int) string { diff --git a/rule/scope_charset.go b/rule/scope_charset.go deleted file mode 100644 index 6afa7be..0000000 --- a/rule/scope_charset.go +++ /dev/null @@ -1,36 +0,0 @@ -package rule - -import ( - "github.com/conventionalcommit/commitlint/lint" -) - -var _ lint.Rule = (*ScopeCharsetRule)(nil) - -// ScopeCharsetRule to validate max length of header -type ScopeCharsetRule struct { - Charset string -} - -// Name return name of the rule -func (r *ScopeCharsetRule) Name() string { return "scope-charset" } - -// Apply sets the needed argument for the rule -func (r *ScopeCharsetRule) Apply(setting lint.RuleSetting) error { - err := setStringArg(&r.Charset, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates ScopeCharsetRule -func (r *ScopeCharsetRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - invalidChars, isValid := validateCharset(r.Charset, msg.Scope()) - if isValid { - return nil, true - } - - desc := "type can only have these chars [" + r.Charset + "]" - err := "invalid characters [" + invalidChars + "]" - return lint.NewIssue(desc, err), false -} diff --git a/rule/scope_enum.go b/rule/scope_enum.go index 71b45c9..3b63911 100644 --- a/rule/scope_enum.go +++ b/rule/scope_enum.go @@ -9,7 +9,7 @@ import ( var _ lint.Rule = (*ScopeEnumRule)(nil) -// ScopeEnumRule to validate max length of header +// ScopeEnumRule to validate scope against a list of allowed values type ScopeEnumRule struct { Scopes []string diff --git a/rule/scope_max_length.go b/rule/scope_max_length.go deleted file mode 100644 index 270201b..0000000 --- a/rule/scope_max_length.go +++ /dev/null @@ -1,27 +0,0 @@ -package rule - -import "github.com/conventionalcommit/commitlint/lint" - -var _ lint.Rule = (*ScopeMaxLenRule)(nil) - -// ScopeMaxLenRule to validate max length of type -type ScopeMaxLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *ScopeMaxLenRule) Name() string { return "scope-max-length" } - -// Apply sets the needed argument for the rule -func (r *ScopeMaxLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates ScopeMaxLenRule -func (r *ScopeMaxLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMaxLen("scope", r.CheckLen, msg.Scope()) -} diff --git a/rule/scope_min_length.go b/rule/scope_min_length.go deleted file mode 100644 index 814d5b3..0000000 --- a/rule/scope_min_length.go +++ /dev/null @@ -1,27 +0,0 @@ -package rule - -import "github.com/conventionalcommit/commitlint/lint" - -var _ lint.Rule = (*ScopeMinLenRule)(nil) - -// ScopeMinLenRule to validate min length of scope -type ScopeMinLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *ScopeMinLenRule) Name() string { return "scope-min-length" } - -// Apply sets the needed argument for the rule -func (r *ScopeMinLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates ScopeMinLenRule -func (r *ScopeMinLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMinLen("scope", r.CheckLen, msg.Scope()) -} 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/rule/type_charset.go b/rule/type_charset.go deleted file mode 100644 index a29ec57..0000000 --- a/rule/type_charset.go +++ /dev/null @@ -1,37 +0,0 @@ -package rule - -import ( - "github.com/conventionalcommit/commitlint/lint" -) - -var _ lint.Rule = (*TypeCharsetRule)(nil) - -// TypeCharsetRule to validate max length of header -type TypeCharsetRule struct { - Charset string -} - -// Name return name of the rule -func (r *TypeCharsetRule) Name() string { return "type-charset" } - -// Apply sets the needed argument for the rule -func (r *TypeCharsetRule) Apply(setting lint.RuleSetting) error { - err := setStringArg(&r.Charset, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates TypeCharsetRule -func (r *TypeCharsetRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - invalidChars, isValid := validateCharset(r.Charset, msg.Type()) - if isValid { - return nil, true - } - - desc := "type can only have chars [" + r.Charset + "]" - info := "invalid characters [" + invalidChars + "]" - - return lint.NewIssue(desc, info), false -} diff --git a/rule/type_max_length.go b/rule/type_max_length.go deleted file mode 100644 index a8c81d0..0000000 --- a/rule/type_max_length.go +++ /dev/null @@ -1,27 +0,0 @@ -package rule - -import "github.com/conventionalcommit/commitlint/lint" - -var _ lint.Rule = (*TypeMaxLenRule)(nil) - -// TypeMaxLenRule to validate max length of type -type TypeMaxLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *TypeMaxLenRule) Name() string { return "type-max-length" } - -// Apply sets the needed argument for the rule -func (r *TypeMaxLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates TypeMaxLenRule -func (r *TypeMaxLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMaxLen("type", r.CheckLen, msg.Type()) -} diff --git a/rule/type_min_length.go b/rule/type_min_length.go deleted file mode 100644 index 1039ef4..0000000 --- a/rule/type_min_length.go +++ /dev/null @@ -1,27 +0,0 @@ -package rule - -import "github.com/conventionalcommit/commitlint/lint" - -var _ lint.Rule = (*TypeMinLenRule)(nil) - -// TypeMinLenRule to validate min length of type -type TypeMinLenRule struct { - CheckLen int -} - -// Name return name of the rule -func (r *TypeMinLenRule) Name() string { return "type-min-length" } - -// Apply sets the needed argument for the rule -func (r *TypeMinLenRule) Apply(setting lint.RuleSetting) error { - err := setIntArg(&r.CheckLen, setting.Argument) - if err != nil { - return errInvalidArg(r.Name(), err) - } - return nil -} - -// Validate validates TypeMinLenRule -func (r *TypeMinLenRule) Validate(msg lint.Commit) (*lint.Issue, bool) { - return validateMinLen("type", r.CheckLen, msg.Type()) -} diff --git a/test/config_test.go b/test/config_test.go new file mode 100644 index 0000000..7ce2f16 --- /dev/null +++ b/test/config_test.go @@ -0,0 +1,447 @@ +package test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/conventionalcommit/commitlint/config" + "github.com/conventionalcommit/commitlint/lint" +) + +func TestConfig_NewDefault(t *testing.T) { + conf := config.NewDefault() + + if conf.MinVersion == "" { + t.Error("expected non-empty MinVersion") + } + if conf.Formatter == "" { + t.Error("expected non-empty Formatter") + } + if conf.Formatter != "default" { + t.Errorf("expected formatter 'default', got %q", conf.Formatter) + } + if len(conf.Rules) == 0 { + t.Error("expected non-empty rules") + } + if len(conf.Settings) == 0 { + t.Error("expected non-empty settings") + } + if conf.Severity.Default != lint.SeverityError { + t.Errorf("expected default severity 'error', got %q", conf.Severity.Default) + } + if len(conf.DefaultIgnorePatterns) == 0 { + t.Error("expected non-empty default ignore patterns") + } + if len(conf.IgnorePatterns) != 0 { + t.Error("expected empty user ignore patterns in default config") + } +} + +func TestConfig_NewLinter(t *testing.T) { + conf := config.NewDefault() + linter, err := config.NewLinter(conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if linter == nil { + t.Fatal("expected non-nil linter") + } +} + +func TestConfig_GetFormatter(t *testing.T) { + conf := config.NewDefault() + f, err := config.GetFormatter(conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f == nil { + t.Fatal("expected non-nil formatter") + } + if f.Name() != "default" { + t.Errorf("expected formatter name 'default', got %q", f.Name()) + } +} + +func TestConfig_GetFormatterJSON(t *testing.T) { + conf := config.NewDefault() + conf.Formatter = "json" + f, err := config.GetFormatter(conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if f.Name() != "json" { + t.Errorf("expected formatter name 'json', got %q", f.Name()) + } +} + +func TestConfig_GetFormatterUnknown(t *testing.T) { + conf := config.NewDefault() + conf.Formatter = "unknown" + _, err := config.GetFormatter(conf) + if err == nil { + t.Error("expected error for unknown formatter") + } +} + +func TestConfig_Validate_Valid(t *testing.T) { + conf := config.NewDefault() + errs := config.Validate(conf) + if len(errs) != 0 { + t.Errorf("expected no validation errors, got %d:", len(errs)) + for _, e := range errs { + t.Errorf(" - %v", e) + } + } +} + +func TestConfig_Validate_InvalidFormatter(t *testing.T) { + conf := config.NewDefault() + conf.Formatter = "nonexistent" + errs := config.Validate(conf) + if len(errs) == 0 { + t.Error("expected validation errors for unknown formatter") + } +} + +func TestConfig_Validate_EmptyFormatter(t *testing.T) { + conf := config.NewDefault() + conf.Formatter = "" + errs := config.Validate(conf) + if len(errs) == 0 { + t.Error("expected validation errors for empty formatter") + } +} + +func TestConfig_Validate_InvalidSeverity(t *testing.T) { + conf := config.NewDefault() + conf.Severity.Default = "invalid" + errs := config.Validate(conf) + if len(errs) == 0 { + t.Error("expected validation errors for invalid severity") + } +} + +func TestConfig_Validate_InvalidRuleSeverity(t *testing.T) { + conf := config.NewDefault() + conf.Severity.Rules = map[string]lint.Severity{ + "type-enum": "invalid-severity", + } + errs := config.Validate(conf) + if len(errs) == 0 { + t.Error("expected validation errors for invalid rule severity") + } +} + +func TestConfig_Validate_UnknownRule(t *testing.T) { + conf := config.NewDefault() + conf.Rules = append(conf.Rules, "nonexistent-rule") + errs := config.Validate(conf) + if len(errs) == 0 { + t.Error("expected validation errors for unknown rule") + } +} + +func TestConfig_Validate_InvalidIgnorePattern(t *testing.T) { + conf := config.NewDefault() + conf.IgnorePatterns = []string{`[invalid`} + errs := config.Validate(conf) + if len(errs) == 0 { + t.Error("expected validation errors for invalid ignore pattern") + } +} + +func TestConfig_Validate_ValidIgnorePattern(t *testing.T) { + conf := config.NewDefault() + conf.IgnorePatterns = []string{`^Merge .*`} + errs := config.Validate(conf) + for _, e := range errs { + t.Errorf("unexpected validation error: %v", e) + } +} + +func TestConfig_WriteCompactTo(t *testing.T) { + conf := config.NewDefault() + var buf bytes.Buffer + err := config.WriteCompactTo(&buf, conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := buf.String() + if output == "" { + t.Error("expected non-empty output from WriteCompactTo") + } + + // Should only contain settings for the 5 enabled rules, not all 20 + for _, disabled := range []string{"body-min-length", "body-max-length", "type-min-length", "scope-charset"} { + if bytes.Contains(buf.Bytes(), []byte(disabled+":")) { + t.Errorf("should not contain disabled rule setting %q", disabled) + } + } + for _, enabled := range []string{"header-min-length", "header-max-length", "type-enum"} { + if !bytes.Contains(buf.Bytes(), []byte(enabled+":")) { + t.Errorf("should contain enabled rule setting %q", enabled) + } + } +} + +func TestConfig_WriteCompactTo_WithUserIgnores(t *testing.T) { + conf := config.NewDefault() + conf.IgnorePatterns = []string{`^WIP `} + var buf bytes.Buffer + err := config.WriteCompactTo(&buf, conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Contains(buf.Bytes(), []byte("ignores")) { + t.Error("expected 'ignores' field in YAML output when user patterns exist") + } +} + +func TestConfig_Parse_ValidFile(t *testing.T) { + tmpDir := t.TempDir() + confPath := filepath.Join(tmpDir, "commitlint.yaml") + + confContent := `min-version: v0.9.0 +formatter: default +rules: + - header-min-length + - header-max-length + - type-enum +severity: + default: error +settings: + header-min-length: + argument: 10 + header-max-length: + argument: 50 + type-enum: + argument: + - feat + - fix +` + err := os.WriteFile(confPath, []byte(confContent), 0o644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + conf, err := config.Parse(confPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if conf.Formatter != "default" { + t.Errorf("expected formatter 'default', got %q", conf.Formatter) + } + if len(conf.Rules) != 3 { + t.Errorf("expected 3 rules, got %d", len(conf.Rules)) + } +} + +func TestConfig_Parse_OldVersionKey(t *testing.T) { + tmpDir := t.TempDir() + confPath := filepath.Join(tmpDir, "commitlint.yaml") + + // Uses the old "version:" key for backward compatibility + confContent := `version: v0.9.0 +formatter: default +rules: + - header-min-length +severity: + default: error +settings: + header-min-length: + argument: 10 +` + err := os.WriteFile(confPath, []byte(confContent), 0o644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + conf, err := config.Parse(confPath) + if err != nil { + t.Fatalf("unexpected error parsing old 'version' key: %v", err) + } + + if conf.MinVersion != "v0.9.0" { + t.Errorf("expected MinVersion 'v0.9.0', got %q", conf.MinVersion) + } +} + +func TestConfig_Parse_WithIgnores(t *testing.T) { + tmpDir := t.TempDir() + confPath := filepath.Join(tmpDir, "commitlint.yaml") + + confContent := `min-version: v0.9.0 +formatter: default +rules: + - header-min-length +severity: + default: error +settings: + header-min-length: + argument: 10 +ignores: + - "^WIP " + - "^TICKET-\\d+" +` + err := os.WriteFile(confPath, []byte(confContent), 0o644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + conf, err := config.Parse(confPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(conf.IgnorePatterns) != 2 { + t.Errorf("expected 2 ignore patterns, got %d", len(conf.IgnorePatterns)) + } +} + +func TestConfig_Parse_WithoutIgnores_UsesDefaults(t *testing.T) { + tmpDir := t.TempDir() + confPath := filepath.Join(tmpDir, "commitlint.yaml") + + confContent := `min-version: v0.9.0 +formatter: default +rules: + - header-min-length +severity: + default: error +settings: + header-min-length: + argument: 10 +` + err := os.WriteFile(confPath, []byte(confContent), 0o644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + conf, err := config.Parse(confPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(conf.DefaultIgnorePatterns) == 0 { + t.Error("expected default ignore patterns to be populated by Parse") + } + if len(conf.IgnorePatterns) != 0 { + t.Error("expected empty user ignore patterns when not specified in config") + } +} + +func TestConfig_Parse_DisableDefaultIgnores(t *testing.T) { + tmpDir := t.TempDir() + confPath := filepath.Join(tmpDir, "commitlint.yaml") + + confContent := `min-version: v0.9.0 +formatter: default +rules: + - header-min-length +severity: + default: error +settings: + header-min-length: + argument: 10 +disable-default-ignores: true +ignores: + - "^WIP " +` + err := os.WriteFile(confPath, []byte(confContent), 0o644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + conf, err := config.Parse(confPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !conf.DisableDefaultIgnores { + t.Error("expected DisableDefaultIgnores to be true") + } + if len(conf.IgnorePatterns) != 1 { + t.Errorf("expected 1 user ignore pattern, got %d", len(conf.IgnorePatterns)) + } + effective := conf.EffectiveIgnorePatterns() + if len(effective) != 1 { + t.Errorf("expected 1 effective pattern (defaults disabled), got %d", len(effective)) + } +} + +func TestConfig_Parse_NonExistentFile(t *testing.T) { + _, err := config.Parse("/nonexistent/path/commitlint.yaml") + if err == nil { + t.Error("expected error for non-existent config file") + } +} + +func TestConfig_Parse_InvalidYAML(t *testing.T) { + tmpDir := t.TempDir() + confPath := filepath.Join(tmpDir, "commitlint.yaml") + + err := os.WriteFile(confPath, []byte("invalid: yaml: content: ["), 0o644) + if err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + _, err = config.Parse(confPath) + if err == nil { + t.Error("expected error for invalid YAML") + } +} + +func TestConfig_GetEnabledRules(t *testing.T) { + conf := config.NewDefault() + rules, err := config.GetEnabledRules(conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rules) != len(conf.Rules) { + t.Errorf("expected %d rules, got %d", len(conf.Rules), len(rules)) + } +} + +func TestConfig_GetEnabledRules_DuplicateRules(t *testing.T) { + conf := config.NewDefault() + conf.Rules = append(conf.Rules, conf.Rules[0]) + + rules, err := config.GetEnabledRules(conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rules) != len(conf.Rules)-1 { + t.Errorf("expected duplicates to be removed, got %d rules", len(rules)) + } +} + +func TestConfig_GetEnabledRules_UnknownRule(t *testing.T) { + conf := config.NewDefault() + conf.Rules = []string{"nonexistent-rule"} + + _, err := config.GetEnabledRules(conf) + if err == nil { + t.Error("expected error for unknown rule") + } +} + +func TestConfig_SeverityString(t *testing.T) { + tests := []struct { + severity lint.Severity + expected string + }{ + {lint.SeverityError, "Error"}, + {lint.SeverityWarn, "Warning"}, + {lint.Severity("unknown"), "Severity(unknown)"}, + } + + for _, tc := range tests { + got := tc.severity.String() + if got != tc.expected { + t.Errorf("Severity(%q).String() = %q, want %q", tc.severity, got, tc.expected) + } + } +} diff --git a/test/formatter_test.go b/test/formatter_test.go new file mode 100644 index 0000000..ffa04d4 --- /dev/null +++ b/test/formatter_test.go @@ -0,0 +1,158 @@ +package test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/conventionalcommit/commitlint/formatter" +) + +func TestDefaultFormatter_Name(t *testing.T) { + f := &formatter.DefaultFormatter{} + if f.Name() != "default" { + t.Errorf("expected 'default', got %q", f.Name()) + } +} + +func TestDefaultFormatter_NoIssues(t *testing.T) { + linter := newDefaultLinter(t) + result, err := linter.ParseAndLint("feat: valid commit message") + if err != nil { + t.Fatal(err) + } + f := &formatter.DefaultFormatter{} + out, fErr := f.Format(result) + if fErr != nil { + t.Fatal(fErr) + } + if !strings.Contains(out, "\u2714") { + t.Errorf("expected checkmark for valid commit, got %q", out) + } +} + +func TestDefaultFormatter_WithIssues(t *testing.T) { + linter := newDefaultLinter(t) + result, err := linter.ParseAndLint("invalid commit no type") + if err != nil { + t.Fatal(err) + } + f := &formatter.DefaultFormatter{} + out, fErr := f.Format(result) + if fErr != nil { + t.Fatal(fErr) + } + if strings.Contains(out, "\u2714") { + t.Errorf("should not have checkmark for invalid commit, got %q", out) + } + if !strings.Contains(out, "commitlint") { + t.Errorf("expected 'commitlint' header in output, got %q", out) + } +} + +func TestJSONFormatter_Name(t *testing.T) { + f := &formatter.JSONFormatter{} + if f.Name() != "json" { + t.Errorf("expected 'json', got %q", f.Name()) + } +} + +func TestJSONFormatter_NoIssues(t *testing.T) { + linter := newDefaultLinter(t) + result, err := linter.ParseAndLint("feat: valid commit message") + if err != nil { + t.Fatal(err) + } + f := &formatter.JSONFormatter{} + out, fErr := f.Format(result) + if fErr != nil { + t.Fatal(fErr) + } + var parsed map[string]interface{} + if jErr := json.Unmarshal([]byte(out), &parsed); jErr != nil { + t.Fatalf("invalid JSON output: %v", jErr) + } + issues, ok := parsed["issues"].([]interface{}) + if !ok { + t.Fatal("expected 'issues' array in JSON output") + } + if len(issues) != 0 { + t.Errorf("expected 0 issues, got %d", len(issues)) + } +} + +func TestJSONFormatter_WithIssues(t *testing.T) { + linter := newDefaultLinter(t) + result, err := linter.ParseAndLint("invalid commit no type") + if err != nil { + t.Fatal(err) + } + f := &formatter.JSONFormatter{} + out, fErr := f.Format(result) + if fErr != nil { + t.Fatal(fErr) + } + var parsed map[string]interface{} + if jErr := json.Unmarshal([]byte(out), &parsed); jErr != nil { + t.Fatalf("invalid JSON output: %v", jErr) + } + issues, ok := parsed["issues"].([]interface{}) + if !ok { + t.Fatal("expected 'issues' array in JSON") + } + if len(issues) == 0 { + t.Error("expected at least one issue for invalid commit") + } + // Verify each issue has mandatory fields + for i, raw := range issues { + entry, ok := raw.(map[string]interface{}) + if !ok { + t.Fatalf("issue[%d] is not a map", i) + } + for _, field := range []string{"name", "severity", "description"} { + if _, exists := entry[field]; !exists { + t.Errorf("issue[%d] missing field %q", i, field) + } + } + } +} + +func TestJSONFormatter_InputField(t *testing.T) { + linter := newDefaultLinter(t) + msg := "feat: check input field" + result, err := linter.ParseAndLint(msg) + if err != nil { + t.Fatal(err) + } + f := &formatter.JSONFormatter{} + out, fErr := f.Format(result) + if fErr != nil { + t.Fatal(fErr) + } + var parsed map[string]interface{} + if jErr := json.Unmarshal([]byte(out), &parsed); jErr != nil { + t.Fatal(jErr) + } + if parsed["input"] != msg { + t.Errorf("expected input %q, got %q", msg, parsed["input"]) + } +} + +func TestDefaultFormatter_TruncatesLongInput(t *testing.T) { + linter := newDefaultLinter(t) + // Create a commit that will fail (no type) but has a very long first line + longMsg := "this is a very long commit message without a type that will definitely be truncated in the output" + result, err := linter.ParseAndLint(longMsg) + if err != nil { + t.Fatal(err) + } + f := &formatter.DefaultFormatter{} + out, fErr := f.Format(result) + if fErr != nil { + t.Fatal(fErr) + } + // The default formatter truncates input to 25 chars + if !strings.Contains(out, "...") { + t.Errorf("expected truncation with '...' in output, got %q", out) + } +} diff --git a/test/helpers_test.go b/test/helpers_test.go new file mode 100644 index 0000000..d9380b7 --- /dev/null +++ b/test/helpers_test.go @@ -0,0 +1,55 @@ +package test + +import ( + "testing" + + "github.com/conventionalcommit/commitlint/config" + "github.com/conventionalcommit/commitlint/lint" +) + +// mockCommit implements lint.Commit for testing +type mockCommit struct { + message string + header string + body string + footer string + typ string + scope string + description string + notes []lint.Note + breaking bool +} + +func (m *mockCommit) Message() string { return m.message } +func (m *mockCommit) Header() string { return m.header } +func (m *mockCommit) Body() string { return m.body } +func (m *mockCommit) Footer() string { return m.footer } +func (m *mockCommit) Type() string { return m.typ } +func (m *mockCommit) Scope() string { return m.scope } +func (m *mockCommit) Description() string { return m.description } +func (m *mockCommit) Notes() []lint.Note { return m.notes } +func (m *mockCommit) IsBreakingChange() bool { return m.breaking } + +// mockNote implements lint.Note for testing +type mockNote struct { + token string + value string +} + +func (n *mockNote) Token() string { return n.token } +func (n *mockNote) Value() string { return n.value } + +// newDefaultLinter creates a linter with default config for testing +func newDefaultLinter(t *testing.T) *lint.Linter { + t.Helper() + conf := config.NewDefault() + rules, err := config.GetEnabledRules(conf) + if err != nil { + t.Fatalf("failed to get enabled rules: %v", err) + } + linter, err := lint.New(conf, rules) + if err != nil { + t.Fatalf("failed to create linter: %v", err) + } + return linter +} diff --git a/test/ignore_test.go b/test/ignore_test.go new file mode 100644 index 0000000..99a5b99 --- /dev/null +++ b/test/ignore_test.go @@ -0,0 +1,347 @@ +package test + +import ( + "testing" + + "github.com/conventionalcommit/commitlint/config" + "github.com/conventionalcommit/commitlint/lint" +) + +// --- Ignore pattern tests --- + +func TestIgnore_MergePullRequest(t *testing.T) { + msgs := []string{ + "Merge pull request #123 from owner/branch", + "Merge pull request #1 from a/b", + } + linter := newDefaultLinter(t) + for _, msg := range msgs { + result, err := linter.ParseAndLint(msg) + if err != nil { + t.Fatalf("error for %q: %v", msg, err) + } + if len(result.Issues()) != 0 { + t.Errorf("expected %q to be ignored, got %d issues", msg, len(result.Issues())) + } + } +} + +func TestIgnore_MergeBranch(t *testing.T) { + msgs := []string{ + "Merge branch 'feature-x'", + "Merge branch 'release/1.0' into main", + "Merge feature into main", + "Merge tag 'v1.0.0'", + "Merge remote-tracking branch 'origin/main'", + "Merge remote-tracking branch 'origin/main' into develop", + } + linter := newDefaultLinter(t) + for _, msg := range msgs { + result, err := linter.ParseAndLint(msg) + if err != nil { + t.Fatalf("error for %q: %v", msg, err) + } + if len(result.Issues()) != 0 { + t.Errorf("expected %q to be ignored, got %d issues", msg, len(result.Issues())) + } + } +} + +func TestIgnore_MergedPR(t *testing.T) { + msgs := []string{ + "Merged PR #456: Add new feature", + "Merged PR 789: Fix bug", + "Merged feature-branch in main", + "Merged feature-branch into main", + } + linter := newDefaultLinter(t) + for _, msg := range msgs { + result, err := linter.ParseAndLint(msg) + if err != nil { + t.Fatalf("error for %q: %v", msg, err) + } + if len(result.Issues()) != 0 { + t.Errorf("expected %q to be ignored, got %d issues", msg, len(result.Issues())) + } + } +} + +func TestIgnore_RevertReapply(t *testing.T) { + msgs := []string{ + `Revert "feat: add new feature"`, + `revert "fix: something"`, + "Revert some commit", + `Reapply "feat: add feature"`, + `reapply "fix: something"`, + } + linter := newDefaultLinter(t) + for _, msg := range msgs { + result, err := linter.ParseAndLint(msg) + if err != nil { + t.Fatalf("error for %q: %v", msg, err) + } + if len(result.Issues()) != 0 { + t.Errorf("expected %q to be ignored, got %d issues", msg, len(result.Issues())) + } + } +} + +func TestIgnore_FixupSquashAmend(t *testing.T) { + msgs := []string{ + "fixup! feat: add something", + "squash! fix: repair something", + "amend! chore: update deps", + } + linter := newDefaultLinter(t) + for _, msg := range msgs { + result, err := linter.ParseAndLint(msg) + if err != nil { + t.Fatalf("error for %q: %v", msg, err) + } + if len(result.Issues()) != 0 { + t.Errorf("expected %q to be ignored, got %d issues", msg, len(result.Issues())) + } + } +} + +func TestIgnore_AutomaticMerge(t *testing.T) { + msgs := []string{ + "Automatic merge from release/1.0 to main", + "Auto-merged feature-x into main", + } + linter := newDefaultLinter(t) + for _, msg := range msgs { + result, err := linter.ParseAndLint(msg) + if err != nil { + t.Fatalf("error for %q: %v", msg, err) + } + if len(result.Issues()) != 0 { + t.Errorf("expected %q to be ignored, got %d issues", msg, len(result.Issues())) + } + } +} + +func TestIgnore_InitialCommit(t *testing.T) { + linter := newDefaultLinter(t) + result, err := linter.ParseAndLint("Initial commit") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues()) != 0 { + t.Errorf("expected 'Initial commit' to be ignored, got %d issues", len(result.Issues())) + } +} + +func TestIgnore_MultilineFirstLineMatches(t *testing.T) { + msg := "Merge pull request #100 from org/branch\n\nAdditional body text" + linter := newDefaultLinter(t) + result, err := linter.ParseAndLint(msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues()) != 0 { + t.Errorf("expected multiline merge to be ignored, got %d issues", len(result.Issues())) + } +} + +func TestIgnore_NotIgnored(t *testing.T) { + msgs := []string{ + "merge something random", + "fixup something", + "initial commit", + "just some random text", + } + linter := newDefaultLinter(t) + for _, msg := range msgs { + result, err := linter.ParseAndLint(msg) + if err != nil { + t.Fatalf("error for %q: %v", msg, err) + } + // These should NOT be ignored, should produce issues + if len(result.Issues()) == 0 { + t.Errorf("expected %q to NOT be ignored (should have issues)", msg) + } + } +} + +func TestIgnore_EmptyPatterns_NoSkip(t *testing.T) { + conf := config.NewDefault() + conf.DisableDefaultIgnores = true + conf.IgnorePatterns = []string{} + + rules, err := config.GetEnabledRules(conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + linter, err := lint.New(conf, rules) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + result, err := linter.ParseAndLint("Merge pull request #123 from owner/branch") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues()) == 0 { + t.Error("expected merge to fail when all ignore patterns disabled") + } +} + +func TestIgnore_CustomPatternsAdditive(t *testing.T) { + conf := config.NewDefault() + conf.IgnorePatterns = []string{`^CUSTOM-\d+`, `^WIP `} + + rules, err := config.GetEnabledRules(conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + linter, err := lint.New(conf, rules) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Custom pattern matches + result, err := linter.ParseAndLint("CUSTOM-123 some ticket work") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues()) != 0 { + t.Errorf("expected custom pattern to be ignored, got %d issues", len(result.Issues())) + } + + // WIP matches + result, err = linter.ParseAndLint("WIP adding new feature") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues()) != 0 { + t.Errorf("expected WIP to be ignored, got %d issues", len(result.Issues())) + } + + // Default merge pattern STILL present (additive), should be ignored + result, err = linter.ParseAndLint("Merge pull request #123 from owner/branch") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues()) != 0 { + t.Error("merge should be ignored - user patterns are additive to defaults") + } +} + +func TestIgnore_CustomPatternsOnlyWhenDefaultsDisabled(t *testing.T) { + conf := config.NewDefault() + conf.DisableDefaultIgnores = true + conf.IgnorePatterns = []string{`^CUSTOM-\d+`} + + rules, err := config.GetEnabledRules(conf) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + linter, err := lint.New(conf, rules) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Custom pattern still matches + result, err := linter.ParseAndLint("CUSTOM-123 some ticket work") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues()) != 0 { + t.Errorf("expected custom pattern to be ignored, got %d issues", len(result.Issues())) + } + + // Default merge pattern should NOT be ignored + result, err = linter.ParseAndLint("Merge pull request #123 from owner/branch") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues()) == 0 { + t.Error("merge should fail when default ignores are disabled") + } +} + +func TestIgnore_InvalidPattern_LinterCreationFails(t *testing.T) { + conf := config.NewDefault() + conf.IgnorePatterns = []string{`^valid`, `[invalid`} + + rules, err := config.GetEnabledRules(conf) + if err != nil { + t.Fatalf("unexpected error getting rules: %v", err) + } + + _, err = lint.New(conf, rules) + if err == nil { + t.Error("expected error for invalid regex pattern") + } +} + +func TestIgnore_ValidationCatchesInvalidPattern(t *testing.T) { + conf := config.NewDefault() + conf.IgnorePatterns = []string{`[invalid`} + + errs := config.Validate(conf) + found := false + for _, e := range errs { + if e != nil { + found = true + } + } + if !found { + t.Error("expected validation error for invalid regex") + } +} + +func TestIgnore_DefaultPatternsExist(t *testing.T) { + patterns := config.DefaultIgnorePatterns() + if len(patterns) == 0 { + t.Fatal("expected default ignore patterns to be non-empty") + } +} + +func TestIgnore_DefaultConfigHasPatterns(t *testing.T) { + conf := config.NewDefault() + if len(conf.DefaultIgnorePatterns) == 0 { + t.Fatal("expected default config to have default ignore patterns") + } +} + +func TestIgnore_EffectiveIgnorePatterns(t *testing.T) { + conf := config.NewDefault() + + // Default: no user patterns, defaults enabled + effective := conf.EffectiveIgnorePatterns() + if len(effective) != len(conf.DefaultIgnorePatterns) { + t.Errorf("expected %d effective patterns, got %d", len(conf.DefaultIgnorePatterns), len(effective)) + } + + // Add user patterns: effective = defaults + user + conf.IgnorePatterns = []string{`^WIP `} + effective = conf.EffectiveIgnorePatterns() + expected := len(conf.DefaultIgnorePatterns) + 1 + if len(effective) != expected { + t.Errorf("expected %d effective patterns, got %d", expected, len(effective)) + } + + // Disable defaults: effective = user only + conf.DisableDefaultIgnores = true + effective = conf.EffectiveIgnorePatterns() + if len(effective) != 1 { + t.Errorf("expected 1 effective pattern (defaults disabled), got %d", len(effective)) + } +} + +func TestIgnore_IgnoredResultHasNoIssues(t *testing.T) { + linter := newDefaultLinter(t) + msg := "Merge pull request #42 from org/feature" + result, err := linter.ParseAndLint(msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Input() != msg { + t.Errorf("expected input preserved, got %q", result.Input()) + } + if len(result.Issues()) != 0 { + t.Errorf("expected no issues for ignored commit, got %d", len(result.Issues())) + } +} diff --git a/test/integration_test.sh b/test/integration_test.sh new file mode 100755 index 0000000..324ace1 --- /dev/null +++ b/test/integration_test.sh @@ -0,0 +1,479 @@ +#!/usr/bin/env bash + +# integration_test.sh - real-world git integration tests for commitlint +# +# Usage: +# ./integration_test.sh /path/to/commitlint [/path/to/tmpdir] +# +# If tmpdir is omitted, a temporary directory is created automatically. +# The script creates git repos, makes various commits, and verifies +# commitlint output for each scenario. + +set -euo pipefail + +# ─── Arguments ─────────────────────────────────────────────────────────────── + +COMMITLINT="${1:?Usage: $0 /path/to/commitlint [tmpdir]}" +TMPDIR_ROOT="${2:-$(mktemp -d)}" + +if [[ ! -x "$COMMITLINT" ]]; then + echo "ERROR: $COMMITLINT is not executable" >&2 + exit 1 +fi + +COMMITLINT="$(realpath "$COMMITLINT")" + +# ─── Counters ──────────────────────────────────────────────────────────────── + +PASS=0 +FAIL=0 +TOTAL=0 + +pass() { + PASS=$((PASS + 1)) + TOTAL=$((TOTAL + 1)) + echo " ✔ $1" +} + +fail() { + FAIL=$((FAIL + 1)) + TOTAL=$((TOTAL + 1)) + echo " ✘ $1" +} + +# Run commitlint lint on a message string. Sets $EXIT_CODE and $OUTPUT. +run_lint() { + local msg="$1" + set +e + OUTPUT=$(echo "$msg" | "$COMMITLINT" lint 2>&1) + EXIT_CODE=$? + set -e +} + +# Expect commitlint to succeed (exit 0) +expect_pass() { + local desc="$1" msg="$2" + run_lint "$msg" + if [[ $EXIT_CODE -eq 0 ]]; then + pass "$desc" + else + fail "$desc (expected pass, got exit $EXIT_CODE)" + echo " msg: $msg" + echo " out: $OUTPUT" + fi +} + +# Expect commitlint to fail (exit != 0) +expect_fail() { + local desc="$1" msg="$2" + run_lint "$msg" + if [[ $EXIT_CODE -ne 0 ]]; then + pass "$desc" + else + fail "$desc (expected fail, got exit 0)" + echo " msg: $msg" + echo " out: $OUTPUT" + fi +} + +# ─── Setup temp repo ──────────────────────────────────────────────────────── + +REPO="$TMPDIR_ROOT/test-repo" +mkdir -p "$REPO" +cd "$REPO" +git init -q +git config user.email "test@test.com" +git config user.name "Test User" +# Disable any global commit hooks so they don't interfere +git config core.hooksPath /dev/null + +# Create initial file so we have something to commit +echo "init" > file.txt +git add file.txt +git commit -q -m "feat: initial project setup with file" + +echo "" +echo "═══════════════════════════════════════════════════════════" +echo " commitlint integration tests" +echo " binary: $COMMITLINT" +echo " tmpdir: $TMPDIR_ROOT" +echo "═══════════════════════════════════════════════════════════" + +# ═════════════════════════════════════════════════════════════════════════════ +# 1. Valid conventional commits +# ═════════════════════════════════════════════════════════════════════════════ + +echo "" +echo "── 1. Valid conventional commits ──" + +expect_pass "feat with description" \ + "feat: add new login page" + +expect_pass "fix with scope" \ + "fix(auth): resolve token expiry issue" + +expect_pass "docs change" \ + "docs: update README with examples" + +expect_pass "chore with long description" \ + "chore: update dependencies to latest" + +expect_pass "refactor with scope" \ + "refactor(core): simplify parser logic" + +expect_pass "ci change" \ + "ci: add GitHub Actions workflow" + +expect_pass "build change" \ + "build: upgrade Go to 1.24" + +expect_pass "test addition" \ + "test: add unit tests for parser" + +expect_pass "perf improvement" \ + "perf: optimize database queries" + +expect_pass "style change" \ + "style: format code with gofmt" + +expect_pass "revert type" \ + "revert: undo accidental deletion" + +expect_pass "multiline body" \ + "feat: add user profiles + +This adds a new user profile page with avatar support, +bio editing, and social links." + +# ═════════════════════════════════════════════════════════════════════════════ +# 2. Invalid conventional commits +# ═════════════════════════════════════════════════════════════════════════════ + +echo "" +echo "── 2. Invalid conventional commits ──" + +expect_fail "unknown type" \ + "fear: this type does not exist" + +expect_fail "missing colon" \ + "feat add something without colon" + +expect_fail "too short header" \ + "fix: x" + +expect_fail "random text" \ + "just some random text without structure" + +expect_fail "lowercase type with bad format" \ + "FEAT: uppercase type" + +# ═════════════════════════════════════════════════════════════════════════════ +# 3. Ignored: merge commits +# ═════════════════════════════════════════════════════════════════════════════ + +echo "" +echo "── 3. Ignored: merge commits ──" + +expect_pass "GitHub PR merge" \ + "Merge pull request #123 from owner/feature-branch" + +expect_pass "merge branch" \ + "Merge branch 'feature-x'" + +expect_pass "merge branch into" \ + "Merge branch 'release/1.0' into main" + +expect_pass "merge tag" \ + "Merge tag 'v1.0.0'" + +expect_pass "merge remote-tracking" \ + "Merge remote-tracking branch 'origin/main'" + +expect_pass "merge X into Y" \ + "Merge feature into main" + +expect_pass "Azure DevOps PR" \ + "Merged PR #456: Add new feature" + +expect_pass "Azure DevOps PR no hash" \ + "Merged PR 789: Fix bug" + +expect_pass "Merged X into Y" \ + "Merged feature-branch into main" + +expect_pass "Merged X in Y" \ + "Merged feature-branch in main" + +expect_pass "multiline merge" \ + "Merge pull request #100 from org/branch + +Additional context about the merge" + +# ═════════════════════════════════════════════════════════════════════════════ +# 4. Ignored: revert / reapply +# ═════════════════════════════════════════════════════════════════════════════ + +echo "" +echo "── 4. Ignored: revert / reapply ──" + +expect_pass "Revert with quotes" \ + 'Revert "feat: add new feature"' + +expect_pass "revert lowercase" \ + 'revert "fix: something"' + +expect_pass "Revert plain" \ + "Revert some commit" + +expect_pass "Reapply with quotes" \ + 'Reapply "feat: add feature"' + +expect_pass "reapply lowercase" \ + 'reapply "fix: something"' + +# ═════════════════════════════════════════════════════════════════════════════ +# 5. Ignored: fixup / squash / amend +# ═════════════════════════════════════════════════════════════════════════════ + +echo "" +echo "── 5. Ignored: fixup / squash / amend ──" + +expect_pass "fixup!" \ + "fixup! feat: add something" + +expect_pass "squash!" \ + "squash! fix: repair something" + +expect_pass "amend!" \ + "amend! chore: update deps" + +# ═════════════════════════════════════════════════════════════════════════════ +# 6. Ignored: automatic merges / initial commit +# ═════════════════════════════════════════════════════════════════════════════ + +echo "" +echo "── 6. Ignored: automatic merges / initial commit ──" + +expect_pass "Automatic merge" \ + "Automatic merge from release/1.0 to main" + +expect_pass "Auto-merged" \ + "Auto-merged feature-x into main" + +expect_pass "Initial commit" \ + "Initial commit" + +# ═════════════════════════════════════════════════════════════════════════════ +# 7. NOT ignored (look-alikes) +# ═════════════════════════════════════════════════════════════════════════════ + +echo "" +echo "── 7. NOT ignored (look-alikes) ──" + +expect_fail "lowercase merge" \ + "merge something random" + +expect_fail "fixup without bang" \ + "fixup something without bang" + +expect_fail "lowercase initial commit" \ + "initial commit" + +expect_fail "Initial commit with suffix" \ + "Initial commit with extra text" + +# ═════════════════════════════════════════════════════════════════════════════ +# 8. Real git merge commit (actual git operations) +# ═════════════════════════════════════════════════════════════════════════════ + +echo "" +echo "── 8. Real git merge commit ──" + +# Create a branch, make a commit, merge it +cd "$REPO" +git checkout -q -b test-feature +echo "feature" > feature.txt +git add feature.txt +git commit -q -m "feat: add feature file" + +git checkout -q master 2>/dev/null || git checkout -q main +# Do a no-ff merge to generate a real merge commit message +git merge --no-ff test-feature -q -m "Merge branch 'test-feature'" + +# Get the merge commit message +MERGE_MSG=$(git log -1 --format=%B) +expect_pass "real git merge commit" "$MERGE_MSG" + +# ═════════════════════════════════════════════════════════════════════════════ +# 9. Config file tests +# ═════════════════════════════════════════════════════════════════════════════ + +echo "" +echo "── 9. Config file tests ──" + +# Test with disable-default-ignores +CONF_DIR="$TMPDIR_ROOT/conf-tests" +mkdir -p "$CONF_DIR" + +cat > "$CONF_DIR/strict.yaml" << 'YAML' +min-version: v0.9.0 +formatter: default +rules: + - header-min-length + - header-max-length + - type-enum +severity: + default: error +settings: + header-min-length: + argument: 10 + header-max-length: + argument: 50 + type-enum: + argument: + - feat + - fix + - docs + - chore +disable-default-ignores: true +YAML + +# Merge commit should FAIL with defaults disabled +set +e +OUTPUT=$(echo "Merge pull request #123 from owner/branch" | "$COMMITLINT" lint --config="$CONF_DIR/strict.yaml" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -ne 0 ]]; then + pass "merge fails with disable-default-ignores: true" +else + fail "merge should fail with disable-default-ignores: true" +fi + +# Test with custom ignore pattern +cat > "$CONF_DIR/custom.yaml" << 'YAML' +min-version: v0.9.0 +formatter: default +rules: + - header-min-length + - header-max-length + - type-enum +severity: + default: error +settings: + header-min-length: + argument: 10 + header-max-length: + argument: 50 + type-enum: + argument: + - feat + - fix + - docs + - chore +ignores: + - "^WIP " + - "^TICKET-\\d+" +YAML + +# WIP should pass (custom pattern) +set +e +OUTPUT=$(echo "WIP adding new feature" | "$COMMITLINT" lint --config="$CONF_DIR/custom.yaml" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "WIP passes with custom ignores" +else + fail "WIP should pass with custom ignores" +fi + +# Merge should ALSO pass (defaults still active, additive) +set +e +OUTPUT=$(echo "Merge branch 'feature'" | "$COMMITLINT" lint --config="$CONF_DIR/custom.yaml" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "merge still passes with additive custom ignores" +else + fail "merge should still pass (custom ignores are additive to defaults)" +fi + +# TICKET pattern +set +e +OUTPUT=$(echo "TICKET-42 implement dashboard" | "$COMMITLINT" lint --config="$CONF_DIR/custom.yaml" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "TICKET-42 passes with custom ignores" +else + fail "TICKET-42 should pass with custom ignores" +fi + +# Config check should succeed +set +e +OUTPUT=$("$COMMITLINT" config check "$CONF_DIR/custom.yaml" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "config check passes for valid config" +else + fail "config check should pass for valid config" +fi + +# ═════════════════════════════════════════════════════════════════════════════ +# 10. Pipe / file input modes +# ═════════════════════════════════════════════════════════════════════════════ + +echo "" +echo "── 10. Input modes ──" + +# Pipe mode (already tested above, but explicit) +set +e +OUTPUT=$(echo "feat: add something new here" | "$COMMITLINT" lint 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "pipe input mode" +else + fail "pipe input mode" +fi + +# File input mode +MSG_FILE="$TMPDIR_ROOT/commit_msg.txt" +echo "feat: add something via file" > "$MSG_FILE" +set +e +OUTPUT=$("$COMMITLINT" lint --message="$MSG_FILE" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "file input mode (--message)" +else + fail "file input mode (--message)" +fi + +# Redirect input mode +set +e +OUTPUT=$("$COMMITLINT" lint < "$MSG_FILE" 2>&1) +EXIT_CODE=$? +set -e +if [[ $EXIT_CODE -eq 0 ]]; then + pass "redirect input mode" +else + fail "redirect input mode" +fi + +# ═════════════════════════════════════════════════════════════════════════════ +# Summary +# ═════════════════════════════════════════════════════════════════════════════ + +echo "" +echo "═══════════════════════════════════════════════════════════" +echo " Results: $PASS passed, $FAIL failed ($TOTAL total)" +echo "═══════════════════════════════════════════════════════════" + +# Cleanup +rm -rf "$TMPDIR_ROOT" + +if [[ $FAIL -gt 0 ]]; then + exit 1 +fi +exit 0 diff --git a/test/lint_test.go b/test/lint_test.go new file mode 100644 index 0000000..a084b48 --- /dev/null +++ b/test/lint_test.go @@ -0,0 +1,319 @@ +package test + +import ( + "testing" + + "github.com/conventionalcommit/commitlint/config" + "github.com/conventionalcommit/commitlint/lint" +) + +// --------------------------------------------------------------------------- +// Table-driven: valid commits (0 issues expected) +// --------------------------------------------------------------------------- + +var validCommitCases = []struct { + name string + msg string +}{ + // --- All default type-enum values --- + {"type/feat", "feat: add new login feature"}, + {"type/fix", "fix: resolve crash on startup"}, + {"type/docs", "docs: update README with examples"}, + {"type/style", "style: format code with gofmt"}, + {"type/refactor", "refactor: simplify user service"}, + {"type/perf", "perf: optimize database queries"}, + {"type/test", "test: add unit tests for parser"}, + {"type/build", "build: update go version to 1.21"}, + {"type/ci", "ci: add GitHub Actions workflow"}, + {"type/chore", "chore: update all dependencies"}, + {"type/revert", "revert: undo last commit changes"}, + + // --- Scope --- + {"scope/simple", "feat(auth): add OAuth2 support"}, + {"scope/nested", "fix(auth/login): handle nil user"}, + {"scope/multi-word", "refactor(core): extract helper"}, + + // --- Breaking indicator via exclamation mark --- + {"breaking/excl-no-scope", "feat!: new breaking change"}, + {"breaking/excl-with-scope", "feat(api)!: change response format"}, + + // --- Body --- + {"body/single-line", "feat(auth): add OAuth2 support\n\nThis adds Google OAuth2."}, + {"body/multi-line", "fix: repair cache\n\nLine one.\nLine two.\nLine three."}, + + // --- Footers (tokens the parser supports) --- + {"footer/single", "feat: add feature\n\nSome body.\n\nFixes: #123"}, + {"footer/multi", "fix: something\n\nBody.\n\nFixes: #123\nReviewed-by: John"}, + {"footer/no-body", "feat: add utils\n\nFixes: #123"}, + + // --- Header length boundaries --- + // min=10 chars header; "ci: update" = 10 chars exactly + {"header/at-min-10", "ci: update"}, + // max=72; "feat: " (6) + 66 = 72 chars exactly + {"header/at-max-72", "feat: add a new feature that is at the exact seventy two character limit"}, +} + +func TestLint_ValidCommits(t *testing.T) { + linter := newDefaultLinter(t) + for _, tc := range validCommitCases { + t.Run(tc.name, func(t *testing.T) { + result, err := linter.ParseAndLint(tc.msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues()) != 0 { + t.Errorf("expected 0 issues for %q, got %d:", tc.msg, len(result.Issues())) + for _, iss := range result.Issues() { + t.Logf(" [%s] %s: %s", iss.Severity(), iss.RuleName(), iss.Description()) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// Table-driven: invalid commits (>= wantMin issues expected) +// --------------------------------------------------------------------------- + +var invalidCommitCases = []struct { + name string + msg string + wantMin int // minimum number of issues expected +}{ + // --- Parser errors --- + {"parse/empty-message", "", 1}, + {"parse/no-colon-separator", "feat add new feature", 1}, + {"parse/missing-type", ": add new feature", 1}, + {"parse/space-in-type", "fe at: something here", 1}, + {"parse/missing-desc-no-space", "feat:", 1}, + {"parse/no-space-after-colon", "feat:compact description text", 1}, + {"parse/random-text", "just some random commit text", 1}, + + // --- type-enum violations --- + {"type-enum/unknown-type", "invalid: not an allowed type", 1}, + {"type-enum/cased-type", "Feat: case-sensitive type check", 1}, + {"type-enum/numeric-type", "123: something numeric", 1}, + + // --- header-min-length violations (min=10) --- + {"header-min/too-short", "fix: x", 1}, + // "fix: abcd" = 9 chars, under min of 10 + {"header-min/nine-chars", "fix: abcd", 1}, + + // --- header-max-length violations (max=72) --- + {"header-max/too-long", "feat: this is an extremely long commit message that exceeds the max header length limit", 1}, + // 73 chars: "feat: " (6) + 67 = 73 + {"header-max/one-over", "feat: add a new feature that is at the exact seventy two character limits", 1}, + // --- body-max-line-length violations (max=72) --- + {"body-line/too-long", "feat: add feature\n\nThis is a very long body line that definitely exceeds the seventy-two character limit per line in body text", 1}, +} + +func TestLint_InvalidCommits(t *testing.T) { + linter := newDefaultLinter(t) + for _, tc := range invalidCommitCases { + t.Run(tc.name, func(t *testing.T) { + result, err := linter.ParseAndLint(tc.msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues()) < tc.wantMin { + t.Errorf("expected >= %d issues for %q, got %d", tc.wantMin, tc.msg, len(result.Issues())) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Specific rule-violation checks (verify the exact rule name in issues) +// --------------------------------------------------------------------------- + +var ruleViolationCases = []struct { + name string + msg string + wantRule string // expected RuleName in at least one issue +}{ + {"rule/type-enum", "invalid: not an allowed type", "type-enum"}, + {"rule/header-min-length", "fix: tiny", "header-min-length"}, + {"rule/header-max-length", "feat: this is an extremely long commit message that exceeds the max header length limit", "header-max-length"}, + {"rule/body-max-line-length", "feat: add feature\n\nThis is a very long body line that definitely exceeds the seventy-two character limit per line in body text", "body-max-line-length"}, + {"rule/footer-max-line-length", "feat: add utils\n\nBody text.\n\nFixes: this-is-a-very-long-footer-line-that-exceeds-the-one-hundred-character-limit-and-should-trigger", "footer-max-line-length"}, + {"rule/parser-error", "invalid message", "parser"}, +} + +func TestLint_RuleViolations(t *testing.T) { + linter := newDefaultLinter(t) + for _, tc := range ruleViolationCases { + t.Run(tc.name, func(t *testing.T) { + result, err := linter.ParseAndLint(tc.msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + found := false + for _, iss := range result.Issues() { + if iss.RuleName() == tc.wantRule { + found = true + break + } + } + if !found { + t.Errorf("expected rule %q in issues for %q", tc.wantRule, tc.msg) + for _, iss := range result.Issues() { + t.Logf(" got: [%s] %s: %s", iss.Severity(), iss.RuleName(), iss.Description()) + } + } + }) + } +} + +// --------------------------------------------------------------------------- +// Result preservation +// --------------------------------------------------------------------------- + +func TestLint_ResultPreservesInput(t *testing.T) { + linter := newDefaultLinter(t) + msgs := []string{ + "feat: add something new", + "invalid message", + "fix: x", + } + for _, msg := range msgs { + result, err := linter.ParseAndLint(msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Input() != msg { + t.Errorf("expected input %q, got %q", msg, result.Input()) + } + } +} + +// --------------------------------------------------------------------------- +// Severity tests +// --------------------------------------------------------------------------- + +func TestLint_DefaultSeverityIsError(t *testing.T) { + linter := newDefaultLinter(t) + result, err := linter.ParseAndLint("unknown: some message here") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(result.Issues()) == 0 { + t.Fatal("expected at least one issue") + } + for _, iss := range result.Issues() { + if iss.Severity() != lint.SeverityError { + t.Errorf("expected error severity, got %q for %s", iss.Severity(), iss.RuleName()) + } + } +} + +func TestLint_CustomWarningSeverity(t *testing.T) { + conf := config.NewDefault() + conf.Severity.Rules = map[string]lint.Severity{ + "type-enum": lint.SeverityWarn, + } + rules, err := config.GetEnabledRules(conf) + if err != nil { + t.Fatal(err) + } + linter, err := lint.New(conf, rules) + if err != nil { + t.Fatal(err) + } + + result, err := linter.ParseAndLint("unknown: some message here") + if err != nil { + t.Fatal(err) + } + for _, iss := range result.Issues() { + if iss.RuleName() == "type-enum" && iss.Severity() != lint.SeverityWarn { + t.Errorf("expected warn for type-enum, got %q", iss.Severity()) + } + } +} + +func TestLint_ParserErrorAlwaysError(t *testing.T) { + conf := config.NewDefault() + // Even with all rules set to warn, parser errors should be SeverityError + conf.Severity.Default = lint.SeverityWarn + rules, err := config.GetEnabledRules(conf) + if err != nil { + t.Fatal(err) + } + linter, err := lint.New(conf, rules) + if err != nil { + t.Fatal(err) + } + + result, err := linter.ParseAndLint("") + if err != nil { + t.Fatal(err) + } + if len(result.Issues()) == 0 { + t.Fatal("expected parser issue for empty msg") + } + for _, iss := range result.Issues() { + if iss.RuleName() == "parser" && iss.Severity() != lint.SeverityError { + t.Errorf("parser errors should be SeverityError, got %q", iss.Severity()) + } + } +} + +// --------------------------------------------------------------------------- +// Body edge cases +// --------------------------------------------------------------------------- + +func TestLint_BodyWithinLimit(t *testing.T) { + linter := newDefaultLinter(t) + result, err := linter.ParseAndLint("feat: add feature\n\nShort body line.") + if err != nil { + t.Fatal(err) + } + if len(result.Issues()) != 0 { + t.Error("body within 72 chars should pass") + } +} + +func TestLint_BodyMultiLineAllWithin(t *testing.T) { + linter := newDefaultLinter(t) + result, err := linter.ParseAndLint("feat: add feature\n\nLine one.\nLine two.\nLine three.") + if err != nil { + t.Fatal(err) + } + for _, iss := range result.Issues() { + if iss.RuleName() == "body-max-line-length" { + t.Error("all body lines <= 72 should not trigger body-max-line-length") + } + } +} + +func TestLint_EmptyBodyAllowed(t *testing.T) { + linter := newDefaultLinter(t) + result, err := linter.ParseAndLint("feat: add feature") + if err != nil { + t.Fatal(err) + } + if len(result.Issues()) != 0 { + t.Error("empty body should be allowed by default config") + } +} + +// --------------------------------------------------------------------------- +// Multiple issues in a single commit +// --------------------------------------------------------------------------- + +func TestLint_MultipleIssues(t *testing.T) { + // "invalid: x" -> type-enum (invalid) + header-min-length (10 chars, "invalid: x" = 10) + // Actually "invalid: x" = 10 chars so header-min passes. Use something shorter. + // "xx: y" -> type: "xx" not in enum (5 chars header < 10) + linter := newDefaultLinter(t) + result, err := linter.ParseAndLint("xx: y") + if err != nil { + t.Fatal(err) + } + if len(result.Issues()) < 2 { + t.Errorf("expected >= 2 issues (type-enum + header-min-length), got %d", len(result.Issues())) + for _, iss := range result.Issues() { + t.Logf(" %s: %s", iss.RuleName(), iss.Description()) + } + } +} diff --git a/test/rule_test.go b/test/rule_test.go new file mode 100644 index 0000000..53b3e17 --- /dev/null +++ b/test/rule_test.go @@ -0,0 +1,1349 @@ +package test + +import ( + "testing" + + "github.com/conventionalcommit/commitlint/internal/casing" + "github.com/conventionalcommit/commitlint/lint" + "github.com/conventionalcommit/commitlint/rule" +) + +// --- Header length rules --- + +func TestHeadMinLen_Pass(t *testing.T) { + r := &rule.HeadMinLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 10}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: "feat: add new feature"}) + if !ok { + t.Error("header length >= 10 should pass") + } +} + +func TestHeadMinLen_Exact(t *testing.T) { + r := &rule.HeadMinLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 10}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: "0123456789"}) + if !ok { + t.Error("header length == 10 should pass") + } +} + +func TestHeadMinLen_Fail(t *testing.T) { + r := &rule.HeadMinLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 10}); err != nil { + t.Fatal(err) + } + issue, ok := r.Validate(&mockCommit{header: "short"}) + if ok { + t.Error("header length < 10 should fail") + } + if issue == nil || issue.Description() == "" { + t.Error("expected non-empty issue description") + } +} + +func TestHeadMinLen_BadArg(t *testing.T) { + r := &rule.HeadMinLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: "string"}); err == nil { + t.Error("expected error for non-int arg") + } +} + +func TestHeadMaxLen_Pass(t *testing.T) { + r := &rule.HeadMaxLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 50}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: "feat: short"}) + if !ok { + t.Error("header <= 50 should pass") + } +} + +func TestHeadMaxLen_Exact(t *testing.T) { + r := &rule.HeadMaxLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 10}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: "0123456789"}) + if !ok { + t.Error("header == max should pass") + } +} + +func TestHeadMaxLen_Fail(t *testing.T) { + r := &rule.HeadMaxLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 10}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: "this header is way too long"}) + if ok { + t.Error("header > 10 should fail") + } +} + +func TestHeadMaxLen_NegativeDisables(t *testing.T) { + r := &rule.HeadMaxLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: -1}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{header: "extremely long header that normally fails"}) + if !ok { + t.Error("max=-1 should disable check") + } +} + +// --- Body length rules --- + +func TestBodyMinLen_ZeroAllowsEmpty(t *testing.T) { + r := &rule.BodyMinLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 0}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: ""}) + if !ok { + t.Error("empty body with min=0 should pass") + } +} + +func TestBodyMinLen_Fail(t *testing.T) { + r := &rule.BodyMinLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 20}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: "short"}) + if ok { + t.Error("body < 20 should fail") + } +} + +func TestBodyMaxLen_NegativeDisables(t *testing.T) { + r := &rule.BodyMaxLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: -1}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: "very long body text here that would normally fail"}) + if !ok { + t.Error("max=-1 should disable check") + } +} + +func TestBodyMaxLen_Fail(t *testing.T) { + r := &rule.BodyMaxLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 5}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: "this body is too long"}) + if ok { + t.Error("body > 5 should fail") + } +} + +func TestBodyMaxLineLen_Pass(t *testing.T) { + r := &rule.BodyMaxLineLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 72}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: "Short line 1.\nShort line 2."}) + if !ok { + t.Error("all lines <= 72 should pass") + } +} + +func TestBodyMaxLineLen_Fail(t *testing.T) { + r := &rule.BodyMaxLineLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 20}); err != nil { + t.Fatal(err) + } + issue, ok := r.Validate(&mockCommit{body: "Short.\nThis line is definitely longer than twenty characters."}) + if ok { + t.Error("line > 20 should fail") + } + if issue == nil || len(issue.Infos()) == 0 { + t.Error("expected infos with per-line detail") + } +} + +func TestBodyMaxLineLen_EmptyBody(t *testing.T) { + r := &rule.BodyMaxLineLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 72}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{body: ""}) + if !ok { + t.Error("empty body should pass") + } +} + +// --- Footer length rules --- + +func TestFooterMinLen_ZeroAllowsEmpty(t *testing.T) { + r := &rule.FooterMinLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 0}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{footer: ""}) + if !ok { + t.Error("empty footer with min=0 should pass") + } +} + +func TestFooterMaxLen_NegativeDisables(t *testing.T) { + r := &rule.FooterMaxLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: -1}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{footer: "Very long footer text"}) + if !ok { + t.Error("max=-1 should disable check") + } +} + +func TestFooterMaxLineLen_Pass(t *testing.T) { + r := &rule.FooterMaxLineLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 72}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{footer: "Fixes: #123\nReviewed-by: John"}) + if !ok { + t.Error("footer lines <= 72 should pass") + } +} + +func TestFooterMaxLineLen_Fail(t *testing.T) { + r := &rule.FooterMaxLineLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 10}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{footer: "Fixes: #123456789012345"}) + if ok { + t.Error("footer line > 10 should fail") + } +} + +// --- Type rules --- + +func TestTypeEnum_ValidTypes(t *testing.T) { + r := &rule.TypeEnumRule{} + if err := r.Apply(lint.RuleSetting{Argument: []interface{}{"feat", "fix", "docs"}}); err != nil { + t.Fatal(err) + } + for _, typ := range []string{"feat", "fix", "docs"} { + _, ok := r.Validate(&mockCommit{typ: typ}) + if !ok { + t.Errorf("type %q should be valid", typ) + } + } +} + +func TestTypeEnum_Invalid(t *testing.T) { + r := &rule.TypeEnumRule{} + if err := r.Apply(lint.RuleSetting{Argument: []interface{}{"feat", "fix"}}); err != nil { + t.Fatal(err) + } + issue, ok := r.Validate(&mockCommit{typ: "unknown"}) + if ok { + t.Error("type 'unknown' should fail") + } + if issue == nil { + t.Fatal("expected non-nil issue") + } +} + +func TestTypeEnum_BadArg(t *testing.T) { + r := &rule.TypeEnumRule{} + if err := r.Apply(lint.RuleSetting{Argument: "not-an-array"}); err == nil { + t.Error("expected error for non-array arg") + } +} + +func TestTypeMinLen_Pass(t *testing.T) { + r := &rule.TypeMinLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 3}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "feat"}) + if !ok { + t.Error("type 'feat' len >= 3 should pass") + } +} + +func TestTypeMinLen_Fail(t *testing.T) { + r := &rule.TypeMinLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 5}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "fix"}) + if ok { + t.Error("type 'fix' len < 5 should fail") + } +} + +func TestTypeMaxLen_Pass(t *testing.T) { + r := &rule.TypeMaxLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 10}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "feat"}) + if !ok { + t.Error("type 'feat' len <= 10 should pass") + } +} + +func TestTypeMaxLen_Fail(t *testing.T) { + r := &rule.TypeMaxLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 3}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "refactor"}) + if ok { + t.Error("type 'refactor' len > 3 should fail") + } +} + +func TestTypeCharset_Pass(t *testing.T) { + r := &rule.TypeCharsetRule{} + if err := r.Apply(lint.RuleSetting{Argument: "abcdefghijklmnopqrstuvwxyz"}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "feat"}) + if !ok { + t.Error("lowercase type should pass") + } +} + +func TestTypeCharset_Fail(t *testing.T) { + r := &rule.TypeCharsetRule{} + if err := r.Apply(lint.RuleSetting{Argument: "abcdefghijklmnopqrstuvwxyz"}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "FEAT"}) + if ok { + t.Error("uppercase type should fail") + } +} + +func TestTypeCharset_BadArg(t *testing.T) { + r := &rule.TypeCharsetRule{} + if err := r.Apply(lint.RuleSetting{Argument: 123}); err == nil { + t.Error("expected error for non-string arg") + } +} + +// --- Scope rules --- + +func TestScopeEnum_Valid(t *testing.T) { + r := &rule.ScopeEnumRule{} + if err := r.Apply(lint.RuleSetting{ + Argument: []interface{}{"auth", "core", "api"}, + Flags: map[string]interface{}{"allow-empty": false}, + }); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{scope: "auth"}) + if !ok { + t.Error("scope 'auth' should pass") + } +} + +func TestScopeEnum_Invalid(t *testing.T) { + r := &rule.ScopeEnumRule{} + if err := r.Apply(lint.RuleSetting{ + Argument: []interface{}{"auth", "core"}, + Flags: map[string]interface{}{"allow-empty": false}, + }); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{scope: "unknown"}) + if ok { + t.Error("scope 'unknown' should fail") + } +} + +func TestScopeEnum_EmptyAllowed(t *testing.T) { + r := &rule.ScopeEnumRule{} + if err := r.Apply(lint.RuleSetting{ + Argument: []interface{}{"auth"}, + Flags: map[string]interface{}{"allow-empty": true}, + }); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{scope: ""}) + if !ok { + t.Error("empty scope with allow-empty=true should pass") + } +} + +func TestScopeEnum_EmptyNotAllowed(t *testing.T) { + r := &rule.ScopeEnumRule{} + if err := r.Apply(lint.RuleSetting{ + Argument: []interface{}{"auth"}, + Flags: map[string]interface{}{"allow-empty": false}, + }); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{scope: ""}) + if ok { + t.Error("empty scope with allow-empty=false should fail") + } +} + +func TestScopeMinLen_Pass(t *testing.T) { + r := &rule.ScopeMinLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 2}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{scope: "auth"}) + if !ok { + t.Error("scope len >= 2 should pass") + } +} + +func TestScopeMaxLen_NegativeDisables(t *testing.T) { + r := &rule.ScopeMaxLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: -1}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{scope: "very-long-scope-name"}) + if !ok { + t.Error("max=-1 should disable check") + } +} + +func TestScopeCharset_Pass(t *testing.T) { + r := &rule.ScopeCharsetRule{} + if err := r.Apply(lint.RuleSetting{Argument: "abcdefghijklmnopqrstuvwxyz/"}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{scope: "auth/core"}) + if !ok { + t.Error("scope with / should pass") + } +} + +func TestScopeCharset_Fail(t *testing.T) { + r := &rule.ScopeCharsetRule{} + if err := r.Apply(lint.RuleSetting{Argument: "abcdefghijklmnopqrstuvwxyz"}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{scope: "Auth"}) + if ok { + t.Error("uppercase scope should fail") + } +} + +// --- Description length rules --- + +func TestDescMinLen_Pass(t *testing.T) { + r := &rule.DescriptionMinLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 5}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{description: "add new feature"}) + if !ok { + t.Error("desc len >= 5 should pass") + } +} + +func TestDescMinLen_Fail(t *testing.T) { + r := &rule.DescriptionMinLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: 20}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{description: "short"}) + if ok { + t.Error("desc len < 20 should fail") + } +} + +func TestDescMaxLen_NegativeDisables(t *testing.T) { + r := &rule.DescriptionMaxLenRule{} + if err := r.Apply(lint.RuleSetting{Argument: -1}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{description: "very long description text"}) + if !ok { + t.Error("max=-1 should disable check") + } +} + +// --- Footer enum rule --- + +func TestFooterEnum_Valid(t *testing.T) { + r := &rule.FooterEnumRule{} + if err := r.Apply(lint.RuleSetting{ + Argument: []interface{}{"Fixes", "Reviewed-by", "BREAKING CHANGE"}, + }); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{ + notes: []lint.Note{&mockNote{token: "Fixes", value: "#123"}}, + }) + if !ok { + t.Error("known footer token should pass") + } +} + +func TestFooterEnum_Invalid(t *testing.T) { + r := &rule.FooterEnumRule{} + if err := r.Apply(lint.RuleSetting{Argument: []interface{}{"Fixes"}}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{ + notes: []lint.Note{&mockNote{token: "Unknown-Token", value: "val"}}, + }) + if ok { + t.Error("unknown footer token should fail") + } +} + +func TestFooterEnum_EmptyTokenList(t *testing.T) { + r := &rule.FooterEnumRule{} + if err := r.Apply(lint.RuleSetting{Argument: []interface{}{}}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{notes: []lint.Note{}}) + if !ok { + t.Error("no notes with empty list should pass") + } +} + +// --- Footer type enum rule --- + +func TestFooterTypeEnum_BadArg(t *testing.T) { + r := &rule.FooterTypeEnumRule{} + if err := r.Apply(lint.RuleSetting{Argument: "not-an-array"}); err == nil { + t.Error("expected error for non-array arg") + } +} + +func TestFooterTypeEnum_EmptyParams(t *testing.T) { + r := &rule.FooterTypeEnumRule{} + if err := r.Apply(lint.RuleSetting{Argument: []interface{}{}}); err != nil { + t.Fatal(err) + } + _, ok := r.Validate(&mockCommit{typ: "feat"}) + if !ok { + t.Error("empty params should pass") + } +} + +// --- Issue properties --- + +func TestIssue_Properties(t *testing.T) { + issue := lint.NewIssue("test desc", "info1", "info2") + if issue.Description() != "test desc" { + t.Errorf("got desc %q", issue.Description()) + } + if len(issue.Infos()) != 2 { + t.Errorf("expected 2 infos, got %d", len(issue.Infos())) + } + if issue.Infos()[0] != "info1" { + t.Errorf("got info[0] %q", issue.Infos()[0]) + } +} + +func TestIssue_NoInfos(t *testing.T) { + issue := lint.NewIssue("desc only") + if len(issue.Infos()) != 0 { + 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") + } +}