diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index 372471f..4091496 100644 --- a/src/git_commit_guard/__init__.py +++ b/src/git_commit_guard/__init__.py @@ -7,6 +7,7 @@ from pathlib import Path import nltk +from nltk.corpus import wordnet TYPES = frozenset( { @@ -24,113 +25,7 @@ } ) -IMPERATIVE_VERBS = frozenset( - { - "add", - "allow", - "apply", - "avoid", - "bump", - "catch", - "change", - "check", - "clean", - "clear", - "configure", - "correct", - "create", - "define", - "delete", - "deprecate", - "disable", - "document", - "drop", - "enable", - "enforce", - "ensure", - "exclude", - "export", - "extend", - "extract", - "fix", - "format", - "guard", - "handle", - "ignore", - "implement", - "import", - "improve", - "include", - "increase", - "initialize", - "inline", - "install", - "introduce", - "invalidate", - "limit", - "log", - "lint", - "make", - "mark", - "merge", - "migrate", - "move", - "normalize", - "open", - "optimize", - "override", - "parse", - "pass", - "patch", - "pin", - "port", - "prevent", - "print", - "provide", - "publish", - "reduce", - "refactor", - "release", - "remove", - "rename", - "reorganize", - "replace", - "report", - "require", - "reset", - "resolve", - "restore", - "restrict", - "return", - "revert", - "run", - "separate", - "set", - "show", - "simplify", - "skip", - "sort", - "split", - "start", - "stop", - "store", - "support", - "suppress", - "switch", - "sync", - "track", - "trim", - "unify", - "update", - "upgrade", - "use", - "validate", - "vendor", - "verify", - "wait", - "wrap", - } -) +_NON_IMPERATIVE_SUFFIX_RE = re.compile(r"(?:ing|ed)$") SUBJECT_RE = re.compile( r"^(?P\w+)(?:\((?P[^)]+)\))?!?:\s+(?P.+)$", @@ -190,6 +85,7 @@ def ok(self): def _ensure_nltk_data(): _download_if_missing("taggers/averaged_perceptron_tagger_eng") _download_if_missing("tokenizers/punkt_tab") + _download_if_missing("corpora/wordnet") def _download_if_missing(resource): @@ -229,10 +125,19 @@ def check_imperative(desc, result): if not tokens: return first = tokens[0] - if first in IMPERATIVE_VERBS: + if _NON_IMPERATIVE_SUFFIX_RE.search(first): + result.error(f"expected imperative verb, got '{first}' (non-imperative suffix)") + return + base = wordnet.morphy(first, wordnet.VERB) + if base is not None and base != first: + result.error( + f"expected imperative verb, got '{first}' (inflected form of '{base}')" + ) return - tagged = nltk.pos_tag(["i", *tokens]) - if tagged[1][1] not in {"VB", "VBP"}: + tagged = nltk.pos_tag(["to", *tokens]) + if tagged[1][1] != "VB": + if wordnet.morphy(first, wordnet.VERB) == first: + return result.error( f"expected imperative verb, got '{tagged[1][0]}' (POS={tagged[1][1]})", ) diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py index 0755232..13cdab5 100644 --- a/tests/test_git_commit_guard.py +++ b/tests/test_git_commit_guard.py @@ -190,17 +190,17 @@ def test_malformed_no_email(self): class TestCheckImperative: - def test_whitelist_verb_passes(self): + def test_imperative_verb_passes(self): r = Result() check_imperative("add token refresh", r) assert r.ok - def test_past_tense_fails(self): + def test_ed_suffix_fails(self): r = Result() check_imperative("added token refresh", r) assert not r.ok - def test_gerund_fails(self): + def test_ing_suffix_fails(self): r = Result() check_imperative("adding token refresh", r) assert not r.ok @@ -210,6 +210,11 @@ def test_third_person_fails(self): check_imperative("adds token refresh", r) assert not r.ok + def test_third_person_es_suffix_fails(self): + r = Result() + check_imperative("fixes the bug", r) + assert not r.ok + def test_non_whitelist_imperative_passes(self): r = Result() check_imperative("refactor authentication module", r) @@ -220,6 +225,27 @@ def test_write_imperative_passes(self): check_imperative("write unit tests", r) assert r.ok + def test_tagger_misclassified_verb_passes(self): + # 'disable' is tagged non-VB by the tagger but wordnet confirms it as a verb + r = Result() + check_imperative("disable feature flag", r) + assert r.ok + + def test_refactor_passes(self): + r = Result() + check_imperative("refactor authentication module", r) + assert r.ok + + def test_vendor_passes(self): + r = Result() + check_imperative("vendor third-party libs", r) + assert r.ok + + def test_configure_passes(self): + r = Result() + check_imperative("configure logging pipeline", r) + assert r.ok + def test_empty_desc_passes(self): r = Result() check_imperative("", r)