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
15 changes: 14 additions & 1 deletion commit_check/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,19 @@ class CheckOutcome:
value: str = ""
error: str = ""
suggest: str = ""
rule_id: str = ""
docs_url: str = ""

def to_dict(self) -> dict[str, str]:
"""Serialise to a plain dict (suitable for JSON encoding)."""
return {
"rule_id": self.rule_id,
"check": self.check,
"status": self.status,
"value": self.value,
"error": self.error,
"suggest": self.suggest,
"docs_url": self.docs_url,
}


Expand Down Expand Up @@ -873,9 +877,18 @@ def validate_all_detailed(self, context: ValidationContext) -> list[CheckOutcome
value=failure.get("value", ""),
error=failure.get("error", ""),
suggest=failure.get("suggest", ""),
rule_id=rule.rule_id or "",
docs_url=rule.docs_url or "",
)
)
else:
outcomes.append(CheckOutcome(check=rule.check, status="pass"))
outcomes.append(
CheckOutcome(
check=rule.check,
status="pass",
rule_id=rule.rule_id or "",
docs_url=rule.docs_url or "",
)
)

return outcomes
17 changes: 17 additions & 0 deletions commit_check/rule_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
COMMIT_RULES,
BRANCH_RULES,
PUSH_RULES,
RULES_BY_CHECK,
RuleCatalogEntry,
)
from commit_check import (
Expand All @@ -31,6 +32,18 @@ class ValidationRule:
allowed: list[str] | None = None
ignored: list[str] | None = None

@property
def rule_id(self) -> str | None:
"""Stable rule ID from the catalog, e.g. ``CC003``."""
entry = RULES_BY_CHECK.get(self.check)
return entry.rule_id if entry else None

@property
def docs_url(self) -> str | None:
"""Link to this rule's section in the rules reference."""
entry = RULES_BY_CHECK.get(self.check)
return entry.docs_url if entry else None

def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for backward compatibility."""
result: dict[str, Any] = {
Expand All @@ -39,6 +52,10 @@ def to_dict(self) -> dict[str, Any]:
"error": self.error or "",
"suggest": self.suggest or "",
}
if self.rule_id:
result["rule_id"] = self.rule_id
if self.docs_url:
result["docs_url"] = self.docs_url
if self.value is not None:
result["value"] = self.value
if self.allowed:
Expand Down
66 changes: 65 additions & 1 deletion commit_check/rules_catalog.py
Original file line number Diff line number Diff line change
@@ -1,110 +1,159 @@
"""Centralized catalog of all commit-check rules, regexes, and error messages."""
"""Centralized catalog of all commit-check rules, regexes, and error messages.

Every user-facing rule has a **stable rule ID** (e.g. ``CC003``) that never
changes once released. Rule IDs give users a durable handle to reference in
documentation, error output, and machine-readable results.

ID ranges
---------
========= ==================================
``CC0xx`` Commit message rules
``CC1xx`` Author (name / email) rules
``CC2xx`` Branch rules
``CC3xx`` Push rules
========= ==================================

Internal bookkeeping entries that never produce a diagnostic (such as
``ignore_authors``) intentionally have no rule ID.
"""

from __future__ import annotations
from dataclasses import dataclass

#: Base URL of the rules reference documentation.
RULES_DOCS_URL = "https://docs.commit-check.com/rules.html"


@dataclass(frozen=True)
class RuleCatalogEntry:
check: str
regex: str | None = None
error: str | None = None
suggest: str | None = None
rule_id: str | None = None

@property
def name(self) -> str:
"""Human-readable rule name, e.g. ``subject-imperative``."""
return self.check.replace("_", "-")

@property
def docs_url(self) -> str | None:
"""Link to this rule's section in the rules reference, if it has an ID."""
if not self.rule_id:
return None
return f"{RULES_DOCS_URL}#{self.rule_id.lower()}"


# Commit message rules
COMMIT_RULES = [
RuleCatalogEntry(
rule_id="CC001",
check="message",
regex=None, # Built dynamically from config
error="The commit message should follow Conventional Commits. See https://www.conventionalcommits.org",
suggest="Use <type>(<scope>): <description> with allowed types",
),
RuleCatalogEntry(
rule_id="CC002",
check="subject_capitalized",
regex=None,
error="Subject must start with a capital letter",
suggest="Capitalize the first word of the subject",
),
RuleCatalogEntry(
rule_id="CC003",
check="subject_imperative",
regex=None,
error="Commit message should use imperative mood (e.g., 'fix bug' not 'fixed bug', 'add feature' not 'adding feature')",
suggest="Change the first verb to imperative form, e.g., 'fix' instead of 'fixed'/'fixes'/'fixing'",
),
RuleCatalogEntry(
rule_id="CC004",
check="subject_max_length",
regex=None,
error="Subject must be at most {max_len} characters",
suggest="Keep the subject concise (<= configured max)",
),
RuleCatalogEntry(
rule_id="CC005",
check="subject_min_length",
regex=None,
error="Subject must be at least {min_len} characters",
suggest="Provide a meaningful subject (>= configured min)",
),
RuleCatalogEntry(
rule_id="CC006",
check="allow_merge_commits",
regex=None,
error="Merge commits are not allowed",
suggest="Rebase or squash your changes instead of merging",
),
RuleCatalogEntry(
rule_id="CC007",
check="allow_revert_commits",
regex=None,
error="Revert commits are not allowed",
suggest="Avoid using 'revert' commits; rewrite history if necessary",
),
RuleCatalogEntry(
rule_id="CC008",
check="allow_empty_commits",
regex=None,
error="Empty commit messages are not allowed",
suggest="Provide a non-empty subject",
),
RuleCatalogEntry(
rule_id="CC009",
check="allow_fixup_commits",
regex=None,
error="Fixup commits are not allowed",
suggest="Use interactive rebase to clean up fixup commits",
),
RuleCatalogEntry(
rule_id="CC010",
check="allow_wip_commits",
regex=None,
error="WIP commits are not allowed",
suggest="Complete the work before committing or remove 'WIP'",
),
RuleCatalogEntry(
rule_id="CC011",
check="require_body",
regex=None,
error="Commit body is required",
suggest="Add a body explaining the change",
),
RuleCatalogEntry(
rule_id="CC101",
check="author_name",
regex=r"^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.'\-]+$|.*(\[bot])",
error="The committer name seems invalid",
suggest="git config user.name 'Your Name'",
),
RuleCatalogEntry(
rule_id="CC102",
check="author_email",
regex=r"^.+@.+$",
error="The committer's email seems invalid",
suggest="git config user.email yourname@example.com",
),
RuleCatalogEntry(
# Internal bookkeeping entry - never produces a diagnostic.
check="ignore_authors",
regex=None,
error=None,
suggest=None,
),
RuleCatalogEntry(
rule_id="CC012",
check="require_signed_off_by",
regex=r"Signed-off-by: .+ <.+@.+>",
error="Signed-off-by not found in latest commit",
suggest="git commit --amend --signoff or use --signoff on commit",
),
RuleCatalogEntry(
rule_id="CC013",
check="ai_attribution",
regex=None,
error="AI attribution policy violation",
Expand All @@ -115,6 +164,7 @@ class RuleCatalogEntry:
# Push rules
PUSH_RULES = [
RuleCatalogEntry(
rule_id="CC301",
check="no_force_push",
regex=None,
error="Force push is not allowed",
Expand All @@ -125,21 +175,35 @@ class RuleCatalogEntry:
# Branch rules
BRANCH_RULES = [
RuleCatalogEntry(
rule_id="CC201",
check="branch",
regex=None, # Built dynamically from config
error="The branch should follow Conventional Branch. See https://conventionalbranch.org",
suggest="Use <type>/<description> with allowed types or add branch name to allow_branch_names in config, or use ignore_authors in config branch section to bypass",
),
RuleCatalogEntry(
rule_id="CC202",
check="merge_base",
regex=None, # Provided by config
error="Current branch is not rebased onto target branch",
suggest="Rebase or merge with the target branch",
),
RuleCatalogEntry(
# Internal bookkeeping entry - never produces a diagnostic.
check="ignore_authors",
regex=None,
error=None,
suggest=None,
),
]

#: All catalog entries that represent a user-facing, documented rule.
ALL_RULES = [
entry
for entry in (*COMMIT_RULES, *BRANCH_RULES, *PUSH_RULES)
if entry.rule_id is not None
]

#: Lookup from check name to its catalog entry, for rules that have an ID.
#: Rule identity lives only here, so built rules can never carry a stale copy.
RULES_BY_CHECK = {entry.check: entry for entry in ALL_RULES}
27 changes: 20 additions & 7 deletions commit_check/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,25 @@ def _print_failure(
compact: bool = False,
) -> None:
"""Print a standardized failure message."""
rule_id = check.get("rule_id", "")
if compact:
compact_value = actual.splitlines()[0] if actual else actual
print(f"[FAIL] {check['check']}: {compact_value}")
label = f"{rule_id} {check['check']}" if rule_id else check["check"]
print(f"[FAIL] {label}: {compact_value}")
return
if not no_banner and not print_error_header.has_been_called:
print_error_header()
print_error_message(check["check"], check.get("error", ""), actual)
print_error_message(
check["check"],
check.get("error", ""),
actual,
rule_id=rule_id,
)
if check.get("suggest"):
print_suggestion(check["suggest"])
docs_url = check.get("docs_url", "")
if docs_url:
print(f"Docs: {docs_url}")


def get_branch_name() -> str:
Expand Down Expand Up @@ -284,16 +294,19 @@ def print_error_header():
print(" ")


def print_error_message(check_type: str, error: str, reason: str):
def print_error_message(check_type: str, error: str, reason: str, rule_id: str = ""):
"""Print error message.
:param check_type:
:param error:
:param reason:

:param check_type: the check that failed, e.g. ``subject_imperative``
:param error: the human-readable explanation of the failure
:param reason: the offending value
:param rule_id: stable rule ID, e.g. ``CC003`` (omitted when empty)

:returns: Give error messages to user
"""
prefix = f"{YELLOW}{rule_id}{RESET_COLOR} " if rule_id else ""
print(
f"Type {YELLOW}{check_type}{RESET_COLOR} check failed ==> {RED}{reason}{RESET_COLOR} ",
f"{prefix}{YELLOW}{check_type}{RESET_COLOR} check failed ==> {RED}{reason}{RESET_COLOR} ",
end="",
)
print("")
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
self
what-is-new
configuration
rules
example
migration
troubleshoot
Expand Down
Loading