Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions cmd/commands/run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package commands

import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"

"github.com/fatih/color"
"github.com/thumbrise/commitlint-scope/cmd/errs"
"github.com/thumbrise/commitlint-scope/pkg/validator"
"github.com/urfave/cli/v3"
)

var (
flagRunFrom string
flagRunTo string
flagRunVerbose bool
flagRunNoColor bool
flagRunJSON bool
)

var RunCMD = &cli.Command{
Name: "run",
Usage: "Lint commit scopes against changed files",
UsageText: "commitlint-scope run --from <sha> --to <sha>",
Description: `Validate that scopes declared in commit messages correspond to actually changed files

The command inspects a range of commits (from exclusive, to inclusive) and reports any scope that does not match the files modified in that commit.

Examples:
commitlint-scope run --from main --to feature-branch
commitlint-scope run --from HEAD~5 --to HEAD
`,
Suggest: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "from",
Aliases: []string{"f"},
Usage: "Start of the commit range (exclusive)",
Required: true,
Destination: &flagRunFrom,
},
&cli.StringFlag{
Name: "to",
Aliases: []string{"t"},
Usage: "End of the commit range (inclusive)",
Required: true,
Destination: &flagRunTo,
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Verbose output",
Required: false,
Destination: &flagRunVerbose,
},
&cli.BoolFlag{
Name: "no-color",
Usage: "Disable color output",
Required: false,
Destination: &flagRunNoColor,
},
&cli.BoolFlag{
Name: "json",
Usage: "Show output in JSON format",
Required: false,
Destination: &flagRunJSON,
},
},

Action: func(ctx context.Context, cmd *cli.Command) error {
color.NoColor = flagRunNoColor

configureLogger(os.Stderr, flagRunVerbose)

cfg, err := validator.LoadConfig()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}

vld, err := validator.NewValidator(cfg, validator.Options{
Logger: slog.Default(),
SHALength: 7,
Git: nil,
OutsiderFinder: nil,
ScopeParser: nil,
})
if err != nil {
return fmt.Errorf("creating validator: %w", err)
}

violations, err := vld.Validate(ctx, flagRunFrom, flagRunTo)
if err != nil {
return fmt.Errorf("validation failed: %w", err)
}

if len(violations) == 0 {
return nil
}

if flagRunJSON {
err := jsonOutput(cmd.Writer, violations)
if err != nil {
return err
}
} else {
textOutput(cmd.Writer, violations)
}

return errs.NewViolationsFound(len(violations))
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

func textOutput(w io.Writer, violations []validator.Violation) {
shaColor := color.New(color.FgYellow, color.Bold)
for _, v := range violations {
_, _ = fmt.Fprintf(w, "%s %s\n", shaColor.Sprintf("%s", v.SHA), v.Header)
for _, o := range v.Outsiders {
_, _ = fmt.Fprintf(w, " - %s\n", o.File)
}
}
}

func jsonOutput(writer io.Writer, violations []validator.Violation) error {
encoder := json.NewEncoder(writer)
encoder.SetIndent("", " ")

if err := encoder.Encode(violations); err != nil {
return fmt.Errorf("failed to output violations: %w", err)
}

return nil
}

func configureLogger(writer io.Writer, verbose bool) {
opts := &slog.HandlerOptions{
AddSource: false,
Level: slog.LevelInfo,
}

if verbose {
opts.Level = slog.LevelDebug
opts.AddSource = true
}

handler := slog.NewTextHandler(writer, opts)

logger := slog.New(handler)

slog.SetDefault(logger)
}
21 changes: 21 additions & 0 deletions cmd/errs/violations_found_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package errs

import (
"fmt"
)

type ViolationsFoundError struct {
num int
}

func NewViolationsFound(num int) *ViolationsFoundError {
return &ViolationsFoundError{num: num}
}
Comment on lines +11 to +13
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate that num is positive.

The constructor should validate that num > 0 because a "violations found" error is semantically meaningless with zero or negative violations. While the current call site passes len(violations) (always non-negative), the exported constructor could be misused by future callers.

🛡️ Proposed fix to add validation
 func NewViolationsFound(num int) *ViolationsFoundError {
+	if num <= 0 {
+		panic(fmt.Sprintf("ViolationsFoundError requires num > 0, got %d", num))
+	}
 	return &ViolationsFoundError{num: num}
 }

Alternatively, return an error from the constructor:

-func NewViolationsFound(num int) *ViolationsFoundError {
+func NewViolationsFound(num int) (*ViolationsFoundError, error) {
+	if num <= 0 {
+		return nil, fmt.Errorf("violations count must be positive, got %d", num)
+	}
 	return &ViolationsFoundError{num: num}, nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func NewViolationsFound(num int) *ViolationsFoundError {
return &ViolationsFoundError{num: num}
}
func NewViolationsFound(num int) *ViolationsFoundError {
if num <= 0 {
panic(fmt.Sprintf("ViolationsFoundError requires num > 0, got %d", num))
}
return &ViolationsFoundError{num: num}
}
🤖 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 `@cmd/errs/violations_found_error.go` around lines 11 - 13, Update
NewViolationsFound to validate that num > 0 and return an error when it's not:
change the signature to NewViolationsFound(num int) (*ViolationsFoundError,
error), check if num <= 0 and return (nil, fmt.Errorf("invalid num: %d, must be
> 0", num)), otherwise return the constructed &ViolationsFoundError{num: num},
and update any callers (e.g., sites that pass len(violations)) to handle the
(value, error) return accordingly.


func (v ViolationsFoundError) Error() string {
return fmt.Sprintf("%d violations found", v.num)
}

func (v ViolationsFoundError) ExitCode() int {
return 2
}
153 changes: 14 additions & 139 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,157 +2,32 @@ package cmd

import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"errors"
"os"

"github.com/fatih/color"
"github.com/thumbrise/commitlint-scope/pkg/validator"
"github.com/thumbrise/commitlint-scope/cmd/commands"
"github.com/urfave/cli/v3"
)

var (
flagFrom string
flagTo string
flagVerbose bool
flagNoColor bool
flagJSON bool
)

// Root is the entry point command for commitlint-scope.
var Root = &cli.Command{
Name: "commitlint-scope",
Usage: "Lint commit scopes against changed files.",
UsageText: "commitlint-scope --from <sha> --to <sha>",
Description: `Validate that scopes declared in commit messages correspond to actually changed files.

The command inspects a range of commits (from exclusive, to inclusive) and reports any scope that does not match the files modified in that commit.

Examples:
commitlint-scope --from main --to feature-branch
commitlint-scope --from HEAD~5 --to HEAD
`,

Suggest: true,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "from",
Aliases: []string{"f"},
Usage: "Start of the commit range (exclusive)",
Required: true,
Destination: &flagFrom,
},
&cli.StringFlag{
Name: "to",
Aliases: []string{"t"},
Usage: "End of the commit range (inclusive)",
Required: true,
Destination: &flagTo,
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Verbose output",
Required: false,
Destination: &flagVerbose,
},
&cli.BoolFlag{
Name: "no-color",
Usage: "Disable color output",
Required: false,
Destination: &flagNoColor,
},
&cli.BoolFlag{
Name: "json",
Usage: "Show output in JSON format",
Required: false,
Destination: &flagJSON,
},
Name: "commitlint-scope",
Description: `commitlint-scope - a linter that checks if declared commit scopes match the changed files`,
Commands: []*cli.Command{
commands.RunCMD,
},
Suggest: true,
ExitErrHandler: func(ctx context.Context, command *cli.Command, err error) {
code := 1

Action: func(ctx context.Context, cmd *cli.Command) error {
color.NoColor = flagNoColor

configureLogger(os.Stderr, flagVerbose)

cfg, err := validator.LoadConfig()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}

vld, err := validator.NewValidator(cfg, validator.Options{
Logger: slog.Default(),
SHALength: 7,
Git: nil,
OutsiderFinder: nil,
ScopeParser: nil,
})
if err != nil {
return fmt.Errorf("creating validator: %w", err)
}

violations, err := vld.Validate(ctx, flagFrom, flagTo)
if err != nil {
return fmt.Errorf("validation failed: %w", err)
}

if len(violations) == 0 {
return nil
if coder, ok := errors.AsType[cli.ExitCoder](err); ok {
code = coder.ExitCode()
}

msg := fmt.Sprintf("%d violation(s) found:", len(violations))
_, _ = color.New(color.FgRed, color.Bold).Fprintln(cmd.ErrWriter, msg)
_, _ = color.New(color.FgRed, color.Bold).Fprintf(os.Stderr, "\n%s\n", err)
_, _ = color.New(color.FgRed, color.Bold).Fprintf(os.Stderr, "\nexit code %d\n", code)

if flagJSON {
err := jsonOutput(cmd.Writer, violations)
if err != nil {
return err
}
} else {
textOutput(cmd.Writer, violations)
}

return nil
os.Exit(code)
},
}

func textOutput(w io.Writer, violations []validator.Violation) {
shaColor := color.New(color.FgYellow, color.Bold)
for _, v := range violations {
_, _ = fmt.Fprintf(w, "%s %s\n", shaColor.Sprintf("%s", v.SHA), v.Header)
for _, o := range v.Outsiders {
_, _ = fmt.Fprintf(w, " - %s\n", o.File)
}
}
}

func jsonOutput(writer io.Writer, violations []validator.Violation) error {
encoder := json.NewEncoder(writer)
encoder.SetIndent("", " ")

if err := encoder.Encode(violations); err != nil {
return fmt.Errorf("failed to output violations: %w", err)
}

return nil
}

func configureLogger(writer io.Writer, verbose bool) {
opts := &slog.HandlerOptions{
AddSource: false,
Level: slog.LevelInfo,
}

if verbose {
opts.Level = slog.LevelDebug
opts.AddSource = true
}

handler := slog.NewTextHandler(writer, opts)

logger := slog.New(handler)

slog.SetDefault(logger)
}
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ func main() {
ctx := context.Background()

if err := cmd.Root.Run(ctx, os.Args); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "error: %[1]v\n", err)
_, _ = fmt.Fprintf(os.Stderr, "fatal error: %[1]v\n", err)

os.Exit(1)
}
Expand Down
Loading