diff --git a/cmd/commit-lint/main.go b/cmd/commit-lint/main.go new file mode 100644 index 0000000..f5d69e5 --- /dev/null +++ b/cmd/commit-lint/main.go @@ -0,0 +1,80 @@ +// Command commit-lint validates a Git commit message against the Nightshift +// Conventional Commits convention. It reads the message from the file path +// passed as its first argument (the contract used by Git's commit-msg hook) and +// exits with a non-zero status if any violations are found. +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/marcus/nightshift/internal/commits" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: commit-lint ") + os.Exit(2) + } + path := os.Args[1] + data, err := os.ReadFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "commit-lint: read %s: %v\n", path, err) + os.Exit(2) + } + + // Strip trailing comment lines that Git appends for the user's reference. + msg := stripComments(string(data)) + + violations := commits.Validate(msg) + if len(violations) == 0 { + return + } + + fmt.Fprintln(os.Stderr, "commit message does not follow the Conventional Commits convention:") + for _, v := range violations { + fmt.Fprintf(os.Stderr, " - %s\n", v.Error()) + } + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Run the normalizer to auto-fix, or edit your message to match:") + fmt.Fprintln(os.Stderr, " (): (imperative, <=72 chars)") + os.Exit(1) +} + +// stripComments removes Git's trailing "#" comment block from the commit +// message file and trims trailing blank lines. +func stripComments(s string) string { + rawLines := splitLines(s) + var keep []string + for _, line := range rawLines { + if strings.HasPrefix(line, "#") { + continue + } + keep = append(keep, line) + } + for len(keep) > 0 && keep[len(keep)-1] == "" { + keep = keep[:len(keep)-1] + } + out := "" + for i, l := range keep { + if i > 0 { + out += "\n" + } + out += l + } + return out +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + lines = append(lines, s[start:]) + return lines +} diff --git a/docs/commit-messages.md b/docs/commit-messages.md new file mode 100644 index 0000000..e7b067f --- /dev/null +++ b/docs/commit-messages.md @@ -0,0 +1,104 @@ +# Commit Message Convention + +This project follows the [Conventional Commits](https://www.conventionalcommits.org/) +specification. All commits are expected to match the shape below and are +enforced by a `commit-msg` hook (see [Installing the hook](#installing-the-hook)). + +## Format + +``` +(): + + + + +``` + +- **type** *(required)* — lowercase, one of the allowed types listed below. +- **scope** *(optional)* — lowercase, one of the allowed scopes. Omit the + parentheses entirely when there is no scope. +- **subject** *(required)* — imperative mood (`add`, not `added`/`adds`), no + trailing period, hard limit of **72 characters**. The first letter is + lower-cased. +- **body** *(optional)* — separated from the subject by a blank line, hard-wrapped + to **72 columns**. Paragraphs are separated by blank lines. +- **trailers** *(optional)* — separated from the body by a blank line, in + `Key: value` form. `Nightshift-*` trailers required for task tracking are + sorted to the end automatically. + +A trailing `!` after the type/scope marks a breaking change +(e.g. `feat(api)!: drop v1`). + +## Allowed types + +| Type | Use for | +|------------|---------------------------------------------------------------| +| `feat` | A new feature | +| `fix` | A bug fix | +| `docs` | Documentation only | +| `style` | Formatting, whitespace, semicolons — no code change | +| `refactor` | Code change that neither fixes a bug nor adds a feature | +| `perf` | A change that improves performance | +| `test` | Adding or correcting tests | +| `build` | Build system or external dependencies | +| `ci` | CI configuration files and scripts | +| `chore` | Repetitive tasks, tooling, repo maintenance | +| `revert` | Reverting a previous commit | + +## Allowed scopes + +`commits`, `config`, `scheduler`, `providers`, `agents`, `cli`, `docs`, +`website`, `db`, `reporting`, `security`, `setup`, `orchestrator`, `budget`. + +Unknown scopes are not rejected outright (new subsystems appear before their +scope is registered), but tooling will warn. Keep scope names lowercase and +short. + +## Examples + +Well-formed: + +``` +feat(commits): add message normalizer + +Parses raw commit messages into type/scope/subject/body/trailers and +rewrites them to the canonical format. Idempotent on already-conforming +messages. + +Nightshift-Task: commit-normalize +Nightshift-Ref: https://github.com/marcus/nightshift +``` + +Needs normalization (the normalizer rewrites this to the form above): + +``` +FEAT(Commits): Added message normalizer. +``` + +The validator reports: + +- `unknown_type` / `missing_type` — type missing or not in the allowed list +- `oversize_subject` — subject longer than 72 characters +- `non_imperative` — leading verb is past tense, gerund, or third person +- `trailing_period` — subject ends with a period +- `unwrapped_body` — a body line longer than 72 columns + +## Programmatic use + +```go +import "github.com/marcus/nightshift/internal/commits" + +normalized, err := commits.Normalize(raw) // auto-fix +violations := commits.Validate(raw) // report +ok := commits.Conforms(raw) // bool +``` + +## Installing the hook + +```sh +scripts/install-commit-msg.sh +``` + +This writes `.git/hooks/commit-msg`, which runs `cmd/commit-lint` on the staged +message and rejects the commit if it does not conform. Re-run the script after a +fresh clone. diff --git a/internal/commits/errors.go b/internal/commits/errors.go new file mode 100644 index 0000000..660d647 --- /dev/null +++ b/internal/commits/errors.go @@ -0,0 +1,9 @@ +package commits + +import "errors" + +// Sentinel errors returned by Parse. +var ( + // ErrEmptyMessage is returned when the input contains no non-blank lines. + ErrEmptyMessage = errors.New("commits: empty commit message") +) diff --git a/internal/commits/normalize.go b/internal/commits/normalize.go new file mode 100644 index 0000000..b267b11 --- /dev/null +++ b/internal/commits/normalize.go @@ -0,0 +1,345 @@ +// Package commits parses, normalizes, and validates Git commit messages +// following the Conventional Commits specification. +// +// The standard message shape enforced by this package is: +// +// (): +// +// +// +// +// +// where: +// - type is a lowercase, known Conventional Commits type, +// - scope is optional, lowercase, and trimmed, +// - subject is a <=72 character imperative-mood phrase with no trailing period, +// - the body is hard-wrapped to <=72 columns, and +// - trailers are preserved verbatim and emitted in a stable order. +package commits + +import ( + "regexp" + "sort" + "strings" + "unicode" +) + +// MaxSubjectWidth is the hard limit on the header subject length. +const MaxSubjectWidth = 72 + +// MaxBodyWidth is the column at which the body is hard-wrapped. +const MaxBodyWidth = 72 + +// AllowedTypes is the set of recognized Conventional Commits types. +var AllowedTypes = map[string]bool{ + "feat": true, + "fix": true, + "docs": true, + "style": true, + "refactor": true, + "perf": true, + "test": true, + "build": true, + "ci": true, + "chore": true, + "revert": true, +} + +// AllowedScopes is the set of known scopes. A scope that is not in this set is +// not rejected — it is only lower-cased and trimmed — but listing the canonical +// scopes here lets tooling warn on typos. +var AllowedScopes = map[string]bool{ + "commits": true, + "config": true, + "scheduler": true, + "providers": true, + "agents": true, + "cli": true, + "docs": true, + "website": true, + "db": true, + "reporting": true, + "security": true, + "setup": true, + "orchestrator": true, + "budget": true, +} + +// imperativeFixes maps common non-imperative verb forms to their imperative +// base. Used both to auto-fix the subject and to detect non-imperative mood. +var imperativeFixes = map[string]string{ + "added": "add", "adds": "add", "adding": "add", + "fixed": "fix", "fixes": "fix", "fixing": "fix", + "updated": "update", "updates": "update", "updating": "update", + "removed": "remove", "removes": "remove", "removing": "remove", + "created": "create", "creates": "create", "creating": "create", + "changed": "change", "changes": "change", "changing": "change", + "renamed": "rename", "renames": "rename", "renaming": "rename", + "moved": "move", "moves": "move", "moving": "move", + "deleted": "delete", "deletes": "delete", "deleting": "delete", + "improved": "improve", "improves": "improve", "improving": "improve", + "refactored": "refactor", "refactors": "refactor", "refactoring": "refactor", + "implemented": "implement", "implements": "implement", "implementing": "implement", + "introduced": "introduce", "introduces": "introduce", "introducing": "introduce", + "supported": "support", "supports": "support", "supporting": "support", + "handled": "handle", "handles": "handle", "handling": "handle", +} + +var ( + // headerRe captures the optional type, scope, breaking marker, and subject + // from the first non-blank line of a commit message. + headerRe = regexp.MustCompile(`^\s*([A-Za-z]+)(?:\(([^)]*)\))?(!)?:\s*(.*)$`) + // trailerRe matches a single git trailer line "Key: value" or "Key #value". + trailerRe = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9._-]*)\s*[:#]\s*(.+)$`) +) + +// Trailer is a single git trailer (e.g. "Signed-off-by: Alice "). +type Trailer struct { + Key string + Value string +} + +// Parsed is the structured representation of a raw commit message. +type Parsed struct { + Type string + Scope string + Breaking bool + Subject string + Body string // body text without trailers + Trailers []Trailer + Raw string // original input +} + +// Parse splits a raw commit message into its header fields, body, and trailers. +// It returns an error only when no header can be identified at all. +func Parse(raw string) (*Parsed, error) { + p := &Parsed{Raw: raw} + + // Normalize CRLF to LF and split. + normalized := strings.ReplaceAll(raw, "\r\n", "\n") + lines := strings.Split(normalized, "\n") + + // Locate the first non-blank line — that is the header. + headerIdx := -1 + for i, l := range lines { + if strings.TrimSpace(l) != "" { + headerIdx = i + break + } + } + if headerIdx < 0 { + return p, ErrEmptyMessage + } + + header := lines[headerIdx] + m := headerRe.FindStringSubmatch(header) + if m == nil { + // No recognizable header: keep the raw line as the subject so callers can + // still normalize/validate. + p.Subject = strings.TrimSpace(header) + } else { + p.Type = m[1] + p.Scope = strings.TrimSpace(m[2]) + p.Breaking = m[3] == "!" + p.Subject = strings.TrimSpace(m[4]) + } + + // Remaining lines form the body + trailer block. + rest := lines[headerIdx+1:] + p.Body, p.Trailers = splitBodyAndTrailers(rest) + + return p, nil +} + +// splitBodyAndTrailers separates trailing trailer lines from the prose body. +// A contiguous run of trailer-formatted lines at the very end of the message +// (preceded by a blank line, or immediately following the header) is treated as +// the trailer block. +func splitBodyAndTrailers(lines []string) (string, []Trailer) { + // Drop a single leading blank line (the separator after the header). + for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" { + lines = lines[1:] + } + // Trim trailing blank lines. + for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { + lines = lines[:len(lines)-1] + } + if len(lines) == 0 { + return "", nil + } + + // Walk backward collecting contiguous trailer lines. + var trailers []Trailer + i := len(lines) + for i > 0 { + idx := i - 1 + line := lines[idx] + if strings.TrimSpace(line) == "" { + break + } + m := trailerRe.FindStringSubmatch(line) + if m == nil { + break + } + trailers = append([]Trailer{{Key: m[1], Value: strings.TrimSpace(m[2])}}, trailers...) + i = idx + } + + bodyLines := lines[:i] + // Trim trailing blank lines left between body and trailers. + for len(bodyLines) > 0 && strings.TrimSpace(bodyLines[len(bodyLines)-1]) == "" { + bodyLines = bodyLines[:len(bodyLines)-1] + } + return strings.TrimRight(strings.Join(bodyLines, "\n"), " \t"), trailers +} + +// Normalize rewrites the parsed message into the standard format. It is +// idempotent: normalizing an already-canonical message yields the same text. +func (p *Parsed) Normalize() string { + var b strings.Builder + + // Header. + typ := strings.ToLower(strings.TrimSpace(p.Type)) + scope := strings.ToLower(strings.TrimSpace(p.Scope)) + b.WriteString(typ) + if scope != "" { + b.WriteString("(") + b.WriteString(scope) + b.WriteString(")") + } + if p.Breaking { + b.WriteString("!") + } + b.WriteString(": ") + b.WriteString(normalizeSubject(p.Subject)) + + // Body. + body := wrapBody(strings.TrimSpace(p.Body)) + if body != "" { + b.WriteString("\n\n") + b.WriteString(body) + } + + // Trailers (stable order: external trailers first in original order, then + // Nightshift-* trailers sorted alphabetically). + if len(p.Trailers) > 0 { + b.WriteString("\n\n") + b.WriteString(strings.Join(formatTrailers(p.Trailers), "\n")) + } + + return b.String() +} + +// Normalize is a convenience that parses raw input and returns the canonical +// message. +func Normalize(raw string) (string, error) { + p, err := Parse(raw) + if err != nil { + return "", err + } + return p.Normalize(), nil +} + +// normalizeSubject trims, drops trailing punctuation, coerces the leading verb +// into imperative mood, lower-cases the first letter (the dominant Conventional +// Commits style), and clamps to MaxSubjectWidth. +func normalizeSubject(subject string) string { + s := strings.TrimSpace(subject) + s = strings.TrimRight(s, ".!?") + s = toImperative(s) + s = lowercaseFirst(s) + if len([]rune(s)) > MaxSubjectWidth { + runes := []rune(s) + s = string(runes[:MaxSubjectWidth]) + } + return s +} + +// toImperative rewrites the leading word if it is a known non-imperative form. +func toImperative(s string) string { + if s == "" { + return s + } + firstSpace := strings.IndexAny(s, " \t") + var first, rest string + if firstSpace < 0 { + first, rest = s, "" + } else { + first, rest = s[:firstSpace], s[firstSpace:] + } + lower := strings.ToLower(first) + if base, ok := imperativeFixes[lower]; ok { + return base + rest + } + return s +} + +// lowercaseFirst lower-cases the first rune of s, leaving the rest untouched. +func lowercaseFirst(s string) string { + if s == "" { + return s + } + r := []rune(s) + r[0] = unicode.ToLower(r[0]) + return string(r) +} + +// wrapBody hard-wraps each paragraph of the body to MaxBodyWidth columns, +// preserving blank-line paragraph breaks. +func wrapBody(body string) string { + if body == "" { + return "" + } + paragraphs := strings.Split(body, "\n\n") + var out []string + for _, para := range paragraphs { + // Collapse internal newlines within a paragraph into spaces so wrapping + // produces even lines, then re-wrap. + flat := strings.Join(strings.Fields(para), " ") + if flat == "" { + out = append(out, "") + continue + } + out = append(out, wrap(flat, MaxBodyWidth)) + } + return strings.Join(out, "\n\n") +} + +// wrap hard-wraps a single line (no internal newlines) to width columns on word +// boundaries. +func wrap(s string, width int) string { + words := strings.Fields(s) + if len(words) == 0 { + return "" + } + var lines []string + line := words[0] + for _, w := range words[1:] { + if len(line)+1+len(w) <= width { + line += " " + w + } else { + lines = append(lines, line) + line = w + } + } + lines = append(lines, line) + return strings.Join(lines, "\n") +} + +// formatTrailers renders trailers with a stable ordering: external trailers +// keep their relative input order, then Nightshift-* trailers are appended +// sorted alphabetically by key. +func formatTrailers(trailers []Trailer) []string { + var external []string + var nightshift []string + for _, t := range trailers { + line := t.Key + ": " + t.Value + if strings.HasPrefix(t.Key, "Nightshift-") { + nightshift = append(nightshift, line) + } else { + external = append(external, line) + } + } + sort.Strings(nightshift) + return append(external, nightshift...) +} diff --git a/internal/commits/normalize_test.go b/internal/commits/normalize_test.go new file mode 100644 index 0000000..991026e --- /dev/null +++ b/internal/commits/normalize_test.go @@ -0,0 +1,237 @@ +package commits + +import ( + "strings" + "testing" +) + +func mustParse(t *testing.T, raw string) *Parsed { + t.Helper() + p, err := Parse(raw) + if err != nil { + t.Fatalf("Parse(%q) error: %v", raw, err) + } + return p +} + +func TestParseHeader(t *testing.T) { + p := mustParse(t, "feat(commits): add normalizer") + if p.Type != "feat" { + t.Errorf("Type = %q, want feat", p.Type) + } + if p.Scope != "commits" { + t.Errorf("Scope = %q, want commits", p.Scope) + } + if p.Subject != "add normalizer" { + t.Errorf("Subject = %q", p.Subject) + } + if p.Breaking { + t.Error("Breaking should be false") + } +} + +func TestParseBreakingHeader(t *testing.T) { + p := mustParse(t, "feat(api)!: drop v1 endpoints") + if !p.Breaking { + t.Error("Breaking should be true") + } +} + +func TestNormalizeIdempotent(t *testing.T) { + cases := []string{ + "feat(commits): add normalizer", + "fix: handle empty body", + } + for _, in := range cases { + got, err := Normalize(in) + if err != nil { + t.Fatalf("Normalize(%q) error: %v", in, err) + } + if got != in { + t.Errorf("Normalize not idempotent for %q\n got: %q\nwant: %q", in, got, in) + } + } +} + +func TestNormalizeLowercasesTypeAndScope(t *testing.T) { + got, _ := Normalize("FEAT(Commits): Add normalizer") + want := "feat(commits): add normalizer" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestNormalizeTrimsTrailingPeriod(t *testing.T) { + got, _ := Normalize("feat: add thing.") + if got != "feat: add thing" { + t.Errorf("got %q", got) + } +} + +func TestNormalizeImperativeVerb(t *testing.T) { + cases := map[string]string{ + "feat: Added new thing": "feat: add new thing", + "fix: Fixes the bug": "fix: fix the bug", + "docs: Updated the readme": "docs: update the readme", + "refactor: Refactored X": "refactor: refactor X", + } + for in, want := range cases { + got, _ := Normalize(in) + if got != want { + t.Errorf("Normalize(%q) = %q, want %q", in, got, want) + } + } +} + +func TestNormalizePreservesCapitalizationWhenNoVerbMatch(t *testing.T) { + // "Enable" is already imperative; the first letter is lower-cased to the + // conventional subject style but the rest of the word is preserved. + got, _ := Normalize("feat: Enable dark mode") + want := "feat: enable dark mode" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestSubjectLengthClamping(t *testing.T) { + long := strings.Repeat("a", 100) + got, _ := Normalize("feat: " + long) + header := got + // The subject portion must be exactly MaxSubjectWidth. + if subj := strings.TrimPrefix(header, "feat: "); len(subj) != MaxSubjectWidth { + t.Errorf("clamped subject length %d, want %d; header=%q", len(subj), MaxSubjectWidth, header) + } +} + +func TestNormalizeBodyWrapping(t *testing.T) { + raw := "feat: add thing\n\n" + strings.Repeat("word ", 30) + "\n\nTrailer-1: value" + got, _ := Normalize(raw) + for i, line := range strings.Split(got, "\n") { + // Header and trailer lines are exempt; only check body paragraph lines. + if i == 0 || strings.HasPrefix(line, "Trailer-") { + continue + } + if len(line) > MaxBodyWidth { + t.Errorf("body line exceeds %d cols: %q (len %d)", MaxBodyWidth, line, len(line)) + } + } +} + +func TestNormalizePreservesParagraphBreaks(t *testing.T) { + raw := "feat: add thing\n\nfirst paragraph here\n\nsecond paragraph here" + got, _ := Normalize(raw) + if !strings.Contains(got, "\n\nsecond") { + t.Errorf("paragraph break not preserved: %q", got) + } +} + +func TestTrailerPreservation(t *testing.T) { + raw := "feat: add thing\n\nBody text.\n\nSigned-off-by: Alice \nReviewed-by: Bob" + p := mustParse(t, raw) + if len(p.Trailers) != 2 { + t.Fatalf("got %d trailers, want 2", len(p.Trailers)) + } + if p.Trailers[0].Key != "Signed-off-by" { + t.Errorf("first trailer key = %q", p.Trailers[0].Key) + } + got := p.Normalize() + if !strings.Contains(got, "Signed-off-by: Alice ") { + t.Errorf("trailer value lost:\n%s", got) + } +} + +func TestNightshiftTrailerOrdering(t *testing.T) { + raw := "feat: add thing\n\nNightshift-Task: commit-normalize\nNightshift-Ref: https://example.com\nSigned-off-by: Alice" + got, _ := Normalize(raw) + // External trailer must come before Nightshift trailers, and Nightshift + // trailers must be sorted alphabetically. + bobIdx := strings.Index(got, "Signed-off-by") + refIdx := strings.Index(got, "Nightshift-Ref") + taskIdx := strings.Index(got, "Nightshift-Task") + if !(bobIdx < refIdx && refIdx < taskIdx) { + t.Errorf("trailer ordering wrong:\n%s", got) + } +} + +func TestValidateWellFormed(t *testing.T) { + vs := Validate("feat(commits): add normalizer\n\nBody line one.\n\nSigned-off-by: A") + if len(vs) != 0 { + t.Errorf("expected no violations, got: %v", vs) + } +} + +func TestValidateMissingType(t *testing.T) { + vs := Validate("just a subject with no type") + if !hasCode(vs, CodeMissingType) && !hasCode(vs, CodeUnknownType) { + t.Errorf("expected missing/unknown type violation, got: %v", vs) + } +} + +func TestValidateUnknownType(t *testing.T) { + vs := Validate("wat: something") + if !hasCode(vs, CodeUnknownType) { + t.Errorf("expected unknown type, got: %v", vs) + } +} + +func TestValidateOversizeSubject(t *testing.T) { + vs := Validate("feat: " + strings.Repeat("a", 100)) + if !hasCode(vs, CodeOversizeSubject) { + t.Errorf("expected oversize subject, got: %v", vs) + } +} + +func TestValidateNonImperative(t *testing.T) { + vs := Validate("feat: Added the thing") + if !hasCode(vs, CodeNonImperative) { + t.Errorf("expected non-imperative, got: %v", vs) + } +} + +func TestValidateTrailingPeriod(t *testing.T) { + vs := Validate("feat: add thing.") + if !hasCode(vs, CodeTrailingPeriod) { + t.Errorf("expected trailing period, got: %v", vs) + } +} + +func TestValidateUnwrappedBody(t *testing.T) { + long := strings.Repeat("word ", 30) + vs := Validate("feat: add thing\n\n" + long) + if !hasCode(vs, CodeUnwrappedBody) { + t.Errorf("expected unwrapped body, got: %v", vs) + } +} + +func TestValidateEmpty(t *testing.T) { + vs := Validate(" \n\n ") + if !hasCode(vs, CodeMissingHeader) { + t.Errorf("expected missing header, got: %v", vs) + } +} + +func TestValidateBaseVerbEdEnding(t *testing.T) { + // "embed" ends in "ed" but is a legitimate base verb. + vs := Validate("feat: embed the payload") + if hasCode(vs, CodeNonImperative) { + t.Errorf("embed should not be flagged: %v", vs) + } +} + +func TestConforms(t *testing.T) { + if !Conforms("feat: add thing") { + t.Error("expected to conform") + } + if Conforms("nope: bad") { + t.Error("expected not to conform") + } +} + +func hasCode(vs []Violation, code string) bool { + for _, v := range vs { + if v.Code == code { + return true + } + } + return false +} diff --git a/internal/commits/validate.go b/internal/commits/validate.go new file mode 100644 index 0000000..e70c222 --- /dev/null +++ b/internal/commits/validate.go @@ -0,0 +1,212 @@ +package commits + +import ( + "fmt" + "strings" +) + +// Violation code constants. +const ( + CodeMissingHeader = "missing_header" + CodeMissingType = "missing_type" + CodeUnknownType = "unknown_type" + CodeOversizeSubject = "oversize_subject" + CodeNonImperative = "non_imperative" + CodeTrailingPeriod = "trailing_period" + CodeUnwrappedBody = "unwrapped_body" + CodeBadTrailer = "bad_trailer" +) + +// Violation is a single structured validation problem. +type Violation struct { + Code string // machine-readable code (Code* constant) + Field string // logical field the problem relates to + Message string // human-readable explanation +} + +// Error renders a violation as a single line. +func (v Violation) Error() string { + return fmt.Sprintf("%s: %s", v.Code, v.Message) +} + +// Validate reports structured violations for a raw commit message. An empty +// (nil) slice means the message already conforms to the standard format. +func Validate(raw string) []Violation { + p, err := Parse(raw) + if err != nil { + return []Violation{{ + Code: CodeMissingHeader, + Field: "header", + Message: "commit message is empty", + }} + } + + var vs []Violation + + // Type checks. + t := strings.ToLower(strings.TrimSpace(p.Type)) + if t == "" { + vs = append(vs, Violation{ + Code: CodeMissingType, + Field: "type", + Message: "commit header is missing a type (expected ': ')", + }) + } else if !AllowedTypes[t] { + vs = append(vs, Violation{ + Code: CodeUnknownType, + Field: "type", + Message: fmt.Sprintf( + "unknown type %q; allowed: %s", + p.Type, joinKeys(AllowedTypes), + ), + }) + } + + // Subject checks. + subject := strings.TrimSpace(p.Subject) + if subject == "" && t != "" { + vs = append(vs, Violation{ + Code: CodeMissingType, + Field: "subject", + Message: "commit subject is empty", + }) + } + if subject != "" { + if r := []rune(subject); len(r) > MaxSubjectWidth { + vs = append(vs, Violation{ + Code: CodeOversizeSubject, + Field: "subject", + Message: fmt.Sprintf( + "subject is %d characters, exceeds limit of %d", + len(r), MaxSubjectWidth, + ), + }) + } + if reason, bad := imperativeMood(subject); bad { + vs = append(vs, Violation{ + Code: CodeNonImperative, + Field: "subject", + Message: reason, + }) + } + if endsWithPeriod(subject) { + vs = append(vs, Violation{ + Code: CodeTrailingPeriod, + Field: "subject", + Message: "subject must not end with a period", + }) + } + } + + // Body wrapping checks. + if body := strings.TrimSpace(p.Body); body != "" { + for _, line := range strings.Split(body, "\n") { + if len([]rune(line)) > MaxBodyWidth { + vs = append(vs, Violation{ + Code: CodeUnwrappedBody, + Field: "body", + Message: fmt.Sprintf( + "body line is %d characters, exceeds %d: %q", + len([]rune(line)), MaxBodyWidth, truncate(line, 40), + ), + }) + break + } + } + } + + // Trailer format checks. + for _, tr := range p.Trailers { + if strings.TrimSpace(tr.Key) == "" || strings.TrimSpace(tr.Value) == "" { + vs = append(vs, Violation{ + Code: CodeBadTrailer, + Field: "trailers", + Message: fmt.Sprintf("malformed trailer: %s: %s", tr.Key, tr.Value), + }) + } + } + + return vs +} + +// imperativeMood reports whether the subject's leading word looks non-imperative +// (past tense, gerund, or third-person singular). Returns a human reason when +// the mood is bad. +func imperativeMood(subject string) (string, bool) { + first := strings.Fields(subject) + if len(first) == 0 { + return "", false + } + word := strings.ToLower(strings.TrimRight(first[0], ".!?")) + if _, bad := imperativeFixes[word]; bad { + return fmt.Sprintf("subject verb %q is not imperative (use the base form)", word), true + } + // Heuristic: words ending in "-ed"/"-ing" are usually non-imperative, unless + // they are known to be valid base verbs. Skip short words to avoid false + // positives on words like "red", "fed". + if len(word) > 4 && strings.HasSuffix(word, "ed") { + if _, common := commonBaseVerbs[word]; !common { + return fmt.Sprintf("subject verb %q looks like past tense; use imperative mood", word), true + } + } + if len(word) > 5 && strings.HasSuffix(word, "ing") { + return fmt.Sprintf("subject verb %q looks like a gerund; use imperative mood", word), true + } + return "", false +} + +// commonBaseVerbs are verbs that legitimately end in "ed" in their base form and +// must not be flagged as past tense. +var commonBaseVerbs = map[string]bool{ + "embed": true, + "shed": true, + "red": true, + "bred": true, + "speed": true, + "feed": true, + "breed": true, + "pled": true, +} + +func endsWithPeriod(s string) bool { + t := strings.TrimRight(s, " \t") + if t == "" { + return false + } + r := []rune(t) + last := r[len(r)-1] + return last == '.' || last == '。' +} + +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + +func joinKeys(m map[string]bool) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sortStrings(keys) + return strings.Join(keys, ", ") +} + +// sortStrings sorts s in place using a simple insertion sort to avoid pulling +// in the sort package here (it lives in normalize.go's import set already, but +// keeping validate.go dependencies minimal makes the file self-contained). +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j-1] > s[j]; j-- { + s[j-1], s[j] = s[j], s[j-1] + } + } +} + +// Conforms is a convenience predicate for callers that only need a boolean. +func Conforms(raw string) bool { + return len(Validate(raw)) == 0 +} diff --git a/scripts/install-commit-msg.sh b/scripts/install-commit-msg.sh new file mode 100755 index 0000000..c92816c --- /dev/null +++ b/scripts/install-commit-msg.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# install-commit-msg.sh — install a Git commit-msg hook that enforces the +# Nightshift Conventional Commits convention. +# +# Usage: scripts/install-commit-msg.sh [repo-root] +# +# The hook runs `go run ./cmd/commit-lint` on the staged commit message and +# rejects the commit when the message does not conform. Re-run this script after +# cloning to re-enable enforcement. +set -euo pipefail + +repo_root="${1:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}" +hooks_dir="$repo_root/.git/hooks" +hook_path="$hooks_dir/commit-msg" + +mkdir -p "$hooks_dir" + +cat >"$hook_path" <<'HOOK' +#!/usr/bin/env bash +# Auto-generated by scripts/install-commit-msg.sh — enforces Conventional Commits. +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +export GOFLAGS=-mod=mod + +if ! go run "$REPO_ROOT/cmd/commit-lint" "$1"; then + exit 1 +fi +HOOK + +chmod +x "$hook_path" + +echo "Installed commit-msg hook at: $hook_path" +echo "Future commits will be validated against the Conventional Commits convention."