Skip to content

feat: Add multi scope lint support#19

Merged
thumbrise merged 3 commits into
mainfrom
multiscope-lint
Jun 8, 2026
Merged

feat: Add multi scope lint support#19
thumbrise merged 3 commits into
mainfrom
multiscope-lint

Conversation

@thumbrise
Copy link
Copy Markdown
Owner

@thumbrise thumbrise commented Jun 7, 2026

Summary by CodeRabbit

Release Notes

  • New Features

    • Added configurable scopeSeparator option to specify the delimiter between multiple scopes in commit messages. Defaults to comma.
    • Extended validation to support multiple scopes per commit.
  • Documentation

    • Updated configuration schema and documentation with the new scopeSeparator setting and examples for commits with multiple scopes.

@thumbrise thumbrise self-assigned this Jun 7, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 7, 2026

Review Change Stack

Warning

Review limit reached

@thumbrise, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 31 minutes and 40 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 58fb73a4-31e3-4636-b786-52de424202d7

📥 Commits

Reviewing files that changed from the base of the PR and between fe08e95 and b7f9e68.

📒 Files selected for processing (3)
  • README.md
  • pkg/validator/config_test.go
  • pkg/validator/validator.go
📝 Walkthrough

Walkthrough

This PR adds multi-scope parsing and validation to commitlint-scope via a new configurable ScopeSeparator setting. Commit messages can now list multiple scopes (e.g., (auth,docs) or auth|docs) separated by a configurable character; validation intersects outsider results across all scopes to find files that are outsiders for every scope simultaneously.

Changes

Multi-Scope Parsing and Validation

Layer / File(s) Summary
ScopeSeparator Configuration & Schema
pkg/validator/config.go, docs/schema/config.v3.json, cmd/commands/init.go, .commitlint-scope.yaml, pkg/validator/config_test.go
Config adds ScopeSeparator field defaulting to ,; JSON schema and user-facing config templates document the new setting; tests validate config loading with custom separators.
Multi-Scope Parsing Implementation
pkg/validator/scope_parser.go, pkg/validator/scope_parser_test.go, pkg/validator/mocks_test.go
DefaultScopeParser accepts a separator parameter, splits extracted scopes by that separator, and returns []string instead of a single scope; tests cover comma, pipe, and whitespace-trimmed multi-scope scenarios.
Validator Multi-Scope Logic & ErrSkip
pkg/validator/validator.go
Validator.Validate refactored with checkCommit helper and ErrSkip sentinel; ScopeParser.Parse signature changed to return []string; new findOutsiders computes outsider files by intersecting results across all parsed scopes.
Validator Tests with Multi-Scope Scenarios
pkg/validator/validator_test.go
TestableCommit fixture updated to hold scopes []string and per-scope outsider expectations; test scenarios validate single-scope, multi-scope, and outsider-intersection logic; mock setup and assertions refactored accordingly.
User Documentation & Wiring
README.md, .mockery.yml
scopeSeparator documented in "Scope parsing customization" section and zero-configuration behavior; .mockery.yml updated to reference v3 validator package path.

Sequence Diagram

sequenceDiagram
  participant CommitMessage
  participant ScopeParser
  participant FindOutsiders
  participant OutsiderFinder
  
  CommitMessage->>ScopeParser: Parse(message)
  ScopeParser->>ScopeParser: Extract regex group
  ScopeParser->>ScopeParser: Split by separator<br/>(comma, pipe, etc.)
  ScopeParser->>ScopeParser: Trim and filter<br/>empty entries
  ScopeParser-->>FindOutsiders: Return []string scopes
  
  FindOutsiders->>OutsiderFinder: Find(scopes[0])
  OutsiderFinder-->>FindOutsiders: Return candidates
  FindOutsiders->>FindOutsiders: Intersect with<br/>Find(scopes[1], ...N)
  FindOutsiders-->>CommitMessage: Return intersected<br/>outsiders
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • thumbrise/commitlint-scope#18: Introduces koanf-based config loading in pkg/validator/config.go; this PR extends that config foundation with the ScopeSeparator field and default.
  • thumbrise/commitlint-scope#4: Refactors validator/scope-parser integration; this PR builds on that surface by changing ScopeParser.Parse to return []string and reworking validation to intersect outsiders across multiple scopes.
  • thumbrise/commitlint-scope#17: Implements scopes-to-patterns matrix in OutsiderFinder; this PR's multi-scope outsider-intersection logic (findOutsiders calling Find once per scope) leverages that scopes-aware outsider-finder build.

Poem

🐰 Scopes multiply like happy carrots,
Separated by your favorite marks,
Auth and docs align like nested burrows,
Intersection found where each one embarks!
Multi-scope validation hops on strong,
One commit message, many scopes belong. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Add multi scope lint support' directly and accurately summarizes the primary change—introducing multi-scope linting functionality to the codebase.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch multiscope-lint

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@thumbrise
Copy link
Copy Markdown
Owner Author

@coderabbitai Hi, check manually please. Be worry about release correctness

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 7, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get PR details
gh pr view 19 --repo thumbrise/commitlint-scope --json title,body,baseRefName,headRefName,files,labels,state,mergeStateStatus,mergeable

echo "---FILES CHANGED---"
gh pr diff 19 --repo thumbrise/commitlint-scope --name-only

echo "---PR CHECKS---"
gh pr checks 19 --repo thumbrise/commitlint-scope 2>/dev/null || echo "No checks info"

Length of output: 2479


🏁 Script executed:

#!/bin/bash
# Inspect key release-related files
echo "=== package.json ==="
cat package.json 2>/dev/null || echo "Not found"

echo "=== CHANGELOG ==="
cat CHANGELOG.md 2>/dev/null || echo "Not found"

echo "=== .releaserc / release.config.js / semantic-release config ==="
cat .releaserc 2>/dev/null || cat .releaserc.json 2>/dev/null || cat .releaserc.js 2>/dev/null || cat release.config.js 2>/dev/null || echo "Not found"

echo "=== .github/workflows ==="
ls .github/workflows/ 2>/dev/null || echo "No workflows dir"

Length of output: 3825


🏁 Script executed:

#!/bin/bash
# Check workflow files and any version/release related config
for f in $(ls .github/workflows/ 2>/dev/null); do
  echo "=== .github/workflows/$f ==="
  cat ".github/workflows/$f"
  echo ""
done

echo "=== package.json scripts and version ==="
cat package.json | python -c "import sys,json; d=json.load(sys.stdin); print('version:', d.get('version')); print('scripts:', json.dumps(d.get('scripts',{}), indent=2)); print('release config:', json.dumps(d.get('release',{}), indent=2))"

Length of output: 4593


🏁 Script executed:

#!/bin/bash
# Check recent git tags and commits on the PR branch
echo "=== Recent tags ==="
git tag --sort=-version:refname | head -20

echo "=== Recent commits on branch ==="
git log --oneline -15

echo "=== PR branch vs main diff (key files) ==="
git diff origin/main...HEAD -- package.json CHANGELOG.md 2>/dev/null | head -100

Length of output: 1312


🏁 Script executed:

#!/bin/bash
echo "=== .goreleaser.yml / .goreleaser.yaml ==="
cat .goreleaser.yml 2>/dev/null || cat .goreleaser.yaml 2>/dev/null || echo "Not found"

echo "=== go.mod ==="
cat go.mod

echo "=== Full PR diff ==="
gh pr diff 19 --repo thumbrise/commitlint-scope

Length of output: 24731


🏁 Script executed:

#!/bin/bash
echo "=== release-template.hbs ==="
cat release-template.hbs 2>/dev/null || echo "Not found"

echo "=== .commitlint-scope.yaml ==="
cat .commitlint-scope.yaml 2>/dev/null || echo "Not found"

echo "=== commitlint.config.js ==="
cat commitlint.config.js 2>/dev/null || cat commitlint.config.cjs 2>/dev/null || echo "Not found"

echo "=== All semver-related: package-lock.json or npm-shrinkwrap.json check ==="
ls -la package-lock.json npm-shrinkwrap.json 2>/dev/null || echo "No lockfile found"

Length of output: 2186



@thumbrise Here is my manual review focused on release correctness:


Release Correctness Review — PR #19

✅ Release Pipeline Mechanics

Item Status
All CI checks (Lint, Test, commitlint, commitlint-scope) ✅ Pass
package-lock.json present for npm ci ✅ Present
semantic-release config (release.config.js) ✅ Correct
goreleaser config (version: 2) ✅ Present
Docker login secrets configured in workflow ⚠️ Depends on DOCKERHUB_USERNAME/DOCKERHUB_TOKEN being set

🔴 Critical: Double GitHub Release

File: release.config.js + .goreleaser.yaml

release.config.js includes @semantic-release/github which creates a GitHub Release, and then immediately after, goreleaser runs via @semantic-release/exec and also tries to create a GitHub Release (the release: key in .goreleaser.yaml is empty/default, meaning goreleaser will attempt to publish its own release).

# .goreleaser.yaml
release:
  # empty = goreleaser creates/publishes a GitHub release
// release.config.js
'`@semantic-release/github`',        // ← creates GitHub release `#1`
['`@semantic-release/exec`', {
    publishCmd: '... goreleaser release ...'  // ← goreleaser creates GitHub release `#2`
}]

Risk: goreleaser may fail with a conflict on the already-created release, or produce two releases with different assets.

Fix options (pick one):

  1. Remove @semantic-release/github and let goreleaser be the sole publisher:
    -'`@semantic-release/github`',
  2. Or disable the GitHub release in goreleaser and keep @semantic-release/github:
    # .goreleaser.yaml
    release:
      disable: true

🟠 Logic Bug: findOutsiders Uses Union Instead of Intersection

File: pkg/validator/validator.go, findOutsiders function

For a multi-scope commit like feat(api, db): ... touching api/handler.go, db/schema.sql, core/other.go:

  • Find("api", files) returns [db/schema.sql, core/other.go] — files not matching api/**
  • Find("db", files) returns [api/handler.go, core/other.go] — files not matching db/**
  • Current (union): [db/schema.sql, core/other.go, api/handler.go] ← incorrectly flags valid files
  • Correct (intersection): [core/other.go] ← only files matching NO scope

The current implementation will false-positive on files that are valid for one scope but not another.

// Current — WRONG for multi-scope
func findOutsiders(finder OutsiderFinder, scopes []string, files []string) []Outsider {
    seen := make(map[string]bool)
    var allOutsiders []Outsider
    for _, scope := range scopes {
        for _, o := range finder.Find(scope, files) {  // union of outsiders
            if !seen[o.File] {
                seen[o.File] = true
                allOutsiders = append(allOutsiders, o)
            }
        }
    }
    return allOutsiders
}

The correct approach is to keep only files that are outsiders for all scopes:

func findOutsiders(finder OutsiderFinder, scopes []string, files []string) []Outsider {
    if len(scopes) == 0 {
        return nil
    }

    // Start with the full outsider set for the first scope
    outsidersByFile := make(map[string]Outsider)
    for _, o := range finder.Find(scopes[0], files) {
        outsidersByFile[o.File] = o
    }

    // Intersect with outsiders from remaining scopes
    for _, scope := range scopes[1:] {
        scopeOutsiders := make(map[string]bool)
        for _, o := range finder.Find(scope, files) {
            scopeOutsiders[o.File] = true
        }
        for file := range outsidersByFile {
            if !scopeOutsiders[file] {
                delete(outsidersByFile, file) // valid for this scope → not an outsider
            }
        }
    }

    result := make([]Outsider, 0, len(outsidersByFile))
    for _, o := range outsidersByFile {
        result = append(result, o)
    }
    return result
}

🟡 Test Masks the Bug

File: pkg/validator/validator_test.go, SetupExpectations

The mock returns the same pre-computed outsiders for every scope call:

for _, scope := range c.scopes {
    outsider.EXPECT().Find(scope, c.files).Return(outsiders) // same list for all scopes
}

This means the test never exercises the union-vs-intersection difference. It would pass with either the current (wrong) or the corrected implementation. An integration-level test with the real OutsiderFinder is needed to catch this.


ℹ️ First Release Will Be v1.0.0

There are no existing git tags. semantic-release will scan the full commit history, find feat!: Scopes-Patterns matrix support... (a breaking change), and produce version 1.0.0. This is likely intentional given package.json has "version": "1.0.0", but worth confirming.


Summary

Issue Severity Blocks Release?
Double GitHub Release (@semantic-release/github + goreleaser) 🔴 Critical Yes — likely
findOutsiders union vs intersection logic bug 🟠 High No, but ships broken feature
Test doesn't catch the logic bug 🟡 Medium No
First release = v1.0.0 (no tags) ℹ️ Info Intentional?

I'd recommend blocking merge until at least the double-release issue is resolved, and ideally the findOutsiders logic is fixed too.

@thumbrise
Copy link
Copy Markdown
Owner Author

@coderabbitai
We have already 14 correct releases and tags with same release config, your info for some reason not full, we do not need touch release config files.
The rest will fixed

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 7, 2026

Tip

For best results, initiate chat on the files or code changes.

@thumbrise — Understood, and apologies for the false alarm on the release config. With 14 successful releases already using the same setup, it's clearly working as intended — my script output was missing the existing tags and I drew the wrong conclusion.

Good to hear the findOutsiders intersection logic and the corresponding test coverage will be addressed. Would you like me to open a follow-up GitHub issue to track those two items, or will you handle them directly in this PR?

🐇 ˶ᵔ ᵕ ᵔ˶


✏️ Learnings added
Learnt from: thumbrise
URL: https://github.com/thumbrise/commitlint-scope/pull/19

Timestamp: 2026-06-07T17:22:25.637Z
Learning: In the `thumbrise/commitlint-scope` repository, the release pipeline combines `semantic-release/github` in `release.config.js` with goreleaser (via `semantic-release/exec`) in `.goreleaser.yaml`. This is intentional and has produced 14 correct releases. Do NOT flag this as a double-release conflict — the setup is known-good and should not be changed.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
pkg/validator/validator.go (2)

193-196: ⚡ Quick win

Sort outsider results before returning for deterministic output.

findOutsiders builds result from a map, so output order is nondeterministic. Stable ordering avoids flaky assertions and inconsistent CLI/JSON output.

Proposed patch
 import (
 	"context"
 	"errors"
 	"fmt"
 	"log/slog"
+	"sort"
 )
@@
 	result := make([]Outsider, 0, len(outsidersByFile))
 	for _, o := range outsidersByFile {
 		result = append(result, o)
 	}
+	sort.Slice(result, func(i, j int) bool {
+		return result[i].File < result[j].File
+	})
 
 	return result
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/validator/validator.go` around lines 193 - 196, findOutsiders currently
builds result from the map outsidersByFile which yields nondeterministic order;
fix by sorting the result slice before returning. After the loop that appends to
result in function findOutsiders, call sort.Slice (importing sort) and provide a
comparator that orders Outsider entries deterministically (e.g., first by
File/path field and then by Name/identifier field) so output is stable for
CLI/JSON and tests.

79-79: Make outsider ordering deterministic (and separator defaulting is already consistent)
NewDefaultScopeParser normalizes an empty cfg.ScopeSeparator to ",", so NewValidator(Config{}, ...) won’t diverge from LoadConfig for separator defaulting. In contrast, findOutsiders returns Outsiders by ranging over outsidersByFile (map iteration), so the Outsiders slice order can vary—sort by Outsider.File before returning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/validator/validator.go` at line 79, The findOutsiders function builds an
Outsiders slice by ranging over outsidersByFile (a map), producing
nondeterministic order; after assembling the []Outsider, sort it
deterministically by the Outsider.File field (e.g., using sort.Slice or
sort.SliceStable) before returning so callers of NewValidator/LoadConfig get
stable ordering; update findOutsiders to perform this sort on the resulting
Outsiders slice.
docs/schema/config.v3.json (1)

13-16: ⚡ Quick win

Add an explicit schema default for scopeSeparator.

The description says the default is ",", but the schema doesn’t declare it. Add "default": "," so schema consumers/editors can apply it consistently.

Suggested patch
     "scopeSeparator": {
       "description": "Character used to separate multiple scopes within the parentheses. Default: ','. Example: 'feat(api, db): message' with separator ',' yields scopes ['api', 'db'].",
-      "type": "string"
+      "type": "string",
+      "default": ","
     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/schema/config.v3.json` around lines 13 - 16, The schema property
"scopeSeparator" currently documents a default of "," but doesn't declare it;
update the JSON schema entry for scopeSeparator (the object keyed
"scopeSeparator") by adding "default": "," alongside its "description" and
"type" so editors and tooling can apply the documented default value
consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/validator/config_test.go`:
- Around line 121-123: The test only asserts cfg.ScopeSeparator when
tt.wantSeparator is non-empty, letting regressions to the default separator
slip; update the test in config_test.go to set a default expected separator
(",") for success cases and assert cfg.ScopeSeparator unconditionally for
non-error cases by using tt.wantSeparator = "," when tt.wantSeparator == "" (or
otherwise treating empty as the default) and then calling the existing
assert.Equal(t, tt.wantSeparator, cfg.ScopeSeparator) for each non-failing test;
reference tt.wantSeparator and cfg.ScopeSeparator to locate and change the test
logic.

In `@README.md`:
- Around line 125-126: Clarify that multiple scopes in a single commit are
treated as alternatives (OR), not conjunctions: update the sentence that
currently reads "Multiple scopes in a single commit are resolved independently —
`feat(api, db): ...` will check files against patterns for both `api` and `db`."
to explicitly state that a file is accepted if it matches any one of the listed
scopes and only flagged if it is outside all parsed scopes; also keep the note
about `scopeSeparator` for alternate separators. Mention the example `feat(api,
db): ...` and the `scopeSeparator` config key to help readers find the relevant
behavior.

---

Nitpick comments:
In `@docs/schema/config.v3.json`:
- Around line 13-16: The schema property "scopeSeparator" currently documents a
default of "," but doesn't declare it; update the JSON schema entry for
scopeSeparator (the object keyed "scopeSeparator") by adding "default": ","
alongside its "description" and "type" so editors and tooling can apply the
documented default value consistently.

In `@pkg/validator/validator.go`:
- Around line 193-196: findOutsiders currently builds result from the map
outsidersByFile which yields nondeterministic order; fix by sorting the result
slice before returning. After the loop that appends to result in function
findOutsiders, call sort.Slice (importing sort) and provide a comparator that
orders Outsider entries deterministically (e.g., first by File/path field and
then by Name/identifier field) so output is stable for CLI/JSON and tests.
- Line 79: The findOutsiders function builds an Outsiders slice by ranging over
outsidersByFile (a map), producing nondeterministic order; after assembling the
[]Outsider, sort it deterministically by the Outsider.File field (e.g., using
sort.Slice or sort.SliceStable) before returning so callers of
NewValidator/LoadConfig get stable ordering; update findOutsiders to perform
this sort on the resulting Outsiders slice.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: aa22d30e-5796-47e7-9f33-0294ab052e23

📥 Commits

Reviewing files that changed from the base of the PR and between ed53b57 and fe08e95.

📒 Files selected for processing (12)
  • .commitlint-scope.yaml
  • .mockery.yml
  • README.md
  • cmd/commands/init.go
  • docs/schema/config.v3.json
  • pkg/validator/config.go
  • pkg/validator/config_test.go
  • pkg/validator/mocks_test.go
  • pkg/validator/scope_parser.go
  • pkg/validator/scope_parser_test.go
  • pkg/validator/validator.go
  • pkg/validator/validator_test.go

Comment thread pkg/validator/config_test.go Outdated
Comment thread README.md Outdated
- Sort findOutsiders result by File (non-deterministic map iteration)
- Assert ScopeSeparator unconditionally in config tests
- Rewrite README multi-scope section with explicit intersection semantics
@thumbrise thumbrise merged commit f0d159c into main Jun 8, 2026
5 checks passed
@thumbrise thumbrise deleted the multiscope-lint branch June 8, 2026 08:52
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 8, 2026

🎉 This PR is included in version 3.2.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant