From 5c6620a1f544d9f3dd11cf5092efd698dc260827 Mon Sep 17 00:00:00 2001 From: Andrew Rynhard Date: Mon, 21 Jan 2019 07:20:27 -0800 Subject: [PATCH] feat(policy): add imperative mood check (#108) Signed-off-by: Andrew Rynhard --- .conform.yaml | 23 +- README.md | 17 +- go.mod | 1 + go.sum | 2 + internal/enforcer/enforcer.go | 6 +- internal/policy/commit/blacklist.go | 84 +++++++ internal/policy/commit/commit.go | 121 +++++++++ .../commit_test.go} | 11 +- internal/policy/commit/whitelist.go | 236 ++++++++++++++++++ .../conventionalcommit/conventionalcommit.go | 113 --------- 10 files changed, 473 insertions(+), 141 deletions(-) create mode 100644 internal/policy/commit/blacklist.go rename internal/policy/{conventionalcommit/conventionalcommit_test.go => commit/commit_test.go} (94%) create mode 100644 internal/policy/commit/whitelist.go delete mode 100644 internal/policy/conventionalcommit/conventionalcommit.go diff --git a/.conform.yaml b/.conform.yaml index 5d41123e..e08a2446 100644 --- a/.conform.yaml +++ b/.conform.yaml @@ -4,18 +4,17 @@ policies: headerLength: 89 dco: true gpg: true -- type: conventionalCommit - spec: - types: - - chore - - docs - - perf - - refactor - - style - - test - scopes: - - policy - - '*' + imperative: true + conventional: + types: + - chore + - docs + - perf + - refactor + - style + - test + scopes: + - policy - type: license spec: includeSuffixes: diff --git a/README.md b/README.md index 7628a8d0..a47d87e7 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,12 @@ Some of the policies included are: -- **Commits**: Enforce basic commit policies including: +- **Commits**: Enforce commit policies including: - Commit message header length - Developer Certificate of Origin - GPG signature -- **Conventional Commits**: Enforce [conventional commits](https://www.conventionalcommits.org) for all commit messages. + - [Conventional Commits](https://www.conventionalcommits.org) + - Imperative verb - **License Headers**: Enforce license headers on source code files. ## Getting Started @@ -36,12 +37,12 @@ policies: headerLength: 89 dco: true gpg: true -- type: conventionalCommit - spec: - types: - - "type" - scopes: - - "scope" + imperative: true + conventional: + types: + - "type" + scopes: + - "scope" - type: license spec: includeSuffixes: diff --git a/go.mod b/go.mod index 1ba8ea81..abaadc8b 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v0.0.0-20170525151105-fa48d7ff1cfb // indirect + github.com/kljensen/snowball v0.6.0 github.com/kr/pretty v0.1.0 // indirect github.com/magiconair/properties v1.7.2 // indirect github.com/mitchellh/go-homedir v0.0.0-20161203194507-b8bc1bf76747 diff --git a/go.sum b/go.sum index edcc3183..37961245 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v0.0.0-20170525151105-fa48d7ff1cfb h1:4qB7kGgjot2tlCOW66sJ+ai5tv81oIDM9t6cvyFTKLM= github.com/kevinburke/ssh_config v0.0.0-20170525151105-fa48d7ff1cfb/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kljensen/snowball v0.6.0 h1:6DZLCcZeL0cLfodx+Md4/OLC6b/bfurWUOUGs1ydfOU= +github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= diff --git a/internal/enforcer/enforcer.go b/internal/enforcer/enforcer.go index ab6e2294..f05965a8 100644 --- a/internal/enforcer/enforcer.go +++ b/internal/enforcer/enforcer.go @@ -12,7 +12,6 @@ import ( "github.com/autonomy/conform/internal/policy" "github.com/autonomy/conform/internal/policy/commit" - "github.com/autonomy/conform/internal/policy/conventionalcommit" "github.com/autonomy/conform/internal/policy/license" "github.com/mitchellh/mapstructure" "github.com/pkg/errors" @@ -34,9 +33,8 @@ type PolicyDeclaration struct { // policyMap defines the set of policies allowed within Conform. var policyMap = map[string]policy.Policy{ - "commit": &commit.Commit{}, - "conventionalCommit": &conventionalcommit.Conventional{}, - "license": &license.License{}, + "commit": &commit.Commit{}, + "license": &license.License{}, // "version": &version.Version{}, } diff --git a/internal/policy/commit/blacklist.go b/internal/policy/commit/blacklist.go new file mode 100644 index 00000000..56e11495 --- /dev/null +++ b/internal/policy/commit/blacklist.go @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package commit + +// BlackList is the set of black listed imperative verbs. +var BlackList = []string{ + "a", + "an", + "the", + "action", + "always", + "api", + "base", + "basic", + "business", + "calculation", + "callback", + "collection", + "common", + "constructor", + "convenience", + "convenient", + "current", + "currently", + "custom", + "data", + "data", + "default", + "deprecated", + "description", + "dict", + "dictionary", + "does", + "dummy", + "example", + "factory", + "false", + "final", + "formula", + "function", + "generic", + "handler", + "handler", + "helper", + "here", + "hook", + "implementation", + "importantly", + "internal", + "it", + "main", + "method", + "module", + "new", + "number", + "optional", + "package", + "placeholder", + "reference", + "result", + "same", + "schema", + "setup", + "should", + "simple", + "some", + "special", + "sql", + "standard", + "static", + "string", + "subclasses", + "that", + "these", + "this", + "true", + "unique", + "unit", + "utility", + "what", + "wrapper", +} diff --git a/internal/policy/commit/commit.go b/internal/policy/commit/commit.go index 95da8d86..e3d641d5 100644 --- a/internal/policy/commit/commit.go +++ b/internal/policy/commit/commit.go @@ -11,6 +11,7 @@ import ( "github.com/autonomy/conform/internal/git" "github.com/autonomy/conform/internal/policy" + "github.com/kljensen/snowball" "github.com/pkg/errors" ) @@ -23,6 +24,18 @@ type Commit struct { DCO bool `mapstructure:"dco"` // GPG enables the GPG signature check. GPG bool `mapstructure:"gpg"` + // Imperative enforces the use of imperative verbs as the first word of a + // commit message. + Imperative bool `mapstructure:"imperative"` + // Conventional is the user specified settings for conventional commits. + Conventional *Conventional `mapstructure:"conventional"` +} + +// Conventional implements the policy.Policy interface and enforces commit +// messages to conform the Conventional Commit standard. +type Conventional struct { + Types []string `mapstructure:"types"` + Scopes []string `mapstructure:"scopes"` } // MaxNumberOfCommitCharacters is the default maximium number of characters @@ -32,7 +45,26 @@ var MaxNumberOfCommitCharacters = 89 // DCORegex is the regular expression used for Developer Certificate of Origin. var DCORegex = regexp.MustCompile(`^Signed-off-by: ([^<]+) <([^<>@]+@[^<>]+)>$`) +// FirstWordRegex is theregular expression used to find the first word in a +// commit. +var FirstWordRegex = regexp.MustCompile(`^\s*([a-zA-Z0-9]+)`) + +// HeaderRegex is the regular expression used for Conventional Commits +// 1.0.0-beta.1. +var HeaderRegex = regexp.MustCompile(`^(\w*)(\(([^)]+)\))?:\s{1}(.*)($|\n{2})`) + +const ( + // TypeFeat is a commit of the type fix patches a bug in your codebase + // (this correlates with PATCH in semantic versioning). + TypeFeat = "feat" + + // TypeFix is a commit of the type feat introduces a new feature to the + // codebase (this correlates with MINOR in semantic versioning). + TypeFix = "fix" +) + // Compliance implements the policy.Policy.Compliance function. +// nolint: gocyclo func (c *Commit) Compliance(options *policy.Options) (report policy.Report) { var err error @@ -72,6 +104,25 @@ func (c *Commit) Compliance(options *policy.Options) (report policy.Report) { ValidateGPGSign(&report, g) } + word := firstWord(msg) + + if c.Conventional != nil { + groups := parseHeader(msg) + if len(groups) != 6 { + report.Errors = append(report.Errors, errors.Errorf("Invalid conventional commits format: %s", msg)) + return + } + word = firstWord(groups[4]) + + ValidateType(&report, groups, c.Conventional.Types) + ValidateScope(&report, groups, c.Conventional.Scopes) + ValidateDescription(&report, groups) + } + + if c.Imperative { + ValidateImperative(&report, word) + } + return report } @@ -105,3 +156,73 @@ func ValidateGPGSign(report *policy.Report, g *git.Git) { report.Errors = append(report.Errors, errors.Errorf("Commit does not have a GPG signature")) } } + +// ValidateImperative checks the commit message for a GPG signature. +func ValidateImperative(report *policy.Report, word string) { + word = strings.ToLower(word) + for _, good := range WhiteList { + stemmed, err := snowball.Stem(good, "english", true) + if err != nil { + report.Errors = append(report.Errors, errors.Errorf("Error checking for imperative mood: %v", err)) + } + if word == stemmed { + return + } + } + for _, bad := range BlackList { + if word == bad { + report.Errors = append(report.Errors, errors.Errorf("Use of blacklisted imperative verb: %s", word)) + return + } + } + report.Errors = append(report.Errors, errors.Errorf("Commit does not have imperative mood")) +} + +// ValidateType returns the commit type. +func ValidateType(report *policy.Report, groups []string, types []string) { + types = append(types, TypeFeat, TypeFix) + for _, t := range types { + if t == groups[1] { + return + } + } + report.Errors = append(report.Errors, errors.Errorf("Invalid type: %s, allowed types are: %v", groups[1], types)) +} + +// ValidateScope returns the commit scope. +func ValidateScope(report *policy.Report, groups []string, scopes []string) { + // Scope is optional. + if groups[3] == "" { + return + } + for _, scope := range scopes { + if scope == groups[3] { + return + } + } + report.Errors = append(report.Errors, errors.Errorf("Invalid scope: %s, allowed scopes are: %v", groups[3], scopes)) +} + +// ValidateDescription returns the commit description. +func ValidateDescription(report *policy.Report, groups []string) { + if len(groups[4]) <= 72 && len(groups[4]) != 0 { + return + } + report.Errors = append(report.Errors, errors.Errorf("Invalid description: %s", groups[4])) +} + +func firstWord(msg string) string { + header := strings.Split(strings.TrimPrefix(msg, "\n"), "\n")[0] + groups := FirstWordRegex.FindStringSubmatch(header) + return groups[0] +} + +func parseHeader(msg string) []string { + // To circumvent any policy violation due to the leading \n that GitHub + // prefixes to the commit message on a squash merge, we remove it from the + // message. + header := strings.Split(strings.TrimPrefix(msg, "\n"), "\n")[0] + groups := HeaderRegex.FindStringSubmatch(header) + + return groups +} diff --git a/internal/policy/conventionalcommit/conventionalcommit_test.go b/internal/policy/commit/commit_test.go similarity index 94% rename from internal/policy/conventionalcommit/conventionalcommit_test.go rename to internal/policy/commit/commit_test.go index 4f6dd0d7..8860ce53 100644 --- a/internal/policy/conventionalcommit/conventionalcommit_test.go +++ b/internal/policy/commit/commit_test.go @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -package conventionalcommit +package commit import ( "io/ioutil" @@ -76,9 +76,12 @@ func TestInvalidConventionalCommitPolicy(t *testing.T) { } func runCompliance() (*policy.Report, error) { - c := &Conventional{} - c.Types = []string{"type"} - c.Scopes = []string{"scope"} + c := &Commit{ + Conventional: &Conventional{ + Types: []string{"type"}, + Scopes: []string{"scope"}, + }, + } report := c.Compliance(&policy.Options{}) diff --git a/internal/policy/commit/whitelist.go b/internal/policy/commit/whitelist.go new file mode 100644 index 00000000..20da538d --- /dev/null +++ b/internal/policy/commit/whitelist.go @@ -0,0 +1,236 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package commit + +// WhiteList is the set of whitelisted imperative verbs. +var WhiteList = []string{ + "accept", + "access", + "add", + "adjust", + "aggregate", + "allow", + "append", + "apply", + "archive", + "assert", + "assign", + "attempt", + "authenticate", + "authorize", + "break", + "build", + "cache", + "calculate", + "call", + "cancel", + "capture", + "change", + "check", + "clean", + "clear", + "close", + "collect", + "combine", + "commit", + "compare", + "compute", + "configure", + "confirm", + "connect", + "construct", + "control", + "convert", + "copy", + "count", + "create", + "customize", + "declare", + "decode", + "decorate", + "define", + "delegate", + "delete", + "deprecate", + "derive", + "describe", + "detect", + "determine", + "display", + "download", + "drop", + "dump", + "emit", + "empty", + "enable", + "encapsulate", + "encode", + "end", + "ensure", + "enumerate", + "establish", + "evaluate", + "examine", + "execute", + "exit", + "expand", + "expect", + "export", + "extend", + "extract", + "feed", + "fetch", + "fill", + "filter", + "finalize", + "find", + "fire", + "fix", + "flag", + "force", + "format", + "forward", + "generate", + "get", + "give", + "go", + "group", + "handle", + "help", + "hold", + "identify", + "implement", + "import", + "indicate", + "init", + "initalise", + "initialise", + "initialize", + "input", + "insert", + "instantiate", + "intercept", + "invoke", + "iterate", + "join", + "keep", + "launch", + "list", + "listen", + "load", + "log", + "look", + "make", + "manage", + "manipulate", + "map", + "mark", + "match", + "merge", + "mock", + "modify", + "monitor", + "move", + "normalize", + "note", + "obtain", + "open", + "output", + "override", + "overwrite", + "pad", + "parse", + "partial", + "pass", + "perform", + "persist", + "pick", + "plot", + "poll", + "populate", + "post", + "prepare", + "print", + "process", + "produce", + "provide", + "publish", + "pull", + "put", + "query", + "raise", + "read", + "record", + "refer", + "refresh", + "register", + "reload", + "remove", + "rename", + "render", + "replace", + "reply", + "report", + "represent", + "request", + "require", + "reset", + "resolve", + "retrieve", + "return", + "roll", + "rollback", + "round", + "run", + "sample", + "save", + "scan", + "search", + "select", + "send", + "serialise", + "serialize", + "serve", + "set", + "show", + "simulate", + "source", + "specify", + "split", + "start", + "step", + "stop", + "store", + "strip", + "submit", + "subscribe", + "sum", + "swap", + "sync", + "synchronise", + "synchronize", + "take", + "tear", + "test", + "time", + "transform", + "translate", + "transmit", + "truncate", + "try", + "turn", + "tweak", + "update", + "upload", + "use", + "validate", + "verify", + "view", + "wait", + "walk", + "wrap", + "write", + "yield", +} diff --git a/internal/policy/conventionalcommit/conventionalcommit.go b/internal/policy/conventionalcommit/conventionalcommit.go deleted file mode 100644 index 8ea63353..00000000 --- a/internal/policy/conventionalcommit/conventionalcommit.go +++ /dev/null @@ -1,113 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package conventionalcommit - -import ( - "io/ioutil" - "regexp" - "strings" - - "github.com/autonomy/conform/internal/git" - "github.com/autonomy/conform/internal/policy" - "github.com/pkg/errors" -) - -// Conventional implements the policy.Policy interface and enforces commit -// messages to conform the Conventional Commit standard. -type Conventional struct { - Types []string `mapstructure:"types"` - Scopes []string `mapstructure:"scopes"` -} - -// HeaderRegex is the regular expression used for Conventional Commits -// 1.0.0-beta.1. -var HeaderRegex = regexp.MustCompile(`^(\w*)(\(([^)]+)\))?:\s{1}(.*)($|\n{2})`) - -// TypeFeat is a commit of the type fix patches a bug in your codebase -// (this correlates with PATCH in semantic versioning). -const TypeFeat = "feat" - -// TypeFix is a commit of the type feat introduces a new feature to the -// codebase (this correlates with MINOR in semantic versioning). -const TypeFix = "fix" - -// Compliance implements the policy.Policy.Compliance function. -func (c *Conventional) Compliance(options *policy.Options) (report policy.Report) { - report = policy.Report{} - - var msg string - if options.CommitMsgFile != nil { - contents, err := ioutil.ReadFile(*options.CommitMsgFile) - if err != nil { - report.Errors = append(report.Errors, errors.Errorf("failed to read commit message file: %v", err)) - return - } - msg = string(contents) - } else { - g, err := git.NewGit() - if err != nil { - report.Errors = append(report.Errors, errors.Errorf("failed to open git repo: %v", err)) - return - } - if msg, err = g.Message(); err != nil { - report.Errors = append(report.Errors, errors.Errorf("failed to get commit message: %v", err)) - return - } - } - groups := parseHeader(msg) - if len(groups) != 6 { - report.Errors = append(report.Errors, errors.Errorf("Invalid commit format: %s", msg)) - return - } - - ValidateType(&report, groups, c.Types) - ValidateScope(&report, groups, c.Scopes) - ValidateDescription(&report, groups) - - return report -} - -// ValidateType returns the commit type. -func ValidateType(report *policy.Report, groups []string, types []string) { - types = append(types, TypeFeat, TypeFix) - for _, t := range types { - if t == groups[1] { - return - } - } - report.Errors = append(report.Errors, errors.Errorf("Invalid type: %s, allowed types are: %v", groups[1], types)) -} - -// ValidateScope returns the commit scope. -func ValidateScope(report *policy.Report, groups []string, scopes []string) { - // Scope is optional. - if groups[3] == "" { - return - } - for _, scope := range scopes { - if scope == groups[3] { - return - } - } - report.Errors = append(report.Errors, errors.Errorf("Invalid scope: %s, allowed scopes are: %v", groups[3], scopes)) -} - -// ValidateDescription returns the commit description. -func ValidateDescription(report *policy.Report, groups []string) { - if len(groups[4]) <= 72 && len(groups[4]) != 0 { - return - } - report.Errors = append(report.Errors, errors.Errorf("Invalid description: %s", groups[4])) -} - -func parseHeader(msg string) []string { - // To circumvent any policy violation due to the leading \n that GitHub - // prefixes to the commit message on a squash merge, we remove it from the - // message. - header := strings.Split(strings.TrimPrefix(msg, "\n"), "\n")[0] - groups := HeaderRegex.FindStringSubmatch(header) - - return groups -}