From 0c9a4e2196bc5f87cf3173acd2f9451444b1c597 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 5 Jul 2026 23:16:23 +0300 Subject: [PATCH 01/10] feat: add AI attribution governance policy (forbid/require/ignore) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a new validation subsystem that lets projects enforce their AI contribution policy at the commit-message level. This feature is motivated by the ongoing industry-wide discussion around AI disclosure in open source (CPython, Linux kernel, VS Code, Apache, Fedora, etc.) and requires no external dependencies. Configuration (under [commit]): ai_attribution = "forbid" | "require" | "ignore" (default: ignore) ai_trailer_style = "assisted-by" | "co-authored-by" (default: assisted-by) Key components: - commit_check/ai_signatures.py — curated database of known AI tool signatures (Claude Code, Copilot, Codex, Gemini, Cursor, Devin, Aider, Windsurf, Tabby, and generic AI patterns) - AiAttributionValidator — three-mode policy engine - AiTrailerStyleValidator — enforces Linux kernel Assisted-by or GitHub Co-authored-by style - Full CLI, API, env-var, and TOML config integration - 52 new tests covering all modes and detection patterns --- commit_check/__init__.py | 4 + commit_check/ai_signatures.py | 348 ++++++++++++++++++++++++++++++++++ commit_check/api.py | 2 + commit_check/config_merger.py | 8 + commit_check/engine.py | 166 ++++++++++++++++ commit_check/main.py | 23 +++ commit_check/rule_builder.py | 57 ++++++ commit_check/rules_catalog.py | 12 ++ tests/ai_signatures_test.py | 233 +++++++++++++++++++++++ tests/engine_test.py | 240 +++++++++++++++++++++++ tests/rule_builder_test.py | 117 ++++++++++++ 11 files changed, 1210 insertions(+) create mode 100644 commit_check/ai_signatures.py create mode 100644 tests/ai_signatures_test.py diff --git a/commit_check/__init__.py b/commit_check/__init__.py index 4ad25d48..33f266c8 100644 --- a/commit_check/__init__.py +++ b/commit_check/__init__.py @@ -72,5 +72,9 @@ "require_signed_off_by": False, } +# AI attribution defaults +DEFAULT_AI_ATTRIBUTION = "ignore" # "ignore" | "require" | "forbid" +DEFAULT_AI_TRAILER_STYLE = "assisted-by" # "assisted-by" | "co-authored-by" + __version__ = version("commit-check") diff --git a/commit_check/ai_signatures.py b/commit_check/ai_signatures.py new file mode 100644 index 00000000..24922946 --- /dev/null +++ b/commit_check/ai_signatures.py @@ -0,0 +1,348 @@ +"""Known AI tool signatures found in commit messages and trailers. + +This module maintains a curated directory of patterns that known AI coding +tools leave behind in commit messages. It is the technical core of the +``ai_attribution`` validator — keeping this database up to date is what +gives users a reason to use commit-check instead of rolling their own regex. + +Each entry describes: +* A human-readable tool name (for error messages). +* One or more regex patterns that match trailers, footers, or body markers + left by that tool. +* Whether the pattern is a ``trailer`` (structured key: value line, typically + in the commit body footer) or a ``body_marker`` (free-text marker anywhere + in the message body). + +Adding a new tool +----------------- +#. Find the commit-message artefacts the tool produces (e.g. + ``Co-Authored-By: Copilot ``). +#. Add a ``KnownAiTool`` entry with a unique name and one or more patterns. +#. Submit a PR — the project maintainers will review and release. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class AiSignaturePattern: + """A single pattern that identifies AI tool usage in a commit message. + + :param regex: A compiled regex that, if matched anywhere in the commit + message body, indicates the corresponding tool was involved. + :param kind: ``"trailer"`` for structured ``Key: value`` footer lines + (matched case-insensitively), ``"body_marker"`` for any other text + marker. + :param description: Human-readable description of what is matched. + """ + + regex: re.Pattern[str] + kind: str # "trailer" | "body_marker" + description: str = "" + + +@dataclass(frozen=True) +class KnownAiTool: + """A known AI coding tool and its commit-message signatures. + + :param name: Short display name (e.g. ``"Claude Code"``, ``"GitHub Copilot"``). + :param patterns: One or more signature patterns that indicate this tool. + """ + + name: str + patterns: list[AiSignaturePattern] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Pattern helpers +# --------------------------------------------------------------------------- + + +def _trailer( + key: str, value_pattern: str = r".*", description: str = "" +) -> AiSignaturePattern: + """Build a trailer pattern for a structured ``Key: value`` line. + + The match is case-insensitive and anchors the key at the start of a line. + """ + raw = rf"^{re.escape(key)}:\s*{value_pattern}\s*$" + return AiSignaturePattern( + regex=re.compile(raw, re.IGNORECASE | re.MULTILINE), + kind="trailer", + description=description or f"``{key}:`` trailer", + ) + + +def _body_marker(pattern: str, description: str = "") -> AiSignaturePattern: + """Build a free-text body marker pattern.""" + return AiSignaturePattern( + regex=re.compile(pattern, re.MULTILINE), + kind="body_marker", + description=description, + ) + + +# --------------------------------------------------------------------------- +# Known tool signatures +# --------------------------------------------------------------------------- + +# --- Anthropic Claude Code / Claude CLI --- +CLAUDE_CODE = KnownAiTool( + name="Claude Code", + patterns=[ + # Standard Co-authored-by trailer added by Claude Code + _trailer( + "Co-authored-by", + r"Claude(?:\s+Code)?\s*(?:<[^>]*>)?", + "``Co-authored-by: Claude`` trailer", + ), + # Assisted-by trailer (future-proof: Claude may adopt Linux kernel style) + _trailer( + "Assisted-by", r"Claude:\S+", "``Assisted-by: Claude:`` trailer" + ), + # Body marker: generated-with notice + _body_marker( + r"🤖\s*Generated\s+(?:with|by)\s+\[?Claude", + "``🤖 Generated with Claude`` body marker", + ), + # Session ID trailer (Claude Code sometimes adds this) + _trailer("Claude-Session", r"\S+", "``Claude-Session:`` trailer"), + # Workflow ID trailer + _trailer("Claude-Workflow", r"\S+", "``Claude-Workflow:`` trailer"), + ], +) + +# --- GitHub Copilot --- +COPILOT = KnownAiTool( + name="GitHub Copilot", + patterns=[ + _trailer( + "Co-authored-by", + r"Copilot\s*(?:<[^>]*>)?", + "``Co-authored-by: Copilot`` trailer", + ), + _trailer( + "Co-authored-by", + r"github-actions\[bot\]", + "``Co-authored-by: github-actions[bot]`` (Copilot)", + ), + ], +) + +# --- OpenAI Codex --- +CODEX = KnownAiTool( + name="OpenAI Codex", + patterns=[ + _trailer( + "Co-authored-by", + r"Codex\s*(?:<[^>]*>)?", + "``Co-authored-by: Codex`` trailer", + ), + ], +) + +# --- Gemini (Google) --- +GEMINI = KnownAiTool( + name="Gemini", + patterns=[ + _trailer( + "Co-authored-by", + r"Gemini\s*(?:<[^>]*>)?", + "``Co-authored-by: Gemini`` trailer", + ), + ], +) + +# --- Cursor --- +CURSOR = KnownAiTool( + name="Cursor", + patterns=[ + _trailer( + "Co-authored-by", + r"Cursor\s*(?:<[^>]*>)?", + "``Co-authored-by: Cursor`` trailer", + ), + ], +) + +# --- Devin --- +DEVIN = KnownAiTool( + name="Devin", + patterns=[ + _trailer( + "Co-authored-by", + r"Devin\s*(?:<[^>]*>)?", + "``Co-authored-by: Devin`` trailer", + ), + ], +) + +# --- Aider --- +AIDER = KnownAiTool( + name="Aider", + patterns=[ + _trailer( + "Co-authored-by", + r"Aider\s*(?:<[^>]*>)?", + "``Co-authored-by: Aider`` trailer", + ), + _body_marker( + r"^#\s+(?:Aider|aider)\s+(?:commit|chat)", + "``# aider commit`` body marker", + ), + ], +) + +# --- Windsurf (Codeium) --- +WINDSURF = KnownAiTool( + name="Windsurf", + patterns=[ + _trailer( + "Co-authored-by", + r"Windsurf\s*(?:<[^>]*>)?", + "``Co-authored-by: Windsurf`` trailer", + ), + ], +) + +# --- Tabby --- +TABBY = KnownAiTool( + name="Tabby", + patterns=[ + _trailer( + "Co-authored-by", + r"Tabby\s*(?:<[^>]*>)?", + "``Co-authored-by: Tabby`` trailer", + ), + ], +) + +# --- Generic / catch-all AI patterns --- +GENERIC_AI = KnownAiTool( + name="Generic AI", + patterns=[ + # Catch AI agent models in Co-authored-by (e.g. claude-sonnet, gpt-4) + _trailer( + "Co-authored-by", + r"(?:claude|gpt|gemini|llama|mistral)", + "``Co-authored-by`` with AI model name", + ), + # Catch Assisted-by trailer (Linux kernel style) regardless of agent + _trailer("Assisted-by", r"\S+:\S+", "``Assisted-by:`` trailer (kernel style)"), + # Catch common body markers + _body_marker( + r"^Generated\s+(?:by|with)\s+(?:AI|artificial intelligence)", + "``Generated by AI`` body marker", + ), + ], +) + +# --------------------------------------------------------------------------- +# Master registry — ordered by specificity (most specific first) +# --------------------------------------------------------------------------- + +#: All known AI tools, ordered so that more specific patterns are checked first. +ALL_KNOWN_TOOLS: list[KnownAiTool] = [ + CLAUDE_CODE, + COPILOT, + CODEX, + GEMINI, + CURSOR, + DEVIN, + AIDER, + WINDSURF, + TABBY, + GENERIC_AI, +] + +#: Flat list of all compiled patterns for bulk scanning. +ALL_PATTERNS: list[tuple[re.Pattern[str], str, str]] = [ + (p.regex, tool.name, p.description) + for tool in ALL_KNOWN_TOOLS + for p in tool.patterns +] + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def detect_ai_signatures(message: str) -> list[dict[str, str]]: + """Scan *message* for known AI tool signatures. + + :param message: The full commit message (subject + body) to scan. + :returns: A list of dicts, one per matched signature, each with keys + ``"tool"``, ``"kind"``, ``"description"``, and ``"matched_text"``. + Returns an empty list when no signatures are found. + + Example:: + + >>> detect_ai_signatures("feat: init\\n\\nCo-authored-by: Claude ") + [{'tool': 'Claude Code', 'kind': 'trailer', 'description': '``Co-authored-by: Claude`` trailer', 'matched_text': 'Co-authored-by: Claude '}] + """ + results: list[dict[str, str]] = [] + seen: set[str] = set() # deduplicate by matched text + + for regex, tool_name, desc in ALL_PATTERNS: + for match in regex.finditer(message): + matched = match.group(0).strip() + if matched not in seen: + seen.add(matched) + results.append( + { + "tool": tool_name, + "kind": "trailer" + if regex.pattern.startswith(r"^") + else "body_marker", + "description": desc, + "matched_text": matched, + } + ) + + return results + + +def has_ai_signature(message: str) -> bool: + """Return ``True`` if *message* contains any known AI signature.""" + for regex, _tool_name, _desc in ALL_PATTERNS: + if regex.search(message): + return True + return False + + +def find_co_authored_by_ai(message: str) -> list[str]: + """Find ``Co-authored-by`` trailer lines that reference known AI tools. + + Scans all known AI patterns and collects those that start with + ``Co-authored-by:`` (case-insensitive). Human co-authors like + ``Co-authored-by: Jane Doe `` are NOT returned + because no known AI tool pattern matches common human names. + + :returns: List of matched trailer lines. + """ + results: list[str] = [] + # Find all Co-authored-by patterns from known AI tools by checking + # the regex against the message line by line for Co-authored trailers. + co_pat = re.compile(r"^Co-authored-by:\s*", re.IGNORECASE | re.MULTILINE) + if not co_pat.search(message): + return results + + for regex, _tool_name, _desc in ALL_PATTERNS: + for match in regex.finditer(message): + matched = match.group(0).strip() + if matched.lower().startswith("co-authored-by:"): + results.append(matched) + return results + + +def find_assisted_by_trailers(message: str) -> list[str]: + """Find ``Assisted-by`` trailer lines in *message*. + + :returns: List of matched ``Assisted-by`` lines. + """ + pat = re.compile(r"^Assisted-by:\s*\S+.*$", re.MULTILINE | re.IGNORECASE) + return [m.group(0).strip() for m in pat.finditer(message)] diff --git a/commit_check/api.py b/commit_check/api.py index f7876858..3b996976 100644 --- a/commit_check/api.py +++ b/commit_check/api.py @@ -132,6 +132,8 @@ def validate_message( "allow_empty_commits", "allow_fixup_commits", "allow_wip_commits", + "ai_attribution", + "ai_trailer_style", ] return _run_checks(check_names, context, cfg) diff --git a/commit_check/config_merger.py b/commit_check/config_merger.py index f801869a..ef27d5c3 100644 --- a/commit_check/config_merger.py +++ b/commit_check/config_merger.py @@ -13,6 +13,8 @@ DEFAULT_BRANCH_NAMES, DEFAULT_BOOLEAN_RULES, DEFAULT_PUSH_RULES, + DEFAULT_AI_ATTRIBUTION, + DEFAULT_AI_TRAILER_STYLE, ) @@ -76,6 +78,8 @@ def get_default_config() -> dict[str, Any]: "require_body": DEFAULT_BOOLEAN_RULES["require_body"], "require_signed_off_by": DEFAULT_BOOLEAN_RULES["require_signed_off_by"], "ignore_authors": [], + "ai_attribution": DEFAULT_AI_ATTRIBUTION, + "ai_trailer_style": DEFAULT_AI_TRAILER_STYLE, }, "branch": { "conventional_branch": True, @@ -120,6 +124,8 @@ class ConfigMerger: "CCHK_REQUIRE_BODY": ("commit", "require_body", parse_bool), "CCHK_REQUIRE_SIGNED_OFF_BY": ("commit", "require_signed_off_by", parse_bool), "CCHK_IGNORE_AUTHORS": ("commit", "ignore_authors", parse_list), + "CCHK_AI_ATTRIBUTION": ("commit", "ai_attribution", str), + "CCHK_AI_TRAILER_STYLE": ("commit", "ai_trailer_style", str), # Branch section "CCHK_CONVENTIONAL_BRANCH": ("branch", "conventional_branch", parse_bool), "CCHK_ALLOW_BRANCH_TYPES": ("branch", "allow_branch_types", parse_list), @@ -147,6 +153,8 @@ class ConfigMerger: "require_body": ("commit", "require_body"), "require_signed_off_by": ("commit", "require_signed_off_by"), "ignore_authors": ("commit", "ignore_authors"), + "ai_attribution": ("commit", "ai_attribution"), + "ai_trailer_style": ("commit", "ai_trailer_style"), # Branch section "conventional_branch": ("branch", "conventional_branch"), "allow_branch_types": ("branch", "allow_branch_types"), diff --git a/commit_check/engine.py b/commit_check/engine.py index a475ff82..e7c676a4 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -7,6 +7,11 @@ from dataclasses import field from commit_check.rule_builder import ValidationRule +from commit_check.ai_signatures import ( + detect_ai_signatures, + find_co_authored_by_ai, + find_assisted_by_trailers, +) from commit_check.util import ( fetch_remote_ref, fetch_upstream_ref, @@ -708,6 +713,165 @@ def _is_wip_commit_allowed(self, message: str) -> bool: return not is_wip or self.rule.value +class AiAttributionValidator(BaseValidator): + """Validates commit messages against AI attribution policy. + + Three modes (configured via ``[commit] ai_attribution``): + + * ``forbid`` — Reject any commit that contains known AI tool signatures. + * ``require`` — If AI signatures are present, they MUST use the configured + trailer style (``ai_trailer_style``). Pure pass-through commits (no + signatures) are allowed. + * ``ignore`` — No validation (default). + """ + + def validate(self, context: ValidationContext) -> ValidationResult: + if self._should_skip_commit_validation(context): + return ValidationResult.PASS + + message = self._get_commit_body(context) + if not message: + return ValidationResult.PASS + + policy = self.rule.value # "ignore" | "require" | "forbid" + if policy == "ignore": + return ValidationResult.PASS + + signatures = detect_ai_signatures(message) + + if policy == "forbid": + if signatures: + tools = {s["tool"] for s in signatures} + self._record_failure( + value=", ".join(sorted(tools)), + error=f"AI-assisted commit is forbidden — detected tools: {', '.join(sorted(tools))}", + suggest="Remove AI-generated trailers from the commit message", + ) + return ValidationResult.FAIL + return ValidationResult.PASS + + if policy == "require": + if not signatures: + # Cannot detect undisclosed AI usage; pass through. + return ValidationResult.PASS + + # AI signatures found — check that they use the required style. + trailer_style = self.rule.allowed or ["assisted-by"] + preferred = trailer_style[0] if trailer_style else "assisted-by" + + if preferred == "assisted-by": + co_ai = find_co_authored_by_ai(message) + if co_ai: + self._record_failure( + value="; ".join(co_ai), + error="AI attribution style violation: project requires 'Assisted-by:' trailer (Linux kernel style)", + suggest="Replace 'Co-authored-by: ' with 'Assisted-by: :'", + ) + return ValidationResult.FAIL + elif preferred == "co-authored-by": + assisted = find_assisted_by_trailers(message) + if assisted: + self._record_failure( + value="; ".join(assisted), + error="AI attribution style violation: project requires 'Co-authored-by:' trailer", + suggest="Replace 'Assisted-by: :' with 'Co-authored-by: '", + ) + return ValidationResult.FAIL + + return ValidationResult.PASS + + return ValidationResult.PASS + + def _record_failure(self, value: str, error: str, suggest: str) -> None: + """Record a failure with dynamic error/suggest messages.""" + self._last_failure = { + "check": self.rule.check, + "value": value, + "error": error, + "suggest": suggest, + } + if not self._suppress_output: + rule_dict = self.rule.to_dict() + from commit_check.util import _print_failure + + _print_failure( + rule_dict, + value, + no_banner=self._no_banner, + compact=self._compact, + ) + + +class AiTrailerStyleValidator(BaseValidator): + """Validates that AI-related trailers use the project-preferred style. + + This validator is a companion to ``ai_attribution = "require"``. It checks + that any known AI trailers use the format specified by ``ai_trailer_style`` + in the config (``"assisted-by"`` or ``"co-authored-by"``). + + When the preferred style is ``"assisted-by"``: + * ``Co-authored-by: Claude`` → FAIL (should be ``Assisted-by: Claude:...``) + + When the preferred style is ``"co-authored-by"``: + * ``Assisted-by: Claude:claude-sonnet`` → FAIL + """ + + def validate(self, context: ValidationContext) -> ValidationResult: + if self._should_skip_commit_validation(context): + return ValidationResult.PASS + + message = self._get_commit_body(context) + if not message: + return ValidationResult.PASS + + preferred = self.rule.value # "assisted-by" or "co-authored-by" + signatures = detect_ai_signatures(message) + + if not signatures: + return ValidationResult.PASS + + if preferred == "assisted-by": + co_ai = find_co_authored_by_ai(message) + if co_ai: + self._record_failure( + value="; ".join(co_ai), + error="AI trailer style violation: project requires 'Assisted-by:' trailer", + suggest="Replace 'Co-authored-by: ' with 'Assisted-by: :'", + ) + return ValidationResult.FAIL + + elif preferred == "co-authored-by": + assisted = find_assisted_by_trailers(message) + if assisted: + self._record_failure( + value="; ".join(assisted), + error="AI trailer style violation: project requires 'Co-authored-by:' trailer", + suggest="Replace 'Assisted-by: :' with 'Co-authored-by: '", + ) + return ValidationResult.FAIL + + return ValidationResult.PASS + + def _record_failure(self, value: str, error: str, suggest: str) -> None: + """Record a failure with dynamic error/suggest messages.""" + self._last_failure = { + "check": self.rule.check, + "value": value, + "error": error, + "suggest": suggest, + } + if not self._suppress_output: + rule_dict = self.rule.to_dict() + from commit_check.util import _print_failure + + _print_failure( + rule_dict, + value, + no_banner=self._no_banner, + compact=self._compact, + ) + + class ValidationEngine: """Main validation engine that orchestrates all validations.""" @@ -730,6 +894,8 @@ class ValidationEngine: "allow_wip_commits": CommitTypeValidator, "ignore_authors": CommitTypeValidator, "no_force_push": ForcePushValidator, + "ai_attribution": AiAttributionValidator, + "ai_trailer_style": AiTrailerStyleValidator, } def __init__(self, rules: list[ValidationRule]): diff --git a/commit_check/main.py b/commit_check/main.py index 3fe46293..2c59511f 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -293,6 +293,27 @@ def _get_parser() -> argparse.ArgumentParser: help="comma-separated list of authors to ignore for commit checks", ) + commit_group.add_argument( + "--ai-attribution", + type=str, + default=None, + choices=["ignore", "require", "forbid"], + metavar="POLICY", + help="AI attribution policy: ignore (default), require, or forbid. " + "'forbid' rejects commits with known AI tool signatures; " + "'require' enforces proper trailer style when AI signatures are present.", + ) + + commit_group.add_argument( + "--ai-trailer-style", + type=str, + default=None, + choices=["assisted-by", "co-authored-by"], + metavar="STYLE", + help="Preferred AI attribution trailer style: 'assisted-by' (Linux kernel style, default) " + "or 'co-authored-by' (GitHub style). Used when ai_attribution is not 'ignore'.", + ) + # Branch configuration options branch_group = parser.add_argument_group( "branch options", "Configuration options for --branch validation" @@ -429,6 +450,8 @@ def _get_requested_checks(args: argparse.Namespace) -> list[str]: "allow_empty_commits", "allow_fixup_commits", "allow_wip_commits", + "ai_attribution", + "ai_trailer_style", ] ) if args.branch: diff --git a/commit_check/rule_builder.py b/commit_check/rule_builder.py index 63832b74..756fd50a 100644 --- a/commit_check/rule_builder.py +++ b/commit_check/rule_builder.py @@ -15,6 +15,8 @@ DEFAULT_BRANCH_NAMES, DEFAULT_BOOLEAN_RULES, DEFAULT_PUSH_RULES, + DEFAULT_AI_ATTRIBUTION, + DEFAULT_AI_TRAILER_STYLE, ) @@ -138,6 +140,10 @@ def _build_single_rule( return self._build_length_rule(catalog_entry, "subject_min_length") elif check == "ignore_authors": return self._build_author_list_rule(catalog_entry, "ignore_authors") + elif check == "ai_attribution": + return self._build_ai_attribution_rule(catalog_entry) + elif check == "ai_trailer_style": + return self._build_ai_trailer_style_rule(catalog_entry) elif check == "merge_base": return self._build_merge_base_rule(catalog_entry) else: @@ -248,6 +254,57 @@ def _build_merge_base_rule( suggest=catalog_entry.suggest, ) + def _build_ai_attribution_rule( + self, catalog_entry: RuleCatalogEntry + ) -> ValidationRule | None: + """Build AI attribution validation rule. + + Three modes: + * ``"forbid"`` — reject any commit with AI tool signatures + * ``"require"`` — if AI signatures present, must use preferred style + * ``"ignore"`` — no validation (default, returns None) + """ + policy = self.commit_config.get("ai_attribution", DEFAULT_AI_ATTRIBUTION) + if policy == "ignore": + return None + + trailer_style = self.commit_config.get( + "ai_trailer_style", DEFAULT_AI_TRAILER_STYLE + ) + + return ValidationRule( + check=catalog_entry.check, + value=policy, + error=catalog_entry.error or "", + suggest=catalog_entry.suggest or "", + allowed=[trailer_style], + ) + + def _build_ai_trailer_style_rule( + self, catalog_entry: RuleCatalogEntry + ) -> ValidationRule | None: + """Build AI trailer style validation rule. + + Checks that AI tool trailers match the project-preferred format + (``"assisted-by"`` or ``"co-authored-by"``). + + Only active when ``ai_attribution`` is not ``"ignore"``. + """ + policy = self.commit_config.get("ai_attribution", DEFAULT_AI_ATTRIBUTION) + if policy == "ignore": + return None + + style = self.commit_config.get("ai_trailer_style", DEFAULT_AI_TRAILER_STYLE) + if not style or style not in ("assisted-by", "co-authored-by"): + return None + + return ValidationRule( + check=catalog_entry.check, + value=style, + error=catalog_entry.error or "", + suggest=catalog_entry.suggest or "", + ) + def _build_boolean_rule( self, catalog_entry: RuleCatalogEntry, section_config: dict[str, Any] ) -> ValidationRule | None: diff --git a/commit_check/rules_catalog.py b/commit_check/rules_catalog.py index fa470735..cf4238ae 100644 --- a/commit_check/rules_catalog.py +++ b/commit_check/rules_catalog.py @@ -104,6 +104,18 @@ class RuleCatalogEntry: error="Signed-off-by not found in latest commit", suggest="git commit --amend --signoff or use --signoff on commit", ), + RuleCatalogEntry( + check="ai_attribution", + regex=None, + error="AI attribution policy violation: {reason}", + suggest="{suggestion}", + ), + RuleCatalogEntry( + check="ai_trailer_style", + regex=None, + error="AI attribution trailer style violation", + suggest="{suggestion}", + ), ] # Push rules diff --git a/tests/ai_signatures_test.py b/tests/ai_signatures_test.py new file mode 100644 index 00000000..95540360 --- /dev/null +++ b/tests/ai_signatures_test.py @@ -0,0 +1,233 @@ +"""Tests for commit_check.ai_signatures — the AI tool signature database.""" + +import pytest +from commit_check.ai_signatures import ( + detect_ai_signatures, + has_ai_signature, + find_co_authored_by_ai, + find_assisted_by_trailers, + ALL_KNOWN_TOOLS, + ALL_PATTERNS, +) + + +class TestDetectAiSignatures: + """Tests for detect_ai_signatures().""" + + @pytest.mark.benchmark + def test_no_signatures_in_clean_commit(self): + """A clean commit message with no AI references returns empty list.""" + message = ( + "feat: add streaming endpoint\n\nSigned-off-by: Alice " + ) + result = detect_ai_signatures(message) + assert result == [] + + @pytest.mark.benchmark + def test_claude_co_author_detected(self): + """Co-authored-by: Claude is detected.""" + message = ( + "feat: implement feature\n\nCo-authored-by: Claude " + ) + result = detect_ai_signatures(message) + assert len(result) >= 1 + assert any(s["tool"] == "Claude Code" for s in result) + + @pytest.mark.benchmark + def test_copilot_co_author_detected(self): + """Co-authored-by: Copilot is detected.""" + message = "fix: resolve bug\n\nCo-authored-by: Copilot " + result = detect_ai_signatures(message) + assert len(result) >= 1 + assert any(s["tool"] == "GitHub Copilot" for s in result) + + @pytest.mark.benchmark + def test_assisted_by_trailer_detected(self): + """Assisted-by: trailer (kernel style) is detected.""" + message = "refactor: clean up API\n\nAssisted-by: Claude:claude-sonnet-4" + result = detect_ai_signatures(message) + assert len(result) >= 1 + assert any("Assisted-by" in s["matched_text"] for s in result) + + @pytest.mark.benchmark + def test_multiple_ai_tools_detected(self): + """Multiple AI tool signatures are all detected.""" + message = ( + "feat: implement feature\n\n" + "Co-authored-by: Claude \n" + "Co-authored-by: Copilot " + ) + result = detect_ai_signatures(message) + tools = {s["tool"] for s in result} + assert "Claude Code" in tools + assert "GitHub Copilot" in tools + + @pytest.mark.benchmark + def test_dedup_matched_text(self): + """Duplicate matched text is reported only once.""" + # Two patterns could match the same line; we only report once + message = "feat: add feature\n\nAssisted-by: Claude:claude-sonnet-4" + result = detect_ai_signatures(message) + matched_texts = [s["matched_text"] for s in result] + assert len(matched_texts) == len(set(matched_texts)) + + @pytest.mark.benchmark + def test_human_co_author_not_detected(self): + """A human Co-authored-by is not flagged.""" + message = "feat: add feature\n\nCo-authored-by: Jane Doe " + result = detect_ai_signatures(message) + # "Jane Doe" doesn't match any AI pattern; our patterns are specific + # to known AI tool names, not arbitrary human names. + for r in result: + assert "AI" not in r["tool"] or "Generic" in r["tool"] + + @pytest.mark.benchmark + def test_claude_session_trailer(self): + """Claude-Session: trailer is detected.""" + message = "feat: update config\n\nClaude-Session: abc123" + result = detect_ai_signatures(message) + assert len(result) >= 1 + assert any("Claude-Session" in s["matched_text"] for s in result) + + @pytest.mark.benchmark + def test_emoji_marker_detected(self): + """🤖 Generated with Claude marker is detected.""" + message = "feat: add feature\n\n🤖 Generated with Claude Code" + result = detect_ai_signatures(message) + assert len(result) >= 1 + assert any("Claude Code" == s["tool"] for s in result) + + @pytest.mark.benchmark + def test_aider_commit_marker(self): + """Aider commit marker is detected.""" + message = "# aider commit: refactored authentication module" + result = detect_ai_signatures(message) + assert len(result) >= 1 + assert any(s["tool"] == "Aider" for s in result) + + @pytest.mark.benchmark + def test_all_known_tools_have_patterns(self): + """All known tools have at least one pattern.""" + for tool in ALL_KNOWN_TOOLS: + assert len(tool.patterns) >= 1, f"{tool.name} has no patterns" + + @pytest.mark.benchmark + def test_all_patterns_compile(self): + """All patterns in the master registry compile successfully.""" + for regex, tool_name, desc in ALL_PATTERNS: + assert regex is not None, f"{tool_name}: {desc} has None regex" + assert regex.search("test") is not None or True # regex is valid + + +class TestHasAiSignature: + """Tests for has_ai_signature().""" + + @pytest.mark.benchmark + def test_clean_message(self): + """Returns False for a clean commit message.""" + assert has_ai_signature("feat: add feature") is False + + @pytest.mark.benchmark + def test_with_ai_signature(self): + """Returns True when AI signature present.""" + assert has_ai_signature("feat: add feature\n\nCo-authored-by: Claude") is True + + @pytest.mark.benchmark + def test_empty_message(self): + """Returns False for empty message.""" + assert has_ai_signature("") is False + + +class TestFindCoAuthoredByAi: + """Tests for find_co_authored_by_ai().""" + + @pytest.mark.benchmark + def test_finds_claude_co_author(self): + """Finds Co-authored-by: Claude lines.""" + message = "feat: add feature\n\nCo-authored-by: Claude " + result = find_co_authored_by_ai(message) + assert len(result) >= 1 + assert "Co-authored-by: Claude" in result[0] + + @pytest.mark.benchmark + def test_finds_copilot_co_author(self): + """Finds Co-authored-by: Copilot lines.""" + message = "feat: add feature\n\nCo-authored-by: Copilot " + result = find_co_authored_by_ai(message) + assert len(result) >= 1 + assert "Copilot" in result[0] + + @pytest.mark.benchmark + def test_no_false_positive_for_human(self): + """Human co-authors are not returned.""" + message = "feat: add feature\n\nCo-authored-by: Alice Smith " + result = find_co_authored_by_ai(message) + assert result == [] + + @pytest.mark.benchmark + def test_empty_message(self): + """Empty message returns empty list.""" + assert find_co_authored_by_ai("") == [] + + +class TestFindAssistedByTrailers: + """Tests for find_assisted_by_trailers().""" + + @pytest.mark.benchmark + def test_finds_assisted_by_trailer(self): + """Finds Assisted-by: trailer.""" + message = "feat: add feature\n\nAssisted-by: Claude:claude-sonnet-4" + result = find_assisted_by_trailers(message) + assert len(result) >= 1 + assert "Assisted-by: Claude:claude-sonnet-4" in result[0] + + @pytest.mark.benchmark + def test_no_false_positive(self): + """Returns empty list when no Assisted-by trailer.""" + message = "feat: add feature\n\nSigned-off-by: Alice " + result = find_assisted_by_trailers(message) + assert result == [] + + @pytest.mark.benchmark + def test_empty_message(self): + """Empty message returns empty list.""" + assert find_assisted_by_trailers("") == [] + + +class TestSignatureDatabase: + """Tests for the structure and completeness of the signature database.""" + + @pytest.mark.benchmark + def test_all_tools_have_unique_names(self): + """All known tools have unique display names.""" + names = [t.name for t in ALL_KNOWN_TOOLS] + assert len(names) == len(set(names)) + + @pytest.mark.benchmark + def test_all_patterns_have_description(self): + """All patterns have a non-empty description.""" + for regex, tool_name, desc in ALL_PATTERNS: + assert desc, f"Pattern for {tool_name} is missing a description" + + @pytest.mark.benchmark + def test_claude_code_variant_detection(self): + """Various Claude Code trailer formats are detected.""" + variants = [ + "Co-authored-by: Claude", + "Co-authored-by: Claude ", + "Co-authored-by: Claude Code ", + "Assisted-by: Claude:claude-sonnet-4-20250514", + "Claude-Session: sess_abc123", + "Claude-Workflow: workflow_xyz", + ] + for variant in variants: + message = f"feat: add feature\n\n{variant}" + result = detect_ai_signatures(message) + assert len(result) >= 1, f"Failed to detect: {variant}" + + @pytest.mark.benchmark + def test_generic_ai_catch_all(self): + """Assisted-by with any AI agent is caught by Generic AI.""" + message = "feat: update code\n\nAssisted-by: gpt-4:openai" + result = detect_ai_signatures(message) + assert len(result) >= 1 diff --git a/tests/engine_test.py b/tests/engine_test.py index 5261c883..30d840ca 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -21,6 +21,8 @@ BodyValidator, MergeBaseValidator, ForcePushValidator, + AiAttributionValidator, + AiTrailerStyleValidator, ) from commit_check.rule_builder import ValidationRule @@ -1016,6 +1018,8 @@ def test_validation_engine_validator_map(self): "allow_fixup_commits": CommitTypeValidator, "allow_wip_commits": CommitTypeValidator, "ignore_authors": CommitTypeValidator, + "ai_attribution": AiAttributionValidator, + "ai_trailer_style": AiTrailerStyleValidator, } for check, validator_class in expected_mappings.items(): @@ -1610,3 +1614,239 @@ def test_validation_context_push_upstream_fallback(self): assert ctx.push_upstream_fallback is True ctx2 = ValidationContext() assert ctx2.push_upstream_fallback is False + + +class TestAiAttributionValidator: + """Tests for AiAttributionValidator.""" + + @pytest.mark.benchmark + def test_ignore_policy_always_passes(self): + """ignore policy skips all validation.""" + rule = ValidationRule( + check="ai_attribution", + value="ignore", + ) + validator = AiAttributionValidator(rule) + message = "feat: add feature\n\nCo-authored-by: Claude " + context = ValidationContext(stdin_text=message) + result = validator.validate(context) + assert result == ValidationResult.PASS + + @pytest.mark.benchmark + def test_forbid_policy_rejects_ai_commit(self): + """forbid policy rejects commits with AI signatures.""" + rule = ValidationRule( + check="ai_attribution", + value="forbid", + ) + validator = AiAttributionValidator(rule) + message = "feat: add feature\n\nCo-authored-by: Claude " + context = ValidationContext(stdin_text=message) + result = validator.validate(context) + assert result == ValidationResult.FAIL + + @pytest.mark.benchmark + def test_forbid_policy_allows_clean_commit(self): + """forbid policy allows commits without AI signatures.""" + rule = ValidationRule( + check="ai_attribution", + value="forbid", + ) + validator = AiAttributionValidator(rule) + context = ValidationContext(stdin_text="feat: add feature by hand") + result = validator.validate(context) + assert result == ValidationResult.PASS + + @pytest.mark.benchmark + def test_forbid_policy_multiple_tools(self): + """forbid rejects commits with multiple AI tools.""" + rule = ValidationRule( + check="ai_attribution", + value="forbid", + ) + validator = AiAttributionValidator(rule) + message = ( + "feat: implement feature\n\n" + "Co-authored-by: Claude \n" + "Co-authored-by: Copilot " + ) + context = ValidationContext(stdin_text=message) + result = validator.validate(context) + assert result == ValidationResult.FAIL + + @pytest.mark.benchmark + def test_require_policy_passes_clean_commit(self): + """require policy allows clean commits (no AI signatures).""" + rule = ValidationRule( + check="ai_attribution", + value="require", + allowed=["assisted-by"], + ) + validator = AiAttributionValidator(rule) + context = ValidationContext(stdin_text="feat: add feature by hand") + result = validator.validate(context) + assert result == ValidationResult.PASS + + @pytest.mark.benchmark + def test_require_assisted_by_rejects_co_author(self): + """require with assisted-by style rejects Co-authored-by AI trailers.""" + rule = ValidationRule( + check="ai_attribution", + value="require", + allowed=["assisted-by"], + ) + validator = AiAttributionValidator(rule) + message = "feat: add feature\n\nCo-authored-by: Claude " + context = ValidationContext(stdin_text=message) + result = validator.validate(context) + assert result == ValidationResult.FAIL + + @pytest.mark.benchmark + def test_require_assisted_by_passes_correct_style(self): + """require with assisted-by passes Assisted-by: trailers.""" + rule = ValidationRule( + check="ai_attribution", + value="require", + allowed=["assisted-by"], + ) + validator = AiAttributionValidator(rule) + message = "feat: add feature\n\nAssisted-by: Claude:claude-sonnet-4" + context = ValidationContext(stdin_text=message) + result = validator.validate(context) + assert result == ValidationResult.PASS + + @pytest.mark.benchmark + def test_require_co_author_rejects_assisted_by(self): + """require with co-authored-by style rejects Assisted-by trailers.""" + rule = ValidationRule( + check="ai_attribution", + value="require", + allowed=["co-authored-by"], + ) + validator = AiAttributionValidator(rule) + message = "feat: add feature\n\nAssisted-by: Claude:claude-sonnet-4" + context = ValidationContext(stdin_text=message) + result = validator.validate(context) + assert result == ValidationResult.FAIL + + @pytest.mark.benchmark + def test_require_co_author_passes_correct_style(self): + """require with co-authored-by passes Co-authored-by AI trailers.""" + rule = ValidationRule( + check="ai_attribution", + value="require", + allowed=["co-authored-by"], + ) + validator = AiAttributionValidator(rule) + message = "feat: add feature\n\nCo-authored-by: Claude " + context = ValidationContext(stdin_text=message) + result = validator.validate(context) + assert result == ValidationResult.PASS + + @pytest.mark.benchmark + def test_skip_when_author_ignored(self): + """Validation is skipped when author is in ignore list.""" + rule = ValidationRule( + check="ai_attribution", + value="forbid", + ) + validator = AiAttributionValidator(rule) + message = "feat: add feature\n\nCo-authored-by: Claude" + config = {"commit": {"ignore_authors": ["bot-user"]}} + context = ValidationContext(stdin_text=message, config=config) + + with patch("commit_check.engine.get_commit_info", return_value="bot-user"): + result = validator.validate(context) + assert result == ValidationResult.PASS # Skipped due to ignored author + + @pytest.mark.benchmark + def test_empty_message_passes(self): + """Empty message passes validation.""" + rule = ValidationRule( + check="ai_attribution", + value="forbid", + ) + validator = AiAttributionValidator(rule) + context = ValidationContext(stdin_text="") + result = validator.validate(context) + assert result == ValidationResult.PASS + + +class TestAiTrailerStyleValidator: + """Tests for AiTrailerStyleValidator.""" + + @pytest.mark.benchmark + def test_clean_message_passes(self): + """Clean message with no AI signatures passes.""" + rule = ValidationRule( + check="ai_trailer_style", + value="assisted-by", + ) + validator = AiTrailerStyleValidator(rule) + context = ValidationContext(stdin_text="feat: add feature") + result = validator.validate(context) + assert result == ValidationResult.PASS + + @pytest.mark.benchmark + def test_assisted_by_style_rejects_co_author(self): + """assisted-by style rejects Co-authored-by AI trailers.""" + rule = ValidationRule( + check="ai_trailer_style", + value="assisted-by", + ) + validator = AiTrailerStyleValidator(rule) + message = "feat: add feature\n\nCo-authored-by: Claude " + context = ValidationContext(stdin_text=message) + result = validator.validate(context) + assert result == ValidationResult.FAIL + + @pytest.mark.benchmark + def test_assisted_by_style_passes_correct(self): + """assisted-by style passes Assisted-by: trailers.""" + rule = ValidationRule( + check="ai_trailer_style", + value="assisted-by", + ) + validator = AiTrailerStyleValidator(rule) + message = "feat: add feature\n\nAssisted-by: Claude:claude-sonnet-4" + context = ValidationContext(stdin_text=message) + result = validator.validate(context) + assert result == ValidationResult.PASS + + @pytest.mark.benchmark + def test_co_author_style_rejects_assisted_by(self): + """co-authored-by style rejects Assisted-by trailers.""" + rule = ValidationRule( + check="ai_trailer_style", + value="co-authored-by", + ) + validator = AiTrailerStyleValidator(rule) + message = "feat: add feature\n\nAssisted-by: Claude:claude-sonnet-4" + context = ValidationContext(stdin_text=message) + result = validator.validate(context) + assert result == ValidationResult.FAIL + + @pytest.mark.benchmark + def test_human_co_author_passes(self): + """Human Co-authored-by (not AI) passes style validation.""" + rule = ValidationRule( + check="ai_trailer_style", + value="assisted-by", + ) + validator = AiTrailerStyleValidator(rule) + message = "feat: add feature\n\nCo-authored-by: Alice Smith " + context = ValidationContext(stdin_text=message) + result = validator.validate(context) + assert result == ValidationResult.PASS + + @pytest.mark.benchmark + def test_empty_message_passes(self): + """Empty message passes.""" + rule = ValidationRule( + check="ai_trailer_style", + value="assisted-by", + ) + validator = AiTrailerStyleValidator(rule) + context = ValidationContext(stdin_text="") + result = validator.validate(context) + assert result == ValidationResult.PASS diff --git a/tests/rule_builder_test.py b/tests/rule_builder_test.py index 1a13a069..167c714b 100644 --- a/tests/rule_builder_test.py +++ b/tests/rule_builder_test.py @@ -372,3 +372,120 @@ def test_push_rule_unknown_check_returns_none(self): unknown_entry = RuleCatalogEntry(check="unknown_push_check") rule = builder._build_push_rule(unknown_entry) assert rule is None + + +class TestAiAttributionRuleBuilder: + """Tests for AI attribution rule building.""" + + @pytest.mark.benchmark + def test_ai_attribution_ignore_returns_none(self): + """ai_attribution='ignore' (default) returns None.""" + config = {"commit": {"ai_attribution": "ignore"}} + builder = RuleBuilder(config) + entry = RuleCatalogEntry(check="ai_attribution") + rule = builder._build_ai_attribution_rule(entry) + assert rule is None + + @pytest.mark.benchmark + def test_ai_attribution_forbid_creates_rule(self): + """ai_attribution='forbid' creates a validation rule.""" + config = {"commit": {"ai_attribution": "forbid"}} + builder = RuleBuilder(config) + entry = RuleCatalogEntry(check="ai_attribution") + rule = builder._build_ai_attribution_rule(entry) + assert rule is not None + assert rule.check == "ai_attribution" + assert rule.value == "forbid" + assert rule.allowed == ["assisted-by"] # default style + + @pytest.mark.benchmark + def test_ai_attribution_require_creates_rule(self): + """ai_attribution='require' creates a validation rule.""" + config = {"commit": {"ai_attribution": "require"}} + builder = RuleBuilder(config) + entry = RuleCatalogEntry(check="ai_attribution") + rule = builder._build_ai_attribution_rule(entry) + assert rule is not None + assert rule.check == "ai_attribution" + assert rule.value == "require" + assert rule.allowed == ["assisted-by"] # default + + @pytest.mark.benchmark + def test_ai_attribution_with_custom_trailer_style(self): + """Custom ai_trailer_style is reflected in the rule.""" + config = { + "commit": { + "ai_attribution": "require", + "ai_trailer_style": "co-authored-by", + } + } + builder = RuleBuilder(config) + entry = RuleCatalogEntry(check="ai_attribution") + rule = builder._build_ai_attribution_rule(entry) + assert rule is not None + assert rule.value == "require" + assert rule.allowed == ["co-authored-by"] + + @pytest.mark.benchmark + def test_ai_trailer_style_ignore_returns_none(self): + """When ai_attribution='ignore', ai_trailer_style returns None.""" + config = { + "commit": {"ai_attribution": "ignore", "ai_trailer_style": "assisted-by"} + } + builder = RuleBuilder(config) + entry = RuleCatalogEntry(check="ai_trailer_style") + rule = builder._build_ai_trailer_style_rule(entry) + assert rule is None + + @pytest.mark.benchmark + def test_ai_trailer_style_creates_rule(self): + """ai_trailer_style creates a rule when ai_attribution is not ignore.""" + config = { + "commit": { + "ai_attribution": "forbid", + "ai_trailer_style": "assisted-by", + } + } + builder = RuleBuilder(config) + entry = RuleCatalogEntry(check="ai_trailer_style") + rule = builder._build_ai_trailer_style_rule(entry) + assert rule is not None + assert rule.check == "ai_trailer_style" + assert rule.value == "assisted-by" + + @pytest.mark.benchmark + def test_ai_trailer_style_co_author(self): + """ai_trailer_style='co-authored-by' is passed through.""" + config = { + "commit": { + "ai_attribution": "require", + "ai_trailer_style": "co-authored-by", + } + } + builder = RuleBuilder(config) + entry = RuleCatalogEntry(check="ai_trailer_style") + rule = builder._build_ai_trailer_style_rule(entry) + assert rule is not None + assert rule.value == "co-authored-by" + + @pytest.mark.benchmark + def test_build_all_rules_includes_ai(self): + """build_all_rules includes AI attribution rules when configured.""" + config = {"commit": {"ai_attribution": "forbid"}} + builder = RuleBuilder(config) + rules = builder.build_all_rules() + ai_rules = [ + r for r in rules if r.check in ("ai_attribution", "ai_trailer_style") + ] + assert len(ai_rules) >= 1 + assert any(r.check == "ai_attribution" for r in ai_rules) + + @pytest.mark.benchmark + def test_build_all_rules_no_ai_by_default(self): + """build_all_rules does not include AI rules by default.""" + builder = RuleBuilder({}) + rules = builder.build_all_rules() + ai_rules = [ + r for r in rules if r.check in ("ai_attribution", "ai_trailer_style") + ] + assert len(ai_rules) == 0 From 3f7b6c681d783bc43886e31b988a02691d1cfdd3 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 5 Jul 2026 23:35:12 +0300 Subject: [PATCH 02/10] fix: resolve P0/P1 issues in AI attribution feature P0 fixes: - Template leak in text output: _record_failure now passes dynamic error/suggest into the rule_dict before printing. - Dual validator conflict: ai_attribution handles only 'forbid'; ai_trailer_style handles only 'require'. One policy = one check. P1 fixes: - Kernel Assisted-by format now accepts optional trailing tool list. - Generic model names now match claude-sonnet-4, gpt-4-turbo, etc. - ALL_PATTERNS tuples include 'kind' field (fixes body_marker misclassification as trailer). - find_co_authored_by_ai deduplicates via seen set. - Claude regex anchors to noreply@anthropic.com / GitHub noreply to avoid false positives with human co-authors named Claude. - Copilot regex anchored to its specific GitHub noreply address. - Added aider '(aider)' author suffix pattern. - Removed dead github-actions[bot] pattern. - Updated tests: capsys output checks, human-name false-positive regression tests, kernel format fixtures. --- commit_check/ai_signatures.py | 72 +++++++++------ commit_check/engine.py | 85 ++++++------------ commit_check/rule_builder.py | 23 ++--- tests/ai_signatures_test.py | 162 +++++++++++++++++++++++++++++----- tests/engine_test.py | 82 +++-------------- tests/rule_builder_test.py | 63 +++++++------ 6 files changed, 263 insertions(+), 224 deletions(-) diff --git a/commit_check/ai_signatures.py b/commit_check/ai_signatures.py index 24922946..021f67fc 100644 --- a/commit_check/ai_signatures.py +++ b/commit_check/ai_signatures.py @@ -93,15 +93,23 @@ def _body_marker(pattern: str, description: str = "") -> AiSignaturePattern: CLAUDE_CODE = KnownAiTool( name="Claude Code", patterns=[ - # Standard Co-authored-by trailer added by Claude Code + # Standard Co-authored-by trailer added by Claude Code. + # When an email is present, anchor to known AI noreply addresses + # to avoid false positives with human co-authors named Claude. _trailer( "Co-authored-by", - r"Claude(?:\s+Code)?\s*(?:<[^>]*>)?", + r"Claude(?: Code)?" + r"(?:\s*<(?:" + r"noreply@anthropic\.com" + r"|\d+\+Claude@users\.noreply\.github\.com" + r")>)?", "``Co-authored-by: Claude`` trailer", ), - # Assisted-by trailer (future-proof: Claude may adopt Linux kernel style) + # Assisted-by trailer (Linux kernel style, with optional tool list) _trailer( - "Assisted-by", r"Claude:\S+", "``Assisted-by: Claude:`` trailer" + "Assisted-by", + r"Claude:\S+(?:\s+\S+)*", + "``Assisted-by: Claude: [tools]`` trailer", ), # Body marker: generated-with notice _body_marker( @@ -121,14 +129,10 @@ def _body_marker(pattern: str, description: str = "") -> AiSignaturePattern: patterns=[ _trailer( "Co-authored-by", - r"Copilot\s*(?:<[^>]*>)?", + r"Copilot" + r"(?:\s*<\d+\+Copilot@users\.noreply\.github\.com>)?", "``Co-authored-by: Copilot`` trailer", ), - _trailer( - "Co-authored-by", - r"github-actions\[bot\]", - "``Co-authored-by: github-actions[bot]`` (Copilot)", - ), ], ) @@ -189,9 +193,11 @@ def _body_marker(pattern: str, description: str = "") -> AiSignaturePattern: r"Aider\s*(?:<[^>]*>)?", "``Co-authored-by: Aider`` trailer", ), - _body_marker( - r"^#\s+(?:Aider|aider)\s+(?:commit|chat)", - "``# aider commit`` body marker", + # aider appends "(aider)" to the author name + _trailer( + "Co-authored-by", + r"[^<]+\(aider\)\s*(?:<[^>]*>)?", + "``Co-authored-by: ... (aider)`` trailer", ), ], ) @@ -224,14 +230,23 @@ def _body_marker(pattern: str, description: str = "") -> AiSignaturePattern: GENERIC_AI = KnownAiTool( name="Generic AI", patterns=[ - # Catch AI agent models in Co-authored-by (e.g. claude-sonnet, gpt-4) + # Catch AI agent model identifiers in Co-authored-by + # (e.g. claude-sonnet-4, gpt-4-turbo, gemini-1.5-pro). + # Only matches values that look like model names (containing + # word chars, dots, or hyphens — not plain human names). _trailer( "Co-authored-by", - r"(?:claude|gpt|gemini|llama|mistral)", + r"(?:claude|gpt|gemini)[\w.-]*(?:\s*<[^>]*>)?", "``Co-authored-by`` with AI model name", ), - # Catch Assisted-by trailer (Linux kernel style) regardless of agent - _trailer("Assisted-by", r"\S+:\S+", "``Assisted-by:`` trailer (kernel style)"), + # Catch Assisted-by trailer (Linux kernel style) regardless of agent, + # with optional trailing tool list, e.g.: + # "Assisted-by: Claude:claude-3-opus coccinelle sparse" + _trailer( + "Assisted-by", + r"\S+:\S+(?:\s+\S+)*", + "``Assisted-by: : [tools]`` trailer (kernel style)", + ), # Catch common body markers _body_marker( r"^Generated\s+(?:by|with)\s+(?:AI|artificial intelligence)", @@ -259,8 +274,9 @@ def _body_marker(pattern: str, description: str = "") -> AiSignaturePattern: ] #: Flat list of all compiled patterns for bulk scanning. -ALL_PATTERNS: list[tuple[re.Pattern[str], str, str]] = [ - (p.regex, tool.name, p.description) +#: Each tuple is ``(regex, tool_name, description, kind)``. +ALL_PATTERNS: list[tuple[re.Pattern[str], str, str, str]] = [ + (p.regex, tool.name, p.description, p.kind) for tool in ALL_KNOWN_TOOLS for p in tool.patterns ] @@ -287,7 +303,7 @@ def detect_ai_signatures(message: str) -> list[dict[str, str]]: results: list[dict[str, str]] = [] seen: set[str] = set() # deduplicate by matched text - for regex, tool_name, desc in ALL_PATTERNS: + for regex, tool_name, desc, kind in ALL_PATTERNS: for match in regex.finditer(message): matched = match.group(0).strip() if matched not in seen: @@ -295,9 +311,7 @@ def detect_ai_signatures(message: str) -> list[dict[str, str]]: results.append( { "tool": tool_name, - "kind": "trailer" - if regex.pattern.startswith(r"^") - else "body_marker", + "kind": kind, "description": desc, "matched_text": matched, } @@ -308,7 +322,7 @@ def detect_ai_signatures(message: str) -> list[dict[str, str]]: def has_ai_signature(message: str) -> bool: """Return ``True`` if *message* contains any known AI signature.""" - for regex, _tool_name, _desc in ALL_PATTERNS: + for regex, _tool_name, _desc, _kind in ALL_PATTERNS: if regex.search(message): return True return False @@ -322,19 +336,19 @@ def find_co_authored_by_ai(message: str) -> list[str]: ``Co-authored-by: Jane Doe `` are NOT returned because no known AI tool pattern matches common human names. - :returns: List of matched trailer lines. + :returns: List of matched trailer lines (deduplicated). """ results: list[str] = [] - # Find all Co-authored-by patterns from known AI tools by checking - # the regex against the message line by line for Co-authored trailers. + seen: set[str] = set() co_pat = re.compile(r"^Co-authored-by:\s*", re.IGNORECASE | re.MULTILINE) if not co_pat.search(message): return results - for regex, _tool_name, _desc in ALL_PATTERNS: + for regex, _tool_name, _desc, _kind in ALL_PATTERNS: for match in regex.finditer(message): matched = match.group(0).strip() - if matched.lower().startswith("co-authored-by:"): + if matched.lower().startswith("co-authored-by:") and matched not in seen: + seen.add(matched) results.append(matched) return results diff --git a/commit_check/engine.py b/commit_check/engine.py index e7c676a4..8ba3333f 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -716,13 +716,11 @@ def _is_wip_commit_allowed(self, message: str) -> bool: class AiAttributionValidator(BaseValidator): """Validates commit messages against AI attribution policy. - Three modes (configured via ``[commit] ai_attribution``): + Single responsibility: when configured to ``forbid``, rejects any commit + that contains known AI tool signatures. - * ``forbid`` — Reject any commit that contains known AI tool signatures. - * ``require`` — If AI signatures are present, they MUST use the configured - trailer style (``ai_trailer_style``). Pure pass-through commits (no - signatures) are allowed. - * ``ignore`` — No validation (default). + Style enforcement (for ``require`` mode) is delegated to + :class:`AiTrailerStyleValidator`. """ def validate(self, context: ValidationContext) -> ValidationResult: @@ -733,54 +731,21 @@ def validate(self, context: ValidationContext) -> ValidationResult: if not message: return ValidationResult.PASS - policy = self.rule.value # "ignore" | "require" | "forbid" - if policy == "ignore": + policy = self.rule.value # "ignore" | "forbid" + if policy != "forbid": return ValidationResult.PASS signatures = detect_ai_signatures(message) - - if policy == "forbid": - if signatures: - tools = {s["tool"] for s in signatures} - self._record_failure( - value=", ".join(sorted(tools)), - error=f"AI-assisted commit is forbidden — detected tools: {', '.join(sorted(tools))}", - suggest="Remove AI-generated trailers from the commit message", - ) - return ValidationResult.FAIL - return ValidationResult.PASS - - if policy == "require": - if not signatures: - # Cannot detect undisclosed AI usage; pass through. - return ValidationResult.PASS - - # AI signatures found — check that they use the required style. - trailer_style = self.rule.allowed or ["assisted-by"] - preferred = trailer_style[0] if trailer_style else "assisted-by" - - if preferred == "assisted-by": - co_ai = find_co_authored_by_ai(message) - if co_ai: - self._record_failure( - value="; ".join(co_ai), - error="AI attribution style violation: project requires 'Assisted-by:' trailer (Linux kernel style)", - suggest="Replace 'Co-authored-by: ' with 'Assisted-by: :'", - ) - return ValidationResult.FAIL - elif preferred == "co-authored-by": - assisted = find_assisted_by_trailers(message) - if assisted: - self._record_failure( - value="; ".join(assisted), - error="AI attribution style violation: project requires 'Co-authored-by:' trailer", - suggest="Replace 'Assisted-by: :' with 'Co-authored-by: '", - ) - return ValidationResult.FAIL - + if not signatures: return ValidationResult.PASS - return ValidationResult.PASS + tools = {s["tool"] for s in signatures} + self._record_failure( + value=", ".join(sorted(tools)), + error=f"AI-assisted commit is forbidden — detected tools: {', '.join(sorted(tools))}", + suggest="Remove AI attribution trailers from the commit message", + ) + return ValidationResult.FAIL def _record_failure(self, value: str, error: str, suggest: str) -> None: """Record a failure with dynamic error/suggest messages.""" @@ -791,7 +756,11 @@ def _record_failure(self, value: str, error: str, suggest: str) -> None: "suggest": suggest, } if not self._suppress_output: + # Pass dynamic messages to the printer by creating a dict with + # the live error/suggest instead of the catalog templates. rule_dict = self.rule.to_dict() + rule_dict["error"] = error + rule_dict["suggest"] = suggest from commit_check.util import _print_failure _print_failure( @@ -805,15 +774,11 @@ def _record_failure(self, value: str, error: str, suggest: str) -> None: class AiTrailerStyleValidator(BaseValidator): """Validates that AI-related trailers use the project-preferred style. - This validator is a companion to ``ai_attribution = "require"``. It checks - that any known AI trailers use the format specified by ``ai_trailer_style`` - in the config (``"assisted-by"`` or ``"co-authored-by"``). - - When the preferred style is ``"assisted-by"``: - * ``Co-authored-by: Claude`` → FAIL (should be ``Assisted-by: Claude:...``) + Active only when ``ai_attribution = "require"``. Checks that any known + AI trailers use the format specified by ``ai_trailer_style``: - When the preferred style is ``"co-authored-by"``: - * ``Assisted-by: Claude:claude-sonnet`` → FAIL + * ``"assisted-by"`` (kernel style) — ``Co-authored-by: Claude`` → FAIL + * ``"co-authored-by"`` (GitHub style) — ``Assisted-by: Claude:...`` → FAIL """ def validate(self, context: ValidationContext) -> ValidationResult: @@ -835,7 +800,7 @@ def validate(self, context: ValidationContext) -> ValidationResult: if co_ai: self._record_failure( value="; ".join(co_ai), - error="AI trailer style violation: project requires 'Assisted-by:' trailer", + error="Project requires 'Assisted-by:' trailer (Linux kernel style) for AI attribution", suggest="Replace 'Co-authored-by: ' with 'Assisted-by: :'", ) return ValidationResult.FAIL @@ -845,7 +810,7 @@ def validate(self, context: ValidationContext) -> ValidationResult: if assisted: self._record_failure( value="; ".join(assisted), - error="AI trailer style violation: project requires 'Co-authored-by:' trailer", + error="Project requires 'Co-authored-by:' trailer for AI attribution", suggest="Replace 'Assisted-by: :' with 'Co-authored-by: '", ) return ValidationResult.FAIL @@ -862,6 +827,8 @@ def _record_failure(self, value: str, error: str, suggest: str) -> None: } if not self._suppress_output: rule_dict = self.rule.to_dict() + rule_dict["error"] = error + rule_dict["suggest"] = suggest from commit_check.util import _print_failure _print_failure( diff --git a/commit_check/rule_builder.py b/commit_check/rule_builder.py index 756fd50a..79fd3016 100644 --- a/commit_check/rule_builder.py +++ b/commit_check/rule_builder.py @@ -259,25 +259,19 @@ def _build_ai_attribution_rule( ) -> ValidationRule | None: """Build AI attribution validation rule. - Three modes: - * ``"forbid"`` — reject any commit with AI tool signatures - * ``"require"`` — if AI signatures present, must use preferred style - * ``"ignore"`` — no validation (default, returns None) + Only active when policy is ``"forbid"`` — rejects any commit with + known AI tool signatures. Style enforcement is handled by + :meth:`_build_ai_trailer_style_rule` for ``"require"`` mode. """ policy = self.commit_config.get("ai_attribution", DEFAULT_AI_ATTRIBUTION) - if policy == "ignore": + if policy != "forbid": return None - trailer_style = self.commit_config.get( - "ai_trailer_style", DEFAULT_AI_TRAILER_STYLE - ) - return ValidationRule( check=catalog_entry.check, value=policy, error=catalog_entry.error or "", suggest=catalog_entry.suggest or "", - allowed=[trailer_style], ) def _build_ai_trailer_style_rule( @@ -285,13 +279,12 @@ def _build_ai_trailer_style_rule( ) -> ValidationRule | None: """Build AI trailer style validation rule. - Checks that AI tool trailers match the project-preferred format - (``"assisted-by"`` or ``"co-authored-by"``). - - Only active when ``ai_attribution`` is not ``"ignore"``. + Only active when ``ai_attribution = \"require\"``. Checks that AI + tool trailers match the project-preferred format (``"assisted-by"`` + or ``"co-authored-by"``). """ policy = self.commit_config.get("ai_attribution", DEFAULT_AI_ATTRIBUTION) - if policy == "ignore": + if policy != "require": return None style = self.commit_config.get("ai_trailer_style", DEFAULT_AI_TRAILER_STYLE) diff --git a/tests/ai_signatures_test.py b/tests/ai_signatures_test.py index 95540360..6cfd320a 100644 --- a/tests/ai_signatures_test.py +++ b/tests/ai_signatures_test.py @@ -24,8 +24,8 @@ def test_no_signatures_in_clean_commit(self): assert result == [] @pytest.mark.benchmark - def test_claude_co_author_detected(self): - """Co-authored-by: Claude is detected.""" + def test_claude_co_author_with_noreply_email(self): + """Co-authored-by: Claude with anthropic noreply is detected.""" message = ( "feat: implement feature\n\nCo-authored-by: Claude " ) @@ -34,16 +34,54 @@ def test_claude_co_author_detected(self): assert any(s["tool"] == "Claude Code" for s in result) @pytest.mark.benchmark - def test_copilot_co_author_detected(self): - """Co-authored-by: Copilot is detected.""" - message = "fix: resolve bug\n\nCo-authored-by: Copilot " + def test_claude_code_with_github_noreply(self): + """Co-authored-by: Claude with GitHub noreply is detected.""" + message = "feat: implement feature\n\nCo-authored-by: Claude <12345+Claude@users.noreply.github.com>" + result = detect_ai_signatures(message) + assert len(result) >= 1 + assert any(s["tool"] == "Claude Code" for s in result) + + @pytest.mark.benchmark + def test_human_claude_with_personal_email_ignored(self): + """A human named Claude with a personal email is NOT detected.""" + message = "feat: add feature\n\nCo-authored-by: Claude Dubois " + result = detect_ai_signatures(message) + claude_hits = [s for s in result if s["tool"] == "Claude Code"] + assert len(claude_hits) == 0 + + @pytest.mark.benchmark + def test_copilot_with_noreply_email(self): + """Co-authored-by: Copilot with GitHub noreply is detected.""" + message = ( + "fix: resolve bug\n\n" + "Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>" + ) + result = detect_ai_signatures(message) + assert len(result) >= 1 + assert any(s["tool"] == "GitHub Copilot" for s in result) + + @pytest.mark.benchmark + def test_copilot_bare_name(self): + """Co-authored-by: Copilot (bare, no email) is detected.""" + message = "fix: resolve bug\n\nCo-authored-by: Copilot" result = detect_ai_signatures(message) assert len(result) >= 1 assert any(s["tool"] == "GitHub Copilot" for s in result) @pytest.mark.benchmark - def test_assisted_by_trailer_detected(self): - """Assisted-by: trailer (kernel style) is detected.""" + def test_kernel_format_with_tool_list(self): + """Assisted-by with kernel-style tool list is detected.""" + message = ( + "refactor: clean up API\n\n" + "Assisted-by: Claude:claude-3-opus coccinelle sparse" + ) + result = detect_ai_signatures(message) + assert len(result) >= 1 + assert any("Assisted-by" in s["matched_text"] for s in result) + + @pytest.mark.benchmark + def test_kernel_format_simple(self): + """Assisted-by with just tool:model is detected.""" message = "refactor: clean up API\n\nAssisted-by: Claude:claude-sonnet-4" result = detect_ai_signatures(message) assert len(result) >= 1 @@ -55,7 +93,7 @@ def test_multiple_ai_tools_detected(self): message = ( "feat: implement feature\n\n" "Co-authored-by: Claude \n" - "Co-authored-by: Copilot " + "Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>" ) result = detect_ai_signatures(message) tools = {s["tool"] for s in result} @@ -65,7 +103,6 @@ def test_multiple_ai_tools_detected(self): @pytest.mark.benchmark def test_dedup_matched_text(self): """Duplicate matched text is reported only once.""" - # Two patterns could match the same line; we only report once message = "feat: add feature\n\nAssisted-by: Claude:claude-sonnet-4" result = detect_ai_signatures(message) matched_texts = [s["matched_text"] for s in result] @@ -76,15 +113,13 @@ def test_human_co_author_not_detected(self): """A human Co-authored-by is not flagged.""" message = "feat: add feature\n\nCo-authored-by: Jane Doe " result = detect_ai_signatures(message) - # "Jane Doe" doesn't match any AI pattern; our patterns are specific - # to known AI tool names, not arbitrary human names. - for r in result: - assert "AI" not in r["tool"] or "Generic" in r["tool"] + # None of the known AI patterns should match a common human name + assert len(result) == 0 @pytest.mark.benchmark def test_claude_session_trailer(self): """Claude-Session: trailer is detected.""" - message = "feat: update config\n\nClaude-Session: abc123" + message = "feat: update config\n\nClaude-Session: sess_abc123" result = detect_ai_signatures(message) assert len(result) >= 1 assert any("Claude-Session" in s["matched_text"] for s in result) @@ -98,13 +133,33 @@ def test_emoji_marker_detected(self): assert any("Claude Code" == s["tool"] for s in result) @pytest.mark.benchmark - def test_aider_commit_marker(self): - """Aider commit marker is detected.""" - message = "# aider commit: refactored authentication module" + def test_aider_suffix_pattern(self): + """Co-authored-by with (aider) suffix is detected.""" + message = ( + "feat: add feature\n\nCo-authored-by: Some Dev (aider) " + ) result = detect_ai_signatures(message) assert len(result) >= 1 assert any(s["tool"] == "Aider" for s in result) + @pytest.mark.benchmark + def test_generic_model_name_detected(self): + """Model names like claude-sonnet-4 in Co-authored-by are detected.""" + message = ( + "feat: add feature\n\nCo-authored-by: claude-sonnet-4 " + ) + result = detect_ai_signatures(message) + assert len(result) >= 1 + assert any(s["tool"] == "Generic AI" for s in result) + + @pytest.mark.benchmark + def test_generic_gpt_model_detected(self): + """gpt-4-turbo in Co-authored-by is detected.""" + message = "feat: add feature\n\nCo-authored-by: gpt-4-turbo " + result = detect_ai_signatures(message) + assert len(result) >= 1 + assert any(s["tool"] == "Generic AI" for s in result) + @pytest.mark.benchmark def test_all_known_tools_have_patterns(self): """All known tools have at least one pattern.""" @@ -114,9 +169,30 @@ def test_all_known_tools_have_patterns(self): @pytest.mark.benchmark def test_all_patterns_compile(self): """All patterns in the master registry compile successfully.""" - for regex, tool_name, desc in ALL_PATTERNS: + for regex, tool_name, desc, kind in ALL_PATTERNS: assert regex is not None, f"{tool_name}: {desc} has None regex" - assert regex.search("test") is not None or True # regex is valid + assert kind in ("trailer", "body_marker"), ( + f"{tool_name}: invalid kind {kind}" + ) + + @pytest.mark.benchmark + def test_kind_field_correct_for_body_marker(self): + """Body markers have kind='body_marker', not 'trailer'.""" + message = "feat: add feature\n\nGenerated by AI" + result = detect_ai_signatures(message) + for r in result: + if r["description"].startswith("``Generated by AI"): + assert r["kind"] == "body_marker", ( + f"Expected body_marker, got {r['kind']}" + ) + + @pytest.mark.benchmark + def test_kind_field_correct_for_trailer(self): + """Trailers have kind='trailer'.""" + message = "feat: add feature\n\nCo-authored-by: Claude " + result = detect_ai_signatures(message) + for r in result: + assert r["kind"] == "trailer", f"Expected trailer, got {r['kind']}" class TestHasAiSignature: @@ -130,7 +206,12 @@ def test_clean_message(self): @pytest.mark.benchmark def test_with_ai_signature(self): """Returns True when AI signature present.""" - assert has_ai_signature("feat: add feature\n\nCo-authored-by: Claude") is True + assert ( + has_ai_signature( + "feat: add feature\n\nCo-authored-by: Claude " + ) + is True + ) @pytest.mark.benchmark def test_empty_message(self): @@ -147,12 +228,15 @@ def test_finds_claude_co_author(self): message = "feat: add feature\n\nCo-authored-by: Claude " result = find_co_authored_by_ai(message) assert len(result) >= 1 - assert "Co-authored-by: Claude" in result[0] + assert "Claude" in result[0] @pytest.mark.benchmark def test_finds_copilot_co_author(self): """Finds Co-authored-by: Copilot lines.""" - message = "feat: add feature\n\nCo-authored-by: Copilot " + message = ( + "feat: add feature\n\n" + "Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>" + ) result = find_co_authored_by_ai(message) assert len(result) >= 1 assert "Copilot" in result[0] @@ -164,6 +248,13 @@ def test_no_false_positive_for_human(self): result = find_co_authored_by_ai(message) assert result == [] + @pytest.mark.benchmark + def test_no_duplicates_for_overlapping_patterns(self): + """Gemini matching both specific and generic patterns returns once.""" + message = "feat: add feature\n\nCo-authored-by: gemini" + result = find_co_authored_by_ai(message) + assert len(result) == 1, f"Expected 1, got {len(result)}: {result}" + @pytest.mark.benchmark def test_empty_message(self): """Empty message returns empty list.""" @@ -179,7 +270,17 @@ def test_finds_assisted_by_trailer(self): message = "feat: add feature\n\nAssisted-by: Claude:claude-sonnet-4" result = find_assisted_by_trailers(message) assert len(result) >= 1 - assert "Assisted-by: Claude:claude-sonnet-4" in result[0] + assert "Claude" in result[0] + + @pytest.mark.benchmark + def test_finds_kernel_format_with_tools(self): + """Finds Assisted-by with kernel-style tool list.""" + message = ( + "feat: add feature\n\nAssisted-by: Claude:claude-3-opus coccinelle sparse" + ) + result = find_assisted_by_trailers(message) + assert len(result) >= 1 + assert "coccinelle" in result[0] @pytest.mark.benchmark def test_no_false_positive(self): @@ -206,7 +307,7 @@ def test_all_tools_have_unique_names(self): @pytest.mark.benchmark def test_all_patterns_have_description(self): """All patterns have a non-empty description.""" - for regex, tool_name, desc in ALL_PATTERNS: + for regex, tool_name, desc, kind in ALL_PATTERNS: assert desc, f"Pattern for {tool_name} is missing a description" @pytest.mark.benchmark @@ -217,6 +318,7 @@ def test_claude_code_variant_detection(self): "Co-authored-by: Claude ", "Co-authored-by: Claude Code ", "Assisted-by: Claude:claude-sonnet-4-20250514", + "Assisted-by: Claude:claude-3-opus coccinelle sparse", "Claude-Session: sess_abc123", "Claude-Workflow: workflow_xyz", ] @@ -231,3 +333,15 @@ def test_generic_ai_catch_all(self): message = "feat: update code\n\nAssisted-by: gpt-4:openai" result = detect_ai_signatures(message) assert len(result) >= 1 + + @pytest.mark.benchmark + def test_human_name_not_detected(self): + """Common human names that could overlap with AI tool names.""" + # Devin is both a human name and an AI tool name + message = ( + "feat: add feature\n\nCo-authored-by: Devin Booker " + ) + result = detect_ai_signatures(message) + devin_hits = [s for s in result if s["tool"] == "Devin"] + # With a personal email, Devin should NOT be detected + assert len(devin_hits) == 0 diff --git a/tests/engine_test.py b/tests/engine_test.py index 30d840ca..62aeaf66 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -1674,75 +1674,6 @@ def test_forbid_policy_multiple_tools(self): result = validator.validate(context) assert result == ValidationResult.FAIL - @pytest.mark.benchmark - def test_require_policy_passes_clean_commit(self): - """require policy allows clean commits (no AI signatures).""" - rule = ValidationRule( - check="ai_attribution", - value="require", - allowed=["assisted-by"], - ) - validator = AiAttributionValidator(rule) - context = ValidationContext(stdin_text="feat: add feature by hand") - result = validator.validate(context) - assert result == ValidationResult.PASS - - @pytest.mark.benchmark - def test_require_assisted_by_rejects_co_author(self): - """require with assisted-by style rejects Co-authored-by AI trailers.""" - rule = ValidationRule( - check="ai_attribution", - value="require", - allowed=["assisted-by"], - ) - validator = AiAttributionValidator(rule) - message = "feat: add feature\n\nCo-authored-by: Claude " - context = ValidationContext(stdin_text=message) - result = validator.validate(context) - assert result == ValidationResult.FAIL - - @pytest.mark.benchmark - def test_require_assisted_by_passes_correct_style(self): - """require with assisted-by passes Assisted-by: trailers.""" - rule = ValidationRule( - check="ai_attribution", - value="require", - allowed=["assisted-by"], - ) - validator = AiAttributionValidator(rule) - message = "feat: add feature\n\nAssisted-by: Claude:claude-sonnet-4" - context = ValidationContext(stdin_text=message) - result = validator.validate(context) - assert result == ValidationResult.PASS - - @pytest.mark.benchmark - def test_require_co_author_rejects_assisted_by(self): - """require with co-authored-by style rejects Assisted-by trailers.""" - rule = ValidationRule( - check="ai_attribution", - value="require", - allowed=["co-authored-by"], - ) - validator = AiAttributionValidator(rule) - message = "feat: add feature\n\nAssisted-by: Claude:claude-sonnet-4" - context = ValidationContext(stdin_text=message) - result = validator.validate(context) - assert result == ValidationResult.FAIL - - @pytest.mark.benchmark - def test_require_co_author_passes_correct_style(self): - """require with co-authored-by passes Co-authored-by AI trailers.""" - rule = ValidationRule( - check="ai_attribution", - value="require", - allowed=["co-authored-by"], - ) - validator = AiAttributionValidator(rule) - message = "feat: add feature\n\nCo-authored-by: Claude " - context = ValidationContext(stdin_text=message) - result = validator.validate(context) - assert result == ValidationResult.PASS - @pytest.mark.benchmark def test_skip_when_author_ignored(self): """Validation is skipped when author is in ignore list.""" @@ -1839,6 +1770,19 @@ def test_human_co_author_passes(self): result = validator.validate(context) assert result == ValidationResult.PASS + @pytest.mark.benchmark + def test_co_author_style_passes_correct_ai_trailers(self): + """co-authored-by style passes Co-authored-by AI trailers.""" + rule = ValidationRule( + check="ai_trailer_style", + value="co-authored-by", + ) + validator = AiTrailerStyleValidator(rule) + message = "feat: add feature\n\nCo-authored-by: Claude " + context = ValidationContext(stdin_text=message) + result = validator.validate(context) + assert result == ValidationResult.PASS + @pytest.mark.benchmark def test_empty_message_passes(self): """Empty message passes.""" diff --git a/tests/rule_builder_test.py b/tests/rule_builder_test.py index 167c714b..f3fa7964 100644 --- a/tests/rule_builder_test.py +++ b/tests/rule_builder_test.py @@ -396,41 +396,36 @@ def test_ai_attribution_forbid_creates_rule(self): assert rule is not None assert rule.check == "ai_attribution" assert rule.value == "forbid" - assert rule.allowed == ["assisted-by"] # default style + assert rule.allowed is None # forbid doesn't set trailer style @pytest.mark.benchmark - def test_ai_attribution_require_creates_rule(self): - """ai_attribution='require' creates a validation rule.""" + def test_ai_attribution_require_returns_none(self): + """ai_attribution='require' returns None (style enforced by trailer validator).""" config = {"commit": {"ai_attribution": "require"}} builder = RuleBuilder(config) entry = RuleCatalogEntry(check="ai_attribution") rule = builder._build_ai_attribution_rule(entry) - assert rule is not None - assert rule.check == "ai_attribution" - assert rule.value == "require" - assert rule.allowed == ["assisted-by"] # default + assert rule is None @pytest.mark.benchmark - def test_ai_attribution_with_custom_trailer_style(self): - """Custom ai_trailer_style is reflected in the rule.""" + def test_ai_trailer_style_ignore_returns_none(self): + """When ai_attribution='ignore', ai_trailer_style returns None.""" config = { - "commit": { - "ai_attribution": "require", - "ai_trailer_style": "co-authored-by", - } + "commit": {"ai_attribution": "ignore", "ai_trailer_style": "assisted-by"} } builder = RuleBuilder(config) - entry = RuleCatalogEntry(check="ai_attribution") - rule = builder._build_ai_attribution_rule(entry) - assert rule is not None - assert rule.value == "require" - assert rule.allowed == ["co-authored-by"] + entry = RuleCatalogEntry(check="ai_trailer_style") + rule = builder._build_ai_trailer_style_rule(entry) + assert rule is None @pytest.mark.benchmark - def test_ai_trailer_style_ignore_returns_none(self): - """When ai_attribution='ignore', ai_trailer_style returns None.""" + def test_ai_trailer_style_forbid_returns_none(self): + """ai_trailer_style returns None when ai_attribution='forbid'.""" config = { - "commit": {"ai_attribution": "ignore", "ai_trailer_style": "assisted-by"} + "commit": { + "ai_attribution": "forbid", + "ai_trailer_style": "assisted-by", + } } builder = RuleBuilder(config) entry = RuleCatalogEntry(check="ai_trailer_style") @@ -438,11 +433,11 @@ def test_ai_trailer_style_ignore_returns_none(self): assert rule is None @pytest.mark.benchmark - def test_ai_trailer_style_creates_rule(self): - """ai_trailer_style creates a rule when ai_attribution is not ignore.""" + def test_ai_trailer_style_creates_rule_for_require(self): + """ai_trailer_style creates a rule only when ai_attribution='require'.""" config = { "commit": { - "ai_attribution": "forbid", + "ai_attribution": "require", "ai_trailer_style": "assisted-by", } } @@ -469,16 +464,28 @@ def test_ai_trailer_style_co_author(self): assert rule.value == "co-authored-by" @pytest.mark.benchmark - def test_build_all_rules_includes_ai(self): - """build_all_rules includes AI attribution rules when configured.""" + def test_build_all_rules_forbid_includes_only_attribution(self): + """forbid mode includes ai_attribution but NOT ai_trailer_style.""" config = {"commit": {"ai_attribution": "forbid"}} builder = RuleBuilder(config) rules = builder.build_all_rules() ai_rules = [ r for r in rules if r.check in ("ai_attribution", "ai_trailer_style") ] - assert len(ai_rules) >= 1 - assert any(r.check == "ai_attribution" for r in ai_rules) + assert len(ai_rules) == 1 + assert ai_rules[0].check == "ai_attribution" + + @pytest.mark.benchmark + def test_build_all_rules_require_includes_only_trailer(self): + """require mode includes ai_trailer_style but NOT ai_attribution.""" + config = {"commit": {"ai_attribution": "require"}} + builder = RuleBuilder(config) + rules = builder.build_all_rules() + ai_rules = [ + r for r in rules if r.check in ("ai_attribution", "ai_trailer_style") + ] + assert len(ai_rules) == 1 + assert ai_rules[0].check == "ai_trailer_style" @pytest.mark.benchmark def test_build_all_rules_no_ai_by_default(self): From 0b708c3b5246f0cade7aa95832cc51aca293d524 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 5 Jul 2026 23:49:19 +0300 Subject: [PATCH 03/10] refactor: remove ai_trailer_style config and AiTrailerStyleValidator Simplify AI attribution to a single binary choice: ai_attribution = "ignore" | "forbid" (default: "ignore") Rationale: - Different AI tools generate different trailer styles (Co-authored-by vs Assisted-by vs future formats). Enforcing a single style is not commit-check's job -- 'forbid' already rejects all known formats. - 'require' mode gave a false sense of security (can't detect undisclosed AI usage) and required users to pick a style side. Changes: - Remove AiTrailerStyleValidator class and all its references - Remove ai_trailer_style TOML key, CLI arg, env var, and default - Remove require from --ai-attribution choices (only ignore|forbid) - Remove ai_trailer_style from rules_catalog, config_merger, api, main.py, engine VALIDATOR_MAP, and all related tests - AiAttributionValidator now handles only forbid (single responsibility) - Simplify rule_builder: only builds ai_attribution rule for forbid --- commit_check/__init__.py | 3 +- commit_check/api.py | 1 - commit_check/config_merger.py | 4 -- commit_check/engine.py | 71 --------------------------- commit_check/main.py | 18 ++----- commit_check/rule_builder.py | 30 +----------- commit_check/rules_catalog.py | 10 +--- tests/engine_test.py | 92 ----------------------------------- tests/rule_builder_test.py | 83 ++----------------------------- 9 files changed, 12 insertions(+), 300 deletions(-) diff --git a/commit_check/__init__.py b/commit_check/__init__.py index 33f266c8..fb2cffbb 100644 --- a/commit_check/__init__.py +++ b/commit_check/__init__.py @@ -73,8 +73,7 @@ } # AI attribution defaults -DEFAULT_AI_ATTRIBUTION = "ignore" # "ignore" | "require" | "forbid" -DEFAULT_AI_TRAILER_STYLE = "assisted-by" # "assisted-by" | "co-authored-by" +DEFAULT_AI_ATTRIBUTION = "ignore" # "ignore" | "forbid" __version__ = version("commit-check") diff --git a/commit_check/api.py b/commit_check/api.py index 3b996976..0458e68b 100644 --- a/commit_check/api.py +++ b/commit_check/api.py @@ -133,7 +133,6 @@ def validate_message( "allow_fixup_commits", "allow_wip_commits", "ai_attribution", - "ai_trailer_style", ] return _run_checks(check_names, context, cfg) diff --git a/commit_check/config_merger.py b/commit_check/config_merger.py index ef27d5c3..149e9da0 100644 --- a/commit_check/config_merger.py +++ b/commit_check/config_merger.py @@ -14,7 +14,6 @@ DEFAULT_BOOLEAN_RULES, DEFAULT_PUSH_RULES, DEFAULT_AI_ATTRIBUTION, - DEFAULT_AI_TRAILER_STYLE, ) @@ -79,7 +78,6 @@ def get_default_config() -> dict[str, Any]: "require_signed_off_by": DEFAULT_BOOLEAN_RULES["require_signed_off_by"], "ignore_authors": [], "ai_attribution": DEFAULT_AI_ATTRIBUTION, - "ai_trailer_style": DEFAULT_AI_TRAILER_STYLE, }, "branch": { "conventional_branch": True, @@ -125,7 +123,6 @@ class ConfigMerger: "CCHK_REQUIRE_SIGNED_OFF_BY": ("commit", "require_signed_off_by", parse_bool), "CCHK_IGNORE_AUTHORS": ("commit", "ignore_authors", parse_list), "CCHK_AI_ATTRIBUTION": ("commit", "ai_attribution", str), - "CCHK_AI_TRAILER_STYLE": ("commit", "ai_trailer_style", str), # Branch section "CCHK_CONVENTIONAL_BRANCH": ("branch", "conventional_branch", parse_bool), "CCHK_ALLOW_BRANCH_TYPES": ("branch", "allow_branch_types", parse_list), @@ -154,7 +151,6 @@ class ConfigMerger: "require_signed_off_by": ("commit", "require_signed_off_by"), "ignore_authors": ("commit", "ignore_authors"), "ai_attribution": ("commit", "ai_attribution"), - "ai_trailer_style": ("commit", "ai_trailer_style"), # Branch section "conventional_branch": ("branch", "conventional_branch"), "allow_branch_types": ("branch", "allow_branch_types"), diff --git a/commit_check/engine.py b/commit_check/engine.py index 8ba3333f..84e28590 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -9,8 +9,6 @@ from commit_check.rule_builder import ValidationRule from commit_check.ai_signatures import ( detect_ai_signatures, - find_co_authored_by_ai, - find_assisted_by_trailers, ) from commit_check.util import ( fetch_remote_ref, @@ -771,74 +769,6 @@ def _record_failure(self, value: str, error: str, suggest: str) -> None: ) -class AiTrailerStyleValidator(BaseValidator): - """Validates that AI-related trailers use the project-preferred style. - - Active only when ``ai_attribution = "require"``. Checks that any known - AI trailers use the format specified by ``ai_trailer_style``: - - * ``"assisted-by"`` (kernel style) — ``Co-authored-by: Claude`` → FAIL - * ``"co-authored-by"`` (GitHub style) — ``Assisted-by: Claude:...`` → FAIL - """ - - def validate(self, context: ValidationContext) -> ValidationResult: - if self._should_skip_commit_validation(context): - return ValidationResult.PASS - - message = self._get_commit_body(context) - if not message: - return ValidationResult.PASS - - preferred = self.rule.value # "assisted-by" or "co-authored-by" - signatures = detect_ai_signatures(message) - - if not signatures: - return ValidationResult.PASS - - if preferred == "assisted-by": - co_ai = find_co_authored_by_ai(message) - if co_ai: - self._record_failure( - value="; ".join(co_ai), - error="Project requires 'Assisted-by:' trailer (Linux kernel style) for AI attribution", - suggest="Replace 'Co-authored-by: ' with 'Assisted-by: :'", - ) - return ValidationResult.FAIL - - elif preferred == "co-authored-by": - assisted = find_assisted_by_trailers(message) - if assisted: - self._record_failure( - value="; ".join(assisted), - error="Project requires 'Co-authored-by:' trailer for AI attribution", - suggest="Replace 'Assisted-by: :' with 'Co-authored-by: '", - ) - return ValidationResult.FAIL - - return ValidationResult.PASS - - def _record_failure(self, value: str, error: str, suggest: str) -> None: - """Record a failure with dynamic error/suggest messages.""" - self._last_failure = { - "check": self.rule.check, - "value": value, - "error": error, - "suggest": suggest, - } - if not self._suppress_output: - rule_dict = self.rule.to_dict() - rule_dict["error"] = error - rule_dict["suggest"] = suggest - from commit_check.util import _print_failure - - _print_failure( - rule_dict, - value, - no_banner=self._no_banner, - compact=self._compact, - ) - - class ValidationEngine: """Main validation engine that orchestrates all validations.""" @@ -862,7 +792,6 @@ class ValidationEngine: "ignore_authors": CommitTypeValidator, "no_force_push": ForcePushValidator, "ai_attribution": AiAttributionValidator, - "ai_trailer_style": AiTrailerStyleValidator, } def __init__(self, rules: list[ValidationRule]): diff --git a/commit_check/main.py b/commit_check/main.py index 2c59511f..c5c814eb 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -297,21 +297,10 @@ def _get_parser() -> argparse.ArgumentParser: "--ai-attribution", type=str, default=None, - choices=["ignore", "require", "forbid"], + choices=["ignore", "forbid"], metavar="POLICY", - help="AI attribution policy: ignore (default), require, or forbid. " - "'forbid' rejects commits with known AI tool signatures; " - "'require' enforces proper trailer style when AI signatures are present.", - ) - - commit_group.add_argument( - "--ai-trailer-style", - type=str, - default=None, - choices=["assisted-by", "co-authored-by"], - metavar="STYLE", - help="Preferred AI attribution trailer style: 'assisted-by' (Linux kernel style, default) " - "or 'co-authored-by' (GitHub style). Used when ai_attribution is not 'ignore'.", + help="AI attribution policy: ignore (default) or forbid. " + "'forbid' rejects commits with known AI tool signatures.", ) # Branch configuration options @@ -451,7 +440,6 @@ def _get_requested_checks(args: argparse.Namespace) -> list[str]: "allow_fixup_commits", "allow_wip_commits", "ai_attribution", - "ai_trailer_style", ] ) if args.branch: diff --git a/commit_check/rule_builder.py b/commit_check/rule_builder.py index 79fd3016..cc768a00 100644 --- a/commit_check/rule_builder.py +++ b/commit_check/rule_builder.py @@ -16,7 +16,6 @@ DEFAULT_BOOLEAN_RULES, DEFAULT_PUSH_RULES, DEFAULT_AI_ATTRIBUTION, - DEFAULT_AI_TRAILER_STYLE, ) @@ -142,8 +141,6 @@ def _build_single_rule( return self._build_author_list_rule(catalog_entry, "ignore_authors") elif check == "ai_attribution": return self._build_ai_attribution_rule(catalog_entry) - elif check == "ai_trailer_style": - return self._build_ai_trailer_style_rule(catalog_entry) elif check == "merge_base": return self._build_merge_base_rule(catalog_entry) else: @@ -260,8 +257,7 @@ def _build_ai_attribution_rule( """Build AI attribution validation rule. Only active when policy is ``"forbid"`` — rejects any commit with - known AI tool signatures. Style enforcement is handled by - :meth:`_build_ai_trailer_style_rule` for ``"require"`` mode. + known AI tool signatures. """ policy = self.commit_config.get("ai_attribution", DEFAULT_AI_ATTRIBUTION) if policy != "forbid": @@ -274,30 +270,6 @@ def _build_ai_attribution_rule( suggest=catalog_entry.suggest or "", ) - def _build_ai_trailer_style_rule( - self, catalog_entry: RuleCatalogEntry - ) -> ValidationRule | None: - """Build AI trailer style validation rule. - - Only active when ``ai_attribution = \"require\"``. Checks that AI - tool trailers match the project-preferred format (``"assisted-by"`` - or ``"co-authored-by"``). - """ - policy = self.commit_config.get("ai_attribution", DEFAULT_AI_ATTRIBUTION) - if policy != "require": - return None - - style = self.commit_config.get("ai_trailer_style", DEFAULT_AI_TRAILER_STYLE) - if not style or style not in ("assisted-by", "co-authored-by"): - return None - - return ValidationRule( - check=catalog_entry.check, - value=style, - error=catalog_entry.error or "", - suggest=catalog_entry.suggest or "", - ) - def _build_boolean_rule( self, catalog_entry: RuleCatalogEntry, section_config: dict[str, Any] ) -> ValidationRule | None: diff --git a/commit_check/rules_catalog.py b/commit_check/rules_catalog.py index cf4238ae..6ee48979 100644 --- a/commit_check/rules_catalog.py +++ b/commit_check/rules_catalog.py @@ -107,14 +107,8 @@ class RuleCatalogEntry: RuleCatalogEntry( check="ai_attribution", regex=None, - error="AI attribution policy violation: {reason}", - suggest="{suggestion}", - ), - RuleCatalogEntry( - check="ai_trailer_style", - regex=None, - error="AI attribution trailer style violation", - suggest="{suggestion}", + error="AI attribution policy violation", + suggest="Remove AI attribution trailers from the commit message", ), ] diff --git a/tests/engine_test.py b/tests/engine_test.py index 62aeaf66..7cea27dd 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -22,7 +22,6 @@ MergeBaseValidator, ForcePushValidator, AiAttributionValidator, - AiTrailerStyleValidator, ) from commit_check.rule_builder import ValidationRule @@ -1019,7 +1018,6 @@ def test_validation_engine_validator_map(self): "allow_wip_commits": CommitTypeValidator, "ignore_authors": CommitTypeValidator, "ai_attribution": AiAttributionValidator, - "ai_trailer_style": AiTrailerStyleValidator, } for check, validator_class in expected_mappings.items(): @@ -1702,95 +1700,5 @@ def test_empty_message_passes(self): result = validator.validate(context) assert result == ValidationResult.PASS - -class TestAiTrailerStyleValidator: - """Tests for AiTrailerStyleValidator.""" - - @pytest.mark.benchmark - def test_clean_message_passes(self): - """Clean message with no AI signatures passes.""" - rule = ValidationRule( - check="ai_trailer_style", - value="assisted-by", - ) - validator = AiTrailerStyleValidator(rule) - context = ValidationContext(stdin_text="feat: add feature") - result = validator.validate(context) - assert result == ValidationResult.PASS - - @pytest.mark.benchmark - def test_assisted_by_style_rejects_co_author(self): - """assisted-by style rejects Co-authored-by AI trailers.""" - rule = ValidationRule( - check="ai_trailer_style", - value="assisted-by", - ) - validator = AiTrailerStyleValidator(rule) - message = "feat: add feature\n\nCo-authored-by: Claude " - context = ValidationContext(stdin_text=message) - result = validator.validate(context) - assert result == ValidationResult.FAIL - - @pytest.mark.benchmark - def test_assisted_by_style_passes_correct(self): - """assisted-by style passes Assisted-by: trailers.""" - rule = ValidationRule( - check="ai_trailer_style", - value="assisted-by", - ) - validator = AiTrailerStyleValidator(rule) - message = "feat: add feature\n\nAssisted-by: Claude:claude-sonnet-4" - context = ValidationContext(stdin_text=message) - result = validator.validate(context) - assert result == ValidationResult.PASS - - @pytest.mark.benchmark - def test_co_author_style_rejects_assisted_by(self): - """co-authored-by style rejects Assisted-by trailers.""" - rule = ValidationRule( - check="ai_trailer_style", - value="co-authored-by", - ) - validator = AiTrailerStyleValidator(rule) - message = "feat: add feature\n\nAssisted-by: Claude:claude-sonnet-4" - context = ValidationContext(stdin_text=message) - result = validator.validate(context) - assert result == ValidationResult.FAIL - - @pytest.mark.benchmark - def test_human_co_author_passes(self): - """Human Co-authored-by (not AI) passes style validation.""" - rule = ValidationRule( - check="ai_trailer_style", - value="assisted-by", - ) - validator = AiTrailerStyleValidator(rule) - message = "feat: add feature\n\nCo-authored-by: Alice Smith " - context = ValidationContext(stdin_text=message) - result = validator.validate(context) - assert result == ValidationResult.PASS - - @pytest.mark.benchmark - def test_co_author_style_passes_correct_ai_trailers(self): - """co-authored-by style passes Co-authored-by AI trailers.""" - rule = ValidationRule( - check="ai_trailer_style", - value="co-authored-by", - ) - validator = AiTrailerStyleValidator(rule) - message = "feat: add feature\n\nCo-authored-by: Claude " - context = ValidationContext(stdin_text=message) - result = validator.validate(context) - assert result == ValidationResult.PASS - - @pytest.mark.benchmark - def test_empty_message_passes(self): - """Empty message passes.""" - rule = ValidationRule( - check="ai_trailer_style", - value="assisted-by", - ) - validator = AiTrailerStyleValidator(rule) - context = ValidationContext(stdin_text="") result = validator.validate(context) assert result == ValidationResult.PASS diff --git a/tests/rule_builder_test.py b/tests/rule_builder_test.py index f3fa7964..c4f4763f 100644 --- a/tests/rule_builder_test.py +++ b/tests/rule_builder_test.py @@ -396,11 +396,10 @@ def test_ai_attribution_forbid_creates_rule(self): assert rule is not None assert rule.check == "ai_attribution" assert rule.value == "forbid" - assert rule.allowed is None # forbid doesn't set trailer style @pytest.mark.benchmark def test_ai_attribution_require_returns_none(self): - """ai_attribution='require' returns None (style enforced by trailer validator).""" + """ai_attribution='require' returns None (only forbid supported).""" config = {"commit": {"ai_attribution": "require"}} builder = RuleBuilder(config) entry = RuleCatalogEntry(check="ai_attribution") @@ -408,91 +407,19 @@ def test_ai_attribution_require_returns_none(self): assert rule is None @pytest.mark.benchmark - def test_ai_trailer_style_ignore_returns_none(self): - """When ai_attribution='ignore', ai_trailer_style returns None.""" - config = { - "commit": {"ai_attribution": "ignore", "ai_trailer_style": "assisted-by"} - } - builder = RuleBuilder(config) - entry = RuleCatalogEntry(check="ai_trailer_style") - rule = builder._build_ai_trailer_style_rule(entry) - assert rule is None - - @pytest.mark.benchmark - def test_ai_trailer_style_forbid_returns_none(self): - """ai_trailer_style returns None when ai_attribution='forbid'.""" - config = { - "commit": { - "ai_attribution": "forbid", - "ai_trailer_style": "assisted-by", - } - } - builder = RuleBuilder(config) - entry = RuleCatalogEntry(check="ai_trailer_style") - rule = builder._build_ai_trailer_style_rule(entry) - assert rule is None - - @pytest.mark.benchmark - def test_ai_trailer_style_creates_rule_for_require(self): - """ai_trailer_style creates a rule only when ai_attribution='require'.""" - config = { - "commit": { - "ai_attribution": "require", - "ai_trailer_style": "assisted-by", - } - } - builder = RuleBuilder(config) - entry = RuleCatalogEntry(check="ai_trailer_style") - rule = builder._build_ai_trailer_style_rule(entry) - assert rule is not None - assert rule.check == "ai_trailer_style" - assert rule.value == "assisted-by" - - @pytest.mark.benchmark - def test_ai_trailer_style_co_author(self): - """ai_trailer_style='co-authored-by' is passed through.""" - config = { - "commit": { - "ai_attribution": "require", - "ai_trailer_style": "co-authored-by", - } - } - builder = RuleBuilder(config) - entry = RuleCatalogEntry(check="ai_trailer_style") - rule = builder._build_ai_trailer_style_rule(entry) - assert rule is not None - assert rule.value == "co-authored-by" - - @pytest.mark.benchmark - def test_build_all_rules_forbid_includes_only_attribution(self): - """forbid mode includes ai_attribution but NOT ai_trailer_style.""" + def test_build_all_rules_forbid_includes_attribution(self): + """forbid mode includes ai_attribution and no other AI rules.""" config = {"commit": {"ai_attribution": "forbid"}} builder = RuleBuilder(config) rules = builder.build_all_rules() - ai_rules = [ - r for r in rules if r.check in ("ai_attribution", "ai_trailer_style") - ] + ai_rules = [r for r in rules if r.check.startswith("ai_")] assert len(ai_rules) == 1 assert ai_rules[0].check == "ai_attribution" - @pytest.mark.benchmark - def test_build_all_rules_require_includes_only_trailer(self): - """require mode includes ai_trailer_style but NOT ai_attribution.""" - config = {"commit": {"ai_attribution": "require"}} - builder = RuleBuilder(config) - rules = builder.build_all_rules() - ai_rules = [ - r for r in rules if r.check in ("ai_attribution", "ai_trailer_style") - ] - assert len(ai_rules) == 1 - assert ai_rules[0].check == "ai_trailer_style" - @pytest.mark.benchmark def test_build_all_rules_no_ai_by_default(self): """build_all_rules does not include AI rules by default.""" builder = RuleBuilder({}) rules = builder.build_all_rules() - ai_rules = [ - r for r in rules if r.check in ("ai_attribution", "ai_trailer_style") - ] + ai_rules = [r for r in rules if r.check.startswith("ai_")] assert len(ai_rules) == 0 From 777e4b42c16c3db0869401323c244261b83aabf6 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 5 Jul 2026 23:52:30 +0300 Subject: [PATCH 04/10] docs: add AI attribution documentation - configuration.rst: add ai_attribution to example config, env var table, CLI mapping, and options reference table - README.rst: mention AI attribution in overview and add config example with ai_attribution = "forbid" - what-is-new.rst: add v2.10.0 entry describing the AI attribution governance feature and the full list of detected tools --- README.rst | 7 +++++-- docs/configuration.rst | 9 +++++++++ docs/what-is-new.rst | 42 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index f82a4bb9..8952eb53 100644 --- a/README.rst +++ b/README.rst @@ -46,8 +46,8 @@ Overview **Commit Check** is a lightweight policy engine for Git commit metadata. It validates commit messages, branch names, author identity, signoff trailers, -and push safety — using one versioned TOML policy across local hooks, CI, -GitHub Actions, and AI automation. +AI attribution policy, and push safety — using one versioned TOML policy across +local hooks, CI, GitHub Actions, and AI automation. - **One policy file:** ``cchk.toml`` - **Multiple enforcement points:** CLI, pre-commit, CI / GitHub Actions @@ -139,6 +139,9 @@ To customize the behavior, create a configuration file named ``cchk.toml`` or `` require_signed_off_by = false # Bypass checks for bot/automation authors and co-authors: ignore_authors = ["dependabot[bot]", "renovate[bot]", "copilot[bot]"] + # AI attribution policy: "ignore" (default) or "forbid" + # "forbid" rejects commits with known AI tool signatures + ai_attribution = "forbid" [branch] # https://conventionalbranch.org diff --git a/docs/configuration.rst b/docs/configuration.rst index 03184bb7..1a4416d2 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -126,6 +126,7 @@ Example Configuration require_signed_off_by = false # required_signoff_name = "Your Name" # Optional # required_signoff_email = "your.email@example.com" # Optional + ai_attribution = "forbid" # "ignore" (default) or "forbid" — rejects AI tool trailers [push] # Block force pushes when used as a pre-push hook or with --no-force-push @@ -288,6 +289,9 @@ Configuration can also be set via environment variables with the ``CCHK_`` prefi * - ``allow_force_push = true`` - ``CCHK_ALLOW_FORCE_PUSH=false`` - ``--no-force-push`` (enable via ``--no-force-push`` flag) + * - ``ai_attribution = "forbid"`` + - ``CCHK_AI_ATTRIBUTION=forbid`` + - ``--ai-attribution=forbid`` * - ``ignore_authors = ["bot"]`` (in branch section) - ``CCHK_BRANCH_IGNORE_AUTHORS=bot,user`` - ``--branch-ignore-authors=bot,user`` @@ -398,6 +402,11 @@ Options Table Description - bool - false - Require "Signed-off-by" line in the commit message footer. + * - commit + - ai_attribution + - str + - "ignore" + - AI attribution policy. ``"forbid"`` rejects any commit containing known AI tool signatures (Claude Code, Copilot, Codex, Gemini, Cursor, Devin, Aider, Windsurf, Tabby, and generic AI model patterns). ``"ignore"`` disables the check. This feature is a response to the industry-wide discussion on AI disclosure in open source (Linux kernel ``Assisted-by:`` trailer, CPython, VS Code, Apache, Fedora policies). * - branch - conventional_branch - bool diff --git a/docs/what-is-new.rst b/docs/what-is-new.rst index e0a50c65..3dfd0769 100644 --- a/docs/what-is-new.rst +++ b/docs/what-is-new.rst @@ -3,6 +3,48 @@ What's New This document highlights the major changes and improvements in each version of commit-check. +Version 2.10.0 — AI Attribution Governance +-------------------------------------------- + +Enforce Your Project's AI Contribution Policy +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +commit-check now supports **AI attribution governance** — a neutral enforcement +layer for the industry-wide discussion on AI disclosure in open source. + +Configured under ``[commit]``: + +.. code-block:: toml + + [commit] + # "ignore" (default) | "forbid" + ai_attribution = "forbid" + +When set to ``"forbid"``, any commit containing known AI tool signatures is +rejected. The built-in signature database detects trailers and markers from: + +* **Claude Code** — ``Co-authored-by: Claude``, ``Assisted-by: Claude:...``, + ``🤖 Generated with Claude``, ``Claude-Session:``, ``Claude-Workflow:`` +* **GitHub Copilot** — ``Co-authored-by: Copilot`` +* **OpenAI Codex** — ``Co-authored-by: Codex`` +* **Gemini** — ``Co-authored-by: Gemini`` +* **Cursor** — ``Co-authored-by: Cursor`` +* **Devin** — ``Co-authored-by: Devin`` +* **Aider** — ``Co-authored-by: Aider``, ``Co-authored-by: ... (aider)`` +* **Windsurf** — ``Co-authored-by: Windsurf`` +* **Tabby** — ``Co-authored-by: Tabby`` +* **Generic AI** — ``Assisted-by:`` (Linux kernel style, with tool list), + model names like ``claude-sonnet-4``, ``gpt-4-turbo`` + +The signature database is designed to be extensible — adding a new tool is as +simple as adding a ``KnownAiTool`` entry with the tool's patterns. + +This feature is motivated by ongoing discussions in the CPython core +development community, the Linux kernel's ``Assisted-by:`` trailer standard, +VS Code, Apache, Fedora, and other foundations. + +See `Configuration Documentation `_ for details. + Version 2.9.1 — Bot Branch Types as Default --------------------------------------------- From 34e90ca9edbb426341f58a728c1675e44eefdd7b Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 5 Jul 2026 23:55:51 +0300 Subject: [PATCH 05/10] docs: bump AI attribution feature version to 2.11.0 --- docs/what-is-new.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/what-is-new.rst b/docs/what-is-new.rst index 3dfd0769..07dc4c97 100644 --- a/docs/what-is-new.rst +++ b/docs/what-is-new.rst @@ -3,7 +3,7 @@ What's New This document highlights the major changes and improvements in each version of commit-check. -Version 2.10.0 — AI Attribution Governance +Version 2.11.0 — AI Attribution Governance -------------------------------------------- Enforce Your Project's AI Contribution Policy From 7f0a856d4e7262c567126a6d25ee96bfd131210f Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 5 Jul 2026 23:59:28 +0300 Subject: [PATCH 06/10] docs: fix Bot Branch Types version from 2.9.1 to 2.10.0 --- docs/what-is-new.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/what-is-new.rst b/docs/what-is-new.rst index 07dc4c97..d9db1cbb 100644 --- a/docs/what-is-new.rst +++ b/docs/what-is-new.rst @@ -45,7 +45,7 @@ VS Code, Apache, Fedora, and other foundations. See `Configuration Documentation `_ for details. -Version 2.9.1 — Bot Branch Types as Default +Version 2.10.0 — Bot Branch Types as Default --------------------------------------------- ``dependabot/`` and ``renovate/`` branches now pass by default From 877e5fd0d77ffff8b47e8aeab76bcd27d20e8cdd Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Mon, 6 Jul 2026 00:05:20 +0300 Subject: [PATCH 07/10] chore: improve forbid suggestion message to clarify project policy --- commit_check/engine.py | 2 +- commit_check/rules_catalog.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/commit_check/engine.py b/commit_check/engine.py index 84e28590..68082371 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -741,7 +741,7 @@ def validate(self, context: ValidationContext) -> ValidationResult: self._record_failure( value=", ".join(sorted(tools)), error=f"AI-assisted commit is forbidden — detected tools: {', '.join(sorted(tools))}", - suggest="Remove AI attribution trailers from the commit message", + suggest="This project does not permit AI-assisted commits. Remove AI tool trailers from the commit message and re-commit.", ) return ValidationResult.FAIL diff --git a/commit_check/rules_catalog.py b/commit_check/rules_catalog.py index 6ee48979..b05944e3 100644 --- a/commit_check/rules_catalog.py +++ b/commit_check/rules_catalog.py @@ -108,7 +108,7 @@ class RuleCatalogEntry: check="ai_attribution", regex=None, error="AI attribution policy violation", - suggest="Remove AI attribution trailers from the commit message", + suggest="This project does not permit AI-assisted commits. Remove AI tool trailers from the commit message and re-commit.", ), ] From dba74daa25b4281bd46c7ed3fa610f3e25b0ccb6 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Mon, 6 Jul 2026 00:06:15 +0300 Subject: [PATCH 08/10] chore: trim forbid suggestion message --- commit_check/engine.py | 2 +- commit_check/rules_catalog.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/commit_check/engine.py b/commit_check/engine.py index 68082371..d8caf189 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -741,7 +741,7 @@ def validate(self, context: ValidationContext) -> ValidationResult: self._record_failure( value=", ".join(sorted(tools)), error=f"AI-assisted commit is forbidden — detected tools: {', '.join(sorted(tools))}", - suggest="This project does not permit AI-assisted commits. Remove AI tool trailers from the commit message and re-commit.", + suggest="This project forbids AI-assisted commits. Remove AI trailers and re-commit.", ) return ValidationResult.FAIL diff --git a/commit_check/rules_catalog.py b/commit_check/rules_catalog.py index b05944e3..108b4134 100644 --- a/commit_check/rules_catalog.py +++ b/commit_check/rules_catalog.py @@ -108,7 +108,7 @@ class RuleCatalogEntry: check="ai_attribution", regex=None, error="AI attribution policy violation", - suggest="This project does not permit AI-assisted commits. Remove AI tool trailers from the commit message and re-commit.", + suggest="This project forbids AI-assisted commits. Remove AI trailers and re-commit.", ), ] From f272e492071595f8ba3bc187e8656c27c6d6f6c8 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Mon, 6 Jul 2026 00:12:36 +0300 Subject: [PATCH 09/10] refactor: split AI signatures into data and logic modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow imperatives.py pattern — ai_signatures_data.py holds tool definitions; ai_signatures.py holds detection API only. --- commit_check/ai_signatures.py | 297 +++-------------------------- commit_check/ai_signatures_data.py | 255 +++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 275 deletions(-) create mode 100644 commit_check/ai_signatures_data.py diff --git a/commit_check/ai_signatures.py b/commit_check/ai_signatures.py index 021f67fc..cbe1a9ee 100644 --- a/commit_check/ai_signatures.py +++ b/commit_check/ai_signatures.py @@ -1,278 +1,29 @@ -"""Known AI tool signatures found in commit messages and trailers. +"""AI tool signature detection logic. -This module maintains a curated directory of patterns that known AI coding -tools leave behind in commit messages. It is the technical core of the -``ai_attribution`` validator — keeping this database up to date is what -gives users a reason to use commit-check instead of rolling their own regex. +This module provides the public API for detecting AI tool signatures in commit +messages. The signature data (tool definitions and patterns) lives in +:mod:`commit_check.ai_signatures_data`. -Each entry describes: -* A human-readable tool name (for error messages). -* One or more regex patterns that match trailers, footers, or body markers - left by that tool. -* Whether the pattern is a ``trailer`` (structured key: value line, typically - in the commit body footer) or a ``body_marker`` (free-text marker anywhere - in the message body). +Typical usage:: -Adding a new tool ------------------ -#. Find the commit-message artefacts the tool produces (e.g. - ``Co-Authored-By: Copilot ``). -#. Add a ``KnownAiTool`` entry with a unique name and one or more patterns. -#. Submit a PR — the project maintainers will review and release. + from commit_check.ai_signatures import detect_ai_signatures + + result = detect_ai_signatures( + "feat: init\\n\\nCo-authored-by: Claude " + ) """ from __future__ import annotations import re -from dataclasses import dataclass, field - - -@dataclass(frozen=True) -class AiSignaturePattern: - """A single pattern that identifies AI tool usage in a commit message. - - :param regex: A compiled regex that, if matched anywhere in the commit - message body, indicates the corresponding tool was involved. - :param kind: ``"trailer"`` for structured ``Key: value`` footer lines - (matched case-insensitively), ``"body_marker"`` for any other text - marker. - :param description: Human-readable description of what is matched. - """ - - regex: re.Pattern[str] - kind: str # "trailer" | "body_marker" - description: str = "" - -@dataclass(frozen=True) -class KnownAiTool: - """A known AI coding tool and its commit-message signatures. - - :param name: Short display name (e.g. ``"Claude Code"``, ``"GitHub Copilot"``). - :param patterns: One or more signature patterns that indicate this tool. - """ +from commit_check.ai_signatures_data import ALL_KNOWN_TOOLS as _ALL_KNOWN_TOOLS - name: str - patterns: list[AiSignaturePattern] = field(default_factory=list) +# Re-export for convenience — consumers can import everything from +# commit_check.ai_signatures without knowing about the data/logic split. +ALL_KNOWN_TOOLS = _ALL_KNOWN_TOOLS -# --------------------------------------------------------------------------- -# Pattern helpers -# --------------------------------------------------------------------------- - - -def _trailer( - key: str, value_pattern: str = r".*", description: str = "" -) -> AiSignaturePattern: - """Build a trailer pattern for a structured ``Key: value`` line. - - The match is case-insensitive and anchors the key at the start of a line. - """ - raw = rf"^{re.escape(key)}:\s*{value_pattern}\s*$" - return AiSignaturePattern( - regex=re.compile(raw, re.IGNORECASE | re.MULTILINE), - kind="trailer", - description=description or f"``{key}:`` trailer", - ) - - -def _body_marker(pattern: str, description: str = "") -> AiSignaturePattern: - """Build a free-text body marker pattern.""" - return AiSignaturePattern( - regex=re.compile(pattern, re.MULTILINE), - kind="body_marker", - description=description, - ) - - -# --------------------------------------------------------------------------- -# Known tool signatures -# --------------------------------------------------------------------------- - -# --- Anthropic Claude Code / Claude CLI --- -CLAUDE_CODE = KnownAiTool( - name="Claude Code", - patterns=[ - # Standard Co-authored-by trailer added by Claude Code. - # When an email is present, anchor to known AI noreply addresses - # to avoid false positives with human co-authors named Claude. - _trailer( - "Co-authored-by", - r"Claude(?: Code)?" - r"(?:\s*<(?:" - r"noreply@anthropic\.com" - r"|\d+\+Claude@users\.noreply\.github\.com" - r")>)?", - "``Co-authored-by: Claude`` trailer", - ), - # Assisted-by trailer (Linux kernel style, with optional tool list) - _trailer( - "Assisted-by", - r"Claude:\S+(?:\s+\S+)*", - "``Assisted-by: Claude: [tools]`` trailer", - ), - # Body marker: generated-with notice - _body_marker( - r"🤖\s*Generated\s+(?:with|by)\s+\[?Claude", - "``🤖 Generated with Claude`` body marker", - ), - # Session ID trailer (Claude Code sometimes adds this) - _trailer("Claude-Session", r"\S+", "``Claude-Session:`` trailer"), - # Workflow ID trailer - _trailer("Claude-Workflow", r"\S+", "``Claude-Workflow:`` trailer"), - ], -) - -# --- GitHub Copilot --- -COPILOT = KnownAiTool( - name="GitHub Copilot", - patterns=[ - _trailer( - "Co-authored-by", - r"Copilot" - r"(?:\s*<\d+\+Copilot@users\.noreply\.github\.com>)?", - "``Co-authored-by: Copilot`` trailer", - ), - ], -) - -# --- OpenAI Codex --- -CODEX = KnownAiTool( - name="OpenAI Codex", - patterns=[ - _trailer( - "Co-authored-by", - r"Codex\s*(?:<[^>]*>)?", - "``Co-authored-by: Codex`` trailer", - ), - ], -) - -# --- Gemini (Google) --- -GEMINI = KnownAiTool( - name="Gemini", - patterns=[ - _trailer( - "Co-authored-by", - r"Gemini\s*(?:<[^>]*>)?", - "``Co-authored-by: Gemini`` trailer", - ), - ], -) - -# --- Cursor --- -CURSOR = KnownAiTool( - name="Cursor", - patterns=[ - _trailer( - "Co-authored-by", - r"Cursor\s*(?:<[^>]*>)?", - "``Co-authored-by: Cursor`` trailer", - ), - ], -) - -# --- Devin --- -DEVIN = KnownAiTool( - name="Devin", - patterns=[ - _trailer( - "Co-authored-by", - r"Devin\s*(?:<[^>]*>)?", - "``Co-authored-by: Devin`` trailer", - ), - ], -) - -# --- Aider --- -AIDER = KnownAiTool( - name="Aider", - patterns=[ - _trailer( - "Co-authored-by", - r"Aider\s*(?:<[^>]*>)?", - "``Co-authored-by: Aider`` trailer", - ), - # aider appends "(aider)" to the author name - _trailer( - "Co-authored-by", - r"[^<]+\(aider\)\s*(?:<[^>]*>)?", - "``Co-authored-by: ... (aider)`` trailer", - ), - ], -) - -# --- Windsurf (Codeium) --- -WINDSURF = KnownAiTool( - name="Windsurf", - patterns=[ - _trailer( - "Co-authored-by", - r"Windsurf\s*(?:<[^>]*>)?", - "``Co-authored-by: Windsurf`` trailer", - ), - ], -) - -# --- Tabby --- -TABBY = KnownAiTool( - name="Tabby", - patterns=[ - _trailer( - "Co-authored-by", - r"Tabby\s*(?:<[^>]*>)?", - "``Co-authored-by: Tabby`` trailer", - ), - ], -) - -# --- Generic / catch-all AI patterns --- -GENERIC_AI = KnownAiTool( - name="Generic AI", - patterns=[ - # Catch AI agent model identifiers in Co-authored-by - # (e.g. claude-sonnet-4, gpt-4-turbo, gemini-1.5-pro). - # Only matches values that look like model names (containing - # word chars, dots, or hyphens — not plain human names). - _trailer( - "Co-authored-by", - r"(?:claude|gpt|gemini)[\w.-]*(?:\s*<[^>]*>)?", - "``Co-authored-by`` with AI model name", - ), - # Catch Assisted-by trailer (Linux kernel style) regardless of agent, - # with optional trailing tool list, e.g.: - # "Assisted-by: Claude:claude-3-opus coccinelle sparse" - _trailer( - "Assisted-by", - r"\S+:\S+(?:\s+\S+)*", - "``Assisted-by: : [tools]`` trailer (kernel style)", - ), - # Catch common body markers - _body_marker( - r"^Generated\s+(?:by|with)\s+(?:AI|artificial intelligence)", - "``Generated by AI`` body marker", - ), - ], -) - -# --------------------------------------------------------------------------- -# Master registry — ordered by specificity (most specific first) -# --------------------------------------------------------------------------- - -#: All known AI tools, ordered so that more specific patterns are checked first. -ALL_KNOWN_TOOLS: list[KnownAiTool] = [ - CLAUDE_CODE, - COPILOT, - CODEX, - GEMINI, - CURSOR, - DEVIN, - AIDER, - WINDSURF, - TABBY, - GENERIC_AI, -] - #: Flat list of all compiled patterns for bulk scanning. #: Each tuple is ``(regex, tool_name, description, kind)``. ALL_PATTERNS: list[tuple[re.Pattern[str], str, str, str]] = [ @@ -282,11 +33,6 @@ def _body_marker(pattern: str, description: str = "") -> AiSignaturePattern: ] -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - - def detect_ai_signatures(message: str) -> list[dict[str, str]]: """Scan *message* for known AI tool signatures. @@ -297,11 +43,13 @@ def detect_ai_signatures(message: str) -> list[dict[str, str]]: Example:: - >>> detect_ai_signatures("feat: init\\n\\nCo-authored-by: Claude ") - [{'tool': 'Claude Code', 'kind': 'trailer', 'description': '``Co-authored-by: Claude`` trailer', 'matched_text': 'Co-authored-by: Claude '}] + >>> detect_ai_signatures( + ... "feat: init\\n\\nCo-authored-by: Claude " + ... ) + [{'tool': 'Claude Code', 'kind': 'trailer', ...}] """ results: list[dict[str, str]] = [] - seen: set[str] = set() # deduplicate by matched text + seen: set[str] = set() for regex, tool_name, desc, kind in ALL_PATTERNS: for match in regex.finditer(message): @@ -331,10 +79,9 @@ def has_ai_signature(message: str) -> bool: def find_co_authored_by_ai(message: str) -> list[str]: """Find ``Co-authored-by`` trailer lines that reference known AI tools. - Scans all known AI patterns and collects those that start with - ``Co-authored-by:`` (case-insensitive). Human co-authors like - ``Co-authored-by: Jane Doe `` are NOT returned - because no known AI tool pattern matches common human names. + Human co-authors like ``Co-authored-by: Jane Doe `` + are NOT returned because no known AI tool pattern matches common + human names. :returns: List of matched trailer lines (deduplicated). """ diff --git a/commit_check/ai_signatures_data.py b/commit_check/ai_signatures_data.py new file mode 100644 index 00000000..62f9b008 --- /dev/null +++ b/commit_check/ai_signatures_data.py @@ -0,0 +1,255 @@ +"""Known AI tool signatures — pure data, no detection logic. + +This module defines the data structures and the curated registry of known AI +coding tool signatures. To add a new tool, define a ``KnownAiTool`` entry +with its patterns and add it to ``ALL_KNOWN_TOOLS``. + +The detection logic lives in :mod:`commit_check.ai_signatures`. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class AiSignaturePattern: + """A single pattern that identifies AI tool usage in a commit message. + + :param regex: A compiled regex that, if matched anywhere in the commit + message body, indicates the corresponding tool was involved. + :param kind: ``"trailer"`` for structured ``Key: value`` footer lines + (matched case-insensitively), ``"body_marker"`` for any other text + marker. + :param description: Human-readable description of what is matched. + """ + + regex: re.Pattern[str] + kind: str # "trailer" | "body_marker" + description: str = "" + + +@dataclass(frozen=True) +class KnownAiTool: + """A known AI coding tool and its commit-message signatures. + + :param name: Short display name (e.g. ``"Claude Code"``, ``"GitHub Copilot"``). + :param patterns: One or more signature patterns that indicate this tool. + """ + + name: str + patterns: list[AiSignaturePattern] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Pattern helpers +# --------------------------------------------------------------------------- + + +def _trailer( + key: str, value_pattern: str = r".*", description: str = "" +) -> AiSignaturePattern: + """Build a trailer pattern for a structured ``Key: value`` line. + + The match is case-insensitive and anchors the key at the start of a line. + """ + raw = rf"^{re.escape(key)}:\s*{value_pattern}\s*$" + return AiSignaturePattern( + regex=re.compile(raw, re.IGNORECASE | re.MULTILINE), + kind="trailer", + description=description or f"``{key}:`` trailer", + ) + + +def _body_marker(pattern: str, description: str = "") -> AiSignaturePattern: + """Build a free-text body marker pattern.""" + return AiSignaturePattern( + regex=re.compile(pattern, re.MULTILINE), + kind="body_marker", + description=description, + ) + + +# --------------------------------------------------------------------------- +# Known tool signatures +# --------------------------------------------------------------------------- + +# --- Anthropic Claude Code / Claude CLI --- +CLAUDE_CODE = KnownAiTool( + name="Claude Code", + patterns=[ + # Standard Co-authored-by trailer added by Claude Code. + # When an email is present, anchor to known AI noreply addresses + # to avoid false positives with human co-authors named Claude. + _trailer( + "Co-authored-by", + r"Claude(?: Code)?" + r"(?:\s*<(?:noreply@anthropic\.com" + r"|\d+\+Claude@users\.noreply\.github\.com)>)?", + "``Co-authored-by: Claude`` trailer", + ), + # Assisted-by trailer (Linux kernel style, with optional tool list) + _trailer( + "Assisted-by", + r"Claude:\S+(?:\s+\S+)*", + "``Assisted-by: Claude: [tools]`` trailer", + ), + # Body marker: generated-with notice + _body_marker( + r"🤖\s*Generated\s+(?:with|by)\s+\[?Claude", + "``🤖 Generated with Claude`` body marker", + ), + # Session ID trailer (Claude Code sometimes adds this) + _trailer("Claude-Session", r"\S+", "``Claude-Session:`` trailer"), + # Workflow ID trailer + _trailer("Claude-Workflow", r"\S+", "``Claude-Workflow:`` trailer"), + ], +) + +# --- GitHub Copilot --- +COPILOT = KnownAiTool( + name="GitHub Copilot", + patterns=[ + _trailer( + "Co-authored-by", + r"Copilot" + r"(?:\s*<\d+\+Copilot@users\.noreply\.github\.com>)?", + "``Co-authored-by: Copilot`` trailer", + ), + ], +) + +# --- OpenAI Codex --- +CODEX = KnownAiTool( + name="OpenAI Codex", + patterns=[ + _trailer( + "Co-authored-by", + r"Codex\s*(?:<[^>]*>)?", + "``Co-authored-by: Codex`` trailer", + ), + ], +) + +# --- Gemini (Google) --- +GEMINI = KnownAiTool( + name="Gemini", + patterns=[ + _trailer( + "Co-authored-by", + r"Gemini\s*(?:<[^>]*>)?", + "``Co-authored-by: Gemini`` trailer", + ), + ], +) + +# --- Cursor --- +CURSOR = KnownAiTool( + name="Cursor", + patterns=[ + _trailer( + "Co-authored-by", + r"Cursor\s*(?:<[^>]*>)?", + "``Co-authored-by: Cursor`` trailer", + ), + ], +) + +# --- Devin --- +DEVIN = KnownAiTool( + name="Devin", + patterns=[ + _trailer( + "Co-authored-by", + r"Devin\s*(?:<[^>]*>)?", + "``Co-authored-by: Devin`` trailer", + ), + ], +) + +# --- Aider --- +AIDER = KnownAiTool( + name="Aider", + patterns=[ + _trailer( + "Co-authored-by", + r"Aider\s*(?:<[^>]*>)?", + "``Co-authored-by: Aider`` trailer", + ), + # aider appends "(aider)" to the author name + _trailer( + "Co-authored-by", + r"[^<]+\(aider\)\s*(?:<[^>]*>)?", + "``Co-authored-by: ... (aider)`` trailer", + ), + ], +) + +# --- Windsurf (Codeium) --- +WINDSURF = KnownAiTool( + name="Windsurf", + patterns=[ + _trailer( + "Co-authored-by", + r"Windsurf\s*(?:<[^>]*>)?", + "``Co-authored-by: Windsurf`` trailer", + ), + ], +) + +# --- Tabby --- +TABBY = KnownAiTool( + name="Tabby", + patterns=[ + _trailer( + "Co-authored-by", + r"Tabby\s*(?:<[^>]*>)?", + "``Co-authored-by: Tabby`` trailer", + ), + ], +) + +# --- Generic / catch-all AI patterns --- +GENERIC_AI = KnownAiTool( + name="Generic AI", + patterns=[ + # Catch AI agent model identifiers in Co-authored-by + # (e.g. claude-sonnet-4, gpt-4-turbo, gemini-1.5-pro). + _trailer( + "Co-authored-by", + r"(?:claude|gpt|gemini)[\w.-]*(?:\s*<[^>]*>)?", + "``Co-authored-by`` with AI model name", + ), + # Catch Assisted-by trailer (Linux kernel style) regardless of agent, + # with optional trailing tool list. + _trailer( + "Assisted-by", + r"\S+:\S+(?:\s+\S+)*", + "``Assisted-by: : [tools]`` trailer (kernel style)", + ), + # Catch common body markers + _body_marker( + r"^Generated\s+(?:by|with)\s+(?:AI|artificial intelligence)", + "``Generated by AI`` body marker", + ), + ], +) + +# --------------------------------------------------------------------------- +# Master registry — ordered by specificity (most specific first) +# --------------------------------------------------------------------------- + +#: All known AI tools, ordered so that more specific patterns are checked first. +ALL_KNOWN_TOOLS: list[KnownAiTool] = [ + CLAUDE_CODE, + COPILOT, + CODEX, + GEMINI, + CURSOR, + DEVIN, + AIDER, + WINDSURF, + TABBY, + GENERIC_AI, +] From 48d9a17b2bfb185468468dd5ab3c879ca486388b Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Mon, 6 Jul 2026 08:29:38 +0000 Subject: [PATCH 10/10] fix: prevent false positives for human names in AI detection The generic AI model-name pattern matched any Co-authored-by trailer whose value started with claude/gpt/gemini plus any email, so a human co-author with a bare first name such as "Claude" would be flagged incorrectly. Require a hyphenated model suffix (e.g. claude-sonnet-4) so bare human names are never matched while real model identifiers still are. Also remove the now-unused find_co_authored_by_ai and find_assisted_by_trailers helpers left over from the dropped trailer-style mode, fix the stale AiAttributionValidator docstring, and add regression tests covering human-name false positives and model-identifier detection. --- commit_check/ai_signatures.py | 33 --------- commit_check/ai_signatures_data.py | 4 +- commit_check/engine.py | 6 +- tests/ai_signatures_test.py | 113 ++++++++++------------------- 4 files changed, 42 insertions(+), 114 deletions(-) diff --git a/commit_check/ai_signatures.py b/commit_check/ai_signatures.py index cbe1a9ee..022dabe3 100644 --- a/commit_check/ai_signatures.py +++ b/commit_check/ai_signatures.py @@ -74,36 +74,3 @@ def has_ai_signature(message: str) -> bool: if regex.search(message): return True return False - - -def find_co_authored_by_ai(message: str) -> list[str]: - """Find ``Co-authored-by`` trailer lines that reference known AI tools. - - Human co-authors like ``Co-authored-by: Jane Doe `` - are NOT returned because no known AI tool pattern matches common - human names. - - :returns: List of matched trailer lines (deduplicated). - """ - results: list[str] = [] - seen: set[str] = set() - co_pat = re.compile(r"^Co-authored-by:\s*", re.IGNORECASE | re.MULTILINE) - if not co_pat.search(message): - return results - - for regex, _tool_name, _desc, _kind in ALL_PATTERNS: - for match in regex.finditer(message): - matched = match.group(0).strip() - if matched.lower().startswith("co-authored-by:") and matched not in seen: - seen.add(matched) - results.append(matched) - return results - - -def find_assisted_by_trailers(message: str) -> list[str]: - """Find ``Assisted-by`` trailer lines in *message*. - - :returns: List of matched ``Assisted-by`` lines. - """ - pat = re.compile(r"^Assisted-by:\s*\S+.*$", re.MULTILINE | re.IGNORECASE) - return [m.group(0).strip() for m in pat.finditer(message)] diff --git a/commit_check/ai_signatures_data.py b/commit_check/ai_signatures_data.py index 62f9b008..2924eea9 100644 --- a/commit_check/ai_signatures_data.py +++ b/commit_check/ai_signatures_data.py @@ -216,9 +216,11 @@ def _body_marker(pattern: str, description: str = "") -> AiSignaturePattern: patterns=[ # Catch AI agent model identifiers in Co-authored-by # (e.g. claude-sonnet-4, gpt-4-turbo, gemini-1.5-pro). + # A hyphenated model suffix is required so bare human first names + # ("Claude", "Gemini") are NOT flagged, regardless of the email. _trailer( "Co-authored-by", - r"(?:claude|gpt|gemini)[\w.-]*(?:\s*<[^>]*>)?", + r"(?:claude|gpt|gemini)[\w.]*-[\w.-]+(?:\s*<[^>]*>)?", "``Co-authored-by`` with AI model name", ), # Catch Assisted-by trailer (Linux kernel style) regardless of agent, diff --git a/commit_check/engine.py b/commit_check/engine.py index d8caf189..edb42278 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -715,10 +715,8 @@ class AiAttributionValidator(BaseValidator): """Validates commit messages against AI attribution policy. Single responsibility: when configured to ``forbid``, rejects any commit - that contains known AI tool signatures. - - Style enforcement (for ``require`` mode) is delegated to - :class:`AiTrailerStyleValidator`. + that contains known AI tool signatures. When set to ``ignore`` (the + default), the check is a no-op. """ def validate(self, context: ValidationContext) -> ValidationResult: diff --git a/tests/ai_signatures_test.py b/tests/ai_signatures_test.py index 6cfd320a..4a755e25 100644 --- a/tests/ai_signatures_test.py +++ b/tests/ai_signatures_test.py @@ -4,8 +4,6 @@ from commit_check.ai_signatures import ( detect_ai_signatures, has_ai_signature, - find_co_authored_by_ai, - find_assisted_by_trailers, ALL_KNOWN_TOOLS, ALL_PATTERNS, ) @@ -219,80 +217,43 @@ def test_empty_message(self): assert has_ai_signature("") is False -class TestFindCoAuthoredByAi: - """Tests for find_co_authored_by_ai().""" - - @pytest.mark.benchmark - def test_finds_claude_co_author(self): - """Finds Co-authored-by: Claude lines.""" - message = "feat: add feature\n\nCo-authored-by: Claude " - result = find_co_authored_by_ai(message) - assert len(result) >= 1 - assert "Claude" in result[0] - - @pytest.mark.benchmark - def test_finds_copilot_co_author(self): - """Finds Co-authored-by: Copilot lines.""" - message = ( - "feat: add feature\n\n" - "Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>" - ) - result = find_co_authored_by_ai(message) - assert len(result) >= 1 - assert "Copilot" in result[0] - - @pytest.mark.benchmark - def test_no_false_positive_for_human(self): - """Human co-authors are not returned.""" - message = "feat: add feature\n\nCo-authored-by: Alice Smith " - result = find_co_authored_by_ai(message) - assert result == [] - - @pytest.mark.benchmark - def test_no_duplicates_for_overlapping_patterns(self): - """Gemini matching both specific and generic patterns returns once.""" - message = "feat: add feature\n\nCo-authored-by: gemini" - result = find_co_authored_by_ai(message) - assert len(result) == 1, f"Expected 1, got {len(result)}: {result}" - - @pytest.mark.benchmark - def test_empty_message(self): - """Empty message returns empty list.""" - assert find_co_authored_by_ai("") == [] - - -class TestFindAssistedByTrailers: - """Tests for find_assisted_by_trailers().""" - - @pytest.mark.benchmark - def test_finds_assisted_by_trailer(self): - """Finds Assisted-by: trailer.""" - message = "feat: add feature\n\nAssisted-by: Claude:claude-sonnet-4" - result = find_assisted_by_trailers(message) - assert len(result) >= 1 - assert "Claude" in result[0] - - @pytest.mark.benchmark - def test_finds_kernel_format_with_tools(self): - """Finds Assisted-by with kernel-style tool list.""" - message = ( - "feat: add feature\n\nAssisted-by: Claude:claude-3-opus coccinelle sparse" - ) - result = find_assisted_by_trailers(message) - assert len(result) >= 1 - assert "coccinelle" in result[0] - - @pytest.mark.benchmark - def test_no_false_positive(self): - """Returns empty list when no Assisted-by trailer.""" - message = "feat: add feature\n\nSigned-off-by: Alice " - result = find_assisted_by_trailers(message) - assert result == [] - - @pytest.mark.benchmark - def test_empty_message(self): - """Empty message returns empty list.""" - assert find_assisted_by_trailers("") == [] +class TestHumanNameFalsePositives: + """Human co-authors whose names overlap with AI tool/model tokens. + + The generic model-name pattern requires a hyphenated model suffix + (e.g. ``claude-sonnet-4``), so a bare human first name — even with a + personal email — must never be flagged. + """ + + @pytest.mark.benchmark + @pytest.mark.parametrize( + "trailer", + [ + "Co-authored-by: Claude ", + "Co-authored-by: Gemini Rossi ", + "Co-authored-by: gpt ", + "Co-authored-by: Claude Monet ", + ], + ) + def test_bare_human_name_not_detected(self, trailer): + """A human co-author is not treated as an AI signature.""" + message = f"feat: add feature\n\n{trailer}" + assert detect_ai_signatures(message) == [] + assert has_ai_signature(message) is False + + @pytest.mark.benchmark + @pytest.mark.parametrize( + "trailer", + [ + "Co-authored-by: claude-sonnet-4 ", + "Co-authored-by: gpt-4-turbo ", + "Co-authored-by: gemini-1.5-pro", + ], + ) + def test_model_identifier_still_detected(self, trailer): + """A hyphenated AI model identifier is still caught by Generic AI.""" + message = f"feat: add feature\n\n{trailer}" + assert has_ai_signature(message) is True class TestSignatureDatabase: