| | | 1 | | package lint |
| | | 2 | | |
| | | 3 | | import ( |
| | | 4 | | "fmt" |
| | | 5 | | "strings" |
| | | 6 | | |
| | | 7 | | "github.com/jedi-knights/go-semantic-release/internal/domain" |
| | | 8 | | "github.com/jedi-knights/go-semantic-release/internal/ports" |
| | | 9 | | ) |
| | | 10 | | |
| | | 11 | | // Compile-time interface compliance check. |
| | | 12 | | var _ ports.CommitLinter = (*ConventionalLinter)(nil) |
| | | 13 | | |
| | | 14 | | // ConventionalLinter validates commits against conventional commit rules. |
| | | 15 | | type ConventionalLinter struct { |
| | | 16 | | config domain.LintConfig |
| | | 17 | | } |
| | | 18 | | |
| | | 19 | | // NewConventionalLinter creates a new conventional commit linter. |
| | | 20 | | func NewConventionalLinter(cfg domain.LintConfig) *ConventionalLinter { |
| | | 21 | | return &ConventionalLinter{config: cfg} |
| | | 22 | | } |
| | | 23 | | |
| | | 24 | | // Lint checks a single commit against all configured rules. |
| | 10 | 25 | | func (l *ConventionalLinter) Lint(commit domain.Commit) []domain.LintViolation { |
| | 10 | 26 | | violations := make([]domain.LintViolation, 0, 5) |
| | 10 | 27 | | |
| | 10 | 28 | | violations = append(violations, l.checkType(commit)...) |
| | 10 | 29 | | violations = append(violations, l.checkScope(commit)...) |
| | 10 | 30 | | violations = append(violations, l.checkDescription(commit)...) |
| | 10 | 31 | | violations = append(violations, l.checkSubjectLength(commit)...) |
| | 10 | 32 | | violations = append(violations, l.checkBody(commit)...) |
| | 10 | 33 | | |
| | 10 | 34 | | return violations |
| | 10 | 35 | | } |
| | | 36 | | |
| | 10 | 37 | | func (l *ConventionalLinter) checkType(commit domain.Commit) []domain.LintViolation { |
| | 1 | 38 | | if commit.Type == "" { |
| | 1 | 39 | | return []domain.LintViolation{{ |
| | 1 | 40 | | Rule: "type-empty", |
| | 1 | 41 | | Message: "commit message must have a type (e.g., feat, fix)", |
| | 1 | 42 | | Severity: domain.LintError, |
| | 1 | 43 | | }} |
| | 1 | 44 | | } |
| | | 45 | | |
| | 9 | 46 | | if len(l.config.AllowedTypes) > 0 { |
| | 9 | 47 | | allowed := false |
| | 9 | 48 | | for _, t := range l.config.AllowedTypes { |
| | 8 | 49 | | if commit.Type == t { |
| | 8 | 50 | | allowed = true |
| | 8 | 51 | | break |
| | | 52 | | } |
| | | 53 | | } |
| | 1 | 54 | | if !allowed { |
| | 1 | 55 | | return []domain.LintViolation{{ |
| | 1 | 56 | | Rule: "type-enum", |
| | 1 | 57 | | Message: fmt.Sprintf("type %q is not allowed; allowed types: %s", commit.Type, strings.Join(l.config.AllowedTyp |
| | 1 | 58 | | Severity: domain.LintError, |
| | 1 | 59 | | }} |
| | 1 | 60 | | } |
| | | 61 | | } |
| | | 62 | | |
| | 8 | 63 | | return nil |
| | | 64 | | } |
| | | 65 | | |
| | 10 | 66 | | func (l *ConventionalLinter) checkScope(commit domain.Commit) []domain.LintViolation { |
| | 1 | 67 | | if l.config.RequireScope && commit.Scope == "" { |
| | 1 | 68 | | return []domain.LintViolation{{ |
| | 1 | 69 | | Rule: "scope-empty", |
| | 1 | 70 | | Message: "commit message must have a scope", |
| | 1 | 71 | | Severity: domain.LintError, |
| | 1 | 72 | | }} |
| | 1 | 73 | | } |
| | | 74 | | |
| | 2 | 75 | | if commit.Scope != "" && len(l.config.AllowedScopes) > 0 { |
| | 2 | 76 | | allowed := false |
| | 2 | 77 | | for _, s := range l.config.AllowedScopes { |
| | 1 | 78 | | if commit.Scope == s { |
| | 1 | 79 | | allowed = true |
| | 1 | 80 | | break |
| | | 81 | | } |
| | | 82 | | } |
| | 1 | 83 | | if !allowed { |
| | 1 | 84 | | return []domain.LintViolation{{ |
| | 1 | 85 | | Rule: "scope-enum", |
| | 1 | 86 | | Message: fmt.Sprintf("scope %q is not allowed; allowed scopes: %s", commit.Scope, strings.Join(l.config.Allowed |
| | 1 | 87 | | Severity: domain.LintError, |
| | 1 | 88 | | }} |
| | 1 | 89 | | } |
| | | 90 | | } |
| | | 91 | | |
| | 8 | 92 | | return nil |
| | | 93 | | } |
| | | 94 | | |
| | 10 | 95 | | func (l *ConventionalLinter) checkDescription(commit domain.Commit) []domain.LintViolation { |
| | 1 | 96 | | if commit.Description == "" { |
| | 1 | 97 | | return []domain.LintViolation{{ |
| | 1 | 98 | | Rule: "description-empty", |
| | 1 | 99 | | Message: "commit message must have a description", |
| | 1 | 100 | | Severity: domain.LintError, |
| | 1 | 101 | | }} |
| | 1 | 102 | | } |
| | | 103 | | |
| | 1 | 104 | | if strings.HasSuffix(commit.Description, ".") { |
| | 1 | 105 | | return []domain.LintViolation{{ |
| | 1 | 106 | | Rule: "description-trailing-period", |
| | 1 | 107 | | Message: "description must not end with a period", |
| | 1 | 108 | | Severity: domain.LintWarning, |
| | 1 | 109 | | }} |
| | 1 | 110 | | } |
| | | 111 | | |
| | 8 | 112 | | return nil |
| | | 113 | | } |
| | | 114 | | |
| | 10 | 115 | | func (l *ConventionalLinter) checkSubjectLength(commit domain.Commit) []domain.LintViolation { |
| | 4 | 116 | | if l.config.MaxSubjectLength <= 0 { |
| | 4 | 117 | | return nil |
| | 4 | 118 | | } |
| | | 119 | | |
| | 6 | 120 | | subject := commit.Message |
| | 0 | 121 | | if idx := strings.IndexByte(subject, '\n'); idx >= 0 { |
| | 0 | 122 | | subject = subject[:idx] |
| | 0 | 123 | | } |
| | | 124 | | |
| | 1 | 125 | | if len(subject) > l.config.MaxSubjectLength { |
| | 1 | 126 | | return []domain.LintViolation{{ |
| | 1 | 127 | | Rule: "subject-max-length", |
| | 1 | 128 | | Message: fmt.Sprintf("subject line is %d characters, maximum is %d", len(subject), l.config.MaxSubjectLength), |
| | 1 | 129 | | Severity: domain.LintWarning, |
| | 1 | 130 | | }} |
| | 1 | 131 | | } |
| | | 132 | | |
| | 5 | 133 | | return nil |
| | | 134 | | } |
| | | 135 | | |
| | 10 | 136 | | func (l *ConventionalLinter) checkBody(commit domain.Commit) []domain.LintViolation { |
| | 1 | 137 | | if l.config.RequireBody && commit.Body == "" { |
| | 1 | 138 | | return []domain.LintViolation{{ |
| | 1 | 139 | | Rule: "body-empty", |
| | 1 | 140 | | Message: "commit message must have a body", |
| | 1 | 141 | | Severity: domain.LintWarning, |
| | 1 | 142 | | }} |
| | 1 | 143 | | } |
| | | 144 | | |
| | 9 | 145 | | return nil |
| | | 146 | | } |