diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 99ce56f9..8bd6fa80 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -29,8 +29,8 @@ jobs: name: Run benchmarks runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.13" @@ -38,7 +38,7 @@ jobs: run: pip install -e .[test] - name: Run benchmarks - uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # v4.17.5 + uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1 with: token: ${{ secrets.CODSPEED_TOKEN }} run: pytest tests/ --codspeed diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 2f38fd5d..fdcef892 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,14 +1,15 @@ -name: PR Autolabeler - -permissions: - contents: write - pull-requests: write +name: PR Auto-labeler on: - # pull_request event is required for autolabeler + # pull_request event is required for auto-labeler pull_request: types: [opened, reopened, synchronize] +permissions: {} + jobs: draft-release: + permissions: + pull-requests: write + contents: read uses: commit-check/.github/.github/workflows/pr-labeler.yml@main diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2122b251..81e204aa 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,8 +20,8 @@ jobs: build: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.x' @@ -38,7 +38,7 @@ jobs: - name: Build wheel run: nox -s build - name: Upload wheel as artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: commit-check_wheel path: ${{ github.workspace }}/dist/*.whl @@ -48,7 +48,9 @@ jobs: - name: Collect Coverage run: nox -s coverage - - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + - name: Upload coverage to Codecov + if: github.actor != 'dependabot[bot]' + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.xml @@ -64,8 +66,8 @@ jobs: os: ['windows-latest', 'ubuntu-24.04', 'macos-latest'] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6.0.3 - - uses: actions/setup-python@v6.2.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.py }} - run: | @@ -73,7 +75,7 @@ jobs: pip install .[dev] - name: Download wheel artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: commit-check_wheel path: dist @@ -88,8 +90,8 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6.0.3 - - uses: actions/setup-python@v6.2.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: "3.10" @@ -102,7 +104,7 @@ jobs: run: nox -s docs - name: Save built docs as artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: "commit-check_docs" path: ${{ github.workspace }}/_build/html diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 17fad27b..dbc52da3 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -14,12 +14,12 @@ jobs: publish: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # use fetch --all for setuptools_scm to work with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.x' @@ -33,21 +33,20 @@ jobs: twine check dist/commit_check* - name: Create attestations - uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: "dist/commit_check*" - name: Publish package to TestPyPI if: github.event_name == 'workflow_dispatch' && github.repository == 'commit-check/commit-check' - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} - run: twine upload --repository testpypi dist/commit_check* + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + repository-url: https://test.pypi.org/legacy/ + password: ${{ secrets.TEST_PYPI_TOKEN }} continue-on-error: true - name: Publish package to PyPI if: github.event_name != 'workflow_dispatch' && github.repository == 'commit-check/commit-check' - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: twine upload dist/commit_check* + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 00000000..fdfdc8fe --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,32 @@ +name: OpenSSF Scorecard + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + analysis: + runs-on: ubuntu-24.04 + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8b83e885..2c310b62 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,5 @@ +default_stages: [pre-commit] + # https://pre-commit.ci/ ci: autofix_commit_msg: 'ci: auto fixes from pre-commit.com hooks' @@ -15,13 +17,13 @@ repos: - id: trailing-whitespace - id: name-tests-test - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.9 + rev: v0.15.19 hooks: - id: ruff-check args: [ --fix ] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.19.1 + rev: v2.1.0 hooks: - id: mypy additional_dependencies: [types-PyYAML] @@ -31,7 +33,7 @@ repos: hooks: - id: codespell - repo: https://github.com/commit-check/commit-check - rev: v2.5.0 + rev: v2.9.0 hooks: - id: check-message stages: [commit-msg] diff --git a/README.rst b/README.rst index 174f6699..8952eb53 100644 --- a/README.rst +++ b/README.rst @@ -17,6 +17,10 @@ Commit Check :target: https://codecov.io/gh/commit-check/commit-check :alt: CodeCov +.. |scorecard-badge| image:: https://api.securityscorecards.dev/projects/github.com/commit-check/commit-check/badge + :target: https://api.securityscorecards.dev/projects/github.com/commit-check/commit-check + :alt: OpenSSF Scorecard + .. |commit-check-badge| image:: https://img.shields.io/badge/commit--check-enabled-brightgreen?logo=Git&logoColor=white&color=%232c9ccd :target: https://github.com/commit-check/commit-check :alt: commit-check @@ -42,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 @@ -115,7 +119,7 @@ Use Default Configuration - **Commit Check** uses a `default configuration `_ if you do not provide a ``cchk.toml`` or ``commit-check.toml`` file. -- The default configuration is lenient — it only checks whether commit messages follow the `Conventional Commits `_ specification and branch names follow the `Conventional Branch `_ convention. +- The default configuration is lenient — it only checks whether commit messages follow the `Conventional Commits `_ specification and branch names follow the `Conventional Branch `_ convention. Use Custom Configuration File ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -135,9 +139,12 @@ 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://conventional-branch.github.io/ + # https://conventionalbranch.org conventional_branch = true allow_branch_types = ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix"] @@ -415,7 +422,7 @@ Check Branch Naming Failed Commit rejected. Type branch check failed ==> test-branch - The branch should follow Conventional Branch. See https://conventional-branch.github.io/ + The branch should follow Conventional Branch. See https://conventionalbranch.org Suggest: Use / with allowed types or add branch name to allow_branch_names in config, or use ignore_authors in config branch section to bypass More examples see `example documentation `_. diff --git a/cchk.toml b/cchk.toml index a870d711..cb2eb833 100644 --- a/cchk.toml +++ b/cchk.toml @@ -16,9 +16,7 @@ require_signed_off_by = false ignore_authors = ["dependabot[bot]", "copilot[bot]", "pre-commit-ci[bot]", "coderabbitai[bot]"] [branch] -# https://conventional-branch.github.io/ +# https://conventionalbranch.org conventional_branch = true -# Explicit list needed until AI agent prefixes are in a released version of commit-check -allow_branch_types = ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix", "ai", "claude", "codex", "copilot", "cursor"] require_rebase_target = "main" ignore_authors = ["dependabot[bot]", "copilot[bot]", "pre-commit-ci[bot]", "shenxianpeng"] diff --git a/commit_check/__init__.py b/commit_check/__init__.py index fcab5eb4..fb2cffbb 100644 --- a/commit_check/__init__.py +++ b/commit_check/__init__.py @@ -32,7 +32,7 @@ "ci", ] # Follow conventional branch (https://conventionalbranch.org/) -# Includes AI agent prefixes added in spec v1.1.0 +# Includes AI agent prefixes (spec v1.1.0) and bot prefixes DEFAULT_BRANCH_TYPES = [ "feature", "bugfix", @@ -47,6 +47,9 @@ "codex", "copilot", "cursor", + # Automation/bot prefixes + "dependabot", + "renovate", ] # Additional allowed branch names (e.g., develop, staging) DEFAULT_BRANCH_NAMES: list[str] = [] @@ -69,6 +72,8 @@ "require_signed_off_by": False, } +# AI attribution defaults +DEFAULT_AI_ATTRIBUTION = "ignore" # "ignore" | "forbid" + -CONFIG_FILE = "." # Search current directory for commit-check.toml or cchk.toml __version__ = version("commit-check") diff --git a/commit_check/ai_signatures.py b/commit_check/ai_signatures.py new file mode 100644 index 00000000..022dabe3 --- /dev/null +++ b/commit_check/ai_signatures.py @@ -0,0 +1,76 @@ +"""AI tool signature detection logic. + +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`. + +Typical usage:: + + 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 commit_check.ai_signatures_data import ALL_KNOWN_TOOLS as _ALL_KNOWN_TOOLS + +# 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 + + +#: 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]] = [ + (p.regex, tool.name, p.description, p.kind) + for tool in ALL_KNOWN_TOOLS + for p in tool.patterns +] + + +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', ...}] + """ + results: list[dict[str, str]] = [] + seen: set[str] = set() + + 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: + seen.add(matched) + results.append( + { + "tool": tool_name, + "kind": kind, + "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, _kind in ALL_PATTERNS: + if regex.search(message): + return True + return False diff --git a/commit_check/ai_signatures_data.py b/commit_check/ai_signatures_data.py new file mode 100644 index 00000000..2924eea9 --- /dev/null +++ b/commit_check/ai_signatures_data.py @@ -0,0 +1,257 @@ +"""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). + # 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.]*-[\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, +] diff --git a/commit_check/api.py b/commit_check/api.py index f7876858..0458e68b 100644 --- a/commit_check/api.py +++ b/commit_check/api.py @@ -132,6 +132,7 @@ def validate_message( "allow_empty_commits", "allow_fixup_commits", "allow_wip_commits", + "ai_attribution", ] return _run_checks(check_names, context, cfg) diff --git a/commit_check/config_merger.py b/commit_check/config_merger.py index f801869a..149e9da0 100644 --- a/commit_check/config_merger.py +++ b/commit_check/config_merger.py @@ -13,6 +13,7 @@ DEFAULT_BRANCH_NAMES, DEFAULT_BOOLEAN_RULES, DEFAULT_PUSH_RULES, + DEFAULT_AI_ATTRIBUTION, ) @@ -76,6 +77,7 @@ 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, }, "branch": { "conventional_branch": True, @@ -120,6 +122,7 @@ 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), # Branch section "CCHK_CONVENTIONAL_BRANCH": ("branch", "conventional_branch", parse_bool), "CCHK_ALLOW_BRANCH_TYPES": ("branch", "allow_branch_types", parse_list), @@ -147,6 +150,7 @@ 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"), # 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 dcb08fae..edb42278 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -7,6 +7,9 @@ from dataclasses import field from commit_check.rule_builder import ValidationRule +from commit_check.ai_signatures import ( + detect_ai_signatures, +) from commit_check.util import ( fetch_remote_ref, fetch_upstream_ref, @@ -98,6 +101,24 @@ def _should_skip_validation(self, context: ValidationContext) -> bool: and not has_commits() ) + @staticmethod + def _get_commit_message(context: ValidationContext) -> str: + """Get commit message from context or git.""" + if context.stdin_text: + return context.stdin_text.strip() + + if context.commit_file: + try: + with open(context.commit_file, "r") as f: + return f.read().strip() + except FileNotFoundError: + pass + + # Fallback to git log + subject = get_commit_info("s") + body = get_commit_info("b") + return f"{subject}\n\n{body}".strip() + def _author_in_ignore_list(self, context: ValidationContext) -> bool: """Check if the current author or any co-author is in the ignore list.""" import re @@ -206,23 +227,6 @@ def validate(self, context: ValidationContext) -> ValidationResult: self._print_failure(message) return ValidationResult.FAIL - def _get_commit_message(self, context: ValidationContext) -> str: - """Get commit message from context or git.""" - if context.stdin_text: - return context.stdin_text.strip() - - if context.commit_file: - try: - with open(context.commit_file, "r") as f: - return f.read().strip() - except FileNotFoundError: - pass - - # Fallback to git log - subject = get_commit_info("s") - body = get_commit_info("b") - return f"{subject}\n\n{body}".strip() - class SubjectValidator(BaseValidator): """Validates commit subject lines.""" @@ -439,21 +443,46 @@ def validate(self, context: ValidationContext) -> ValidationResult: return ValidationResult.FAIL def _find_target_branch(self, pattern: str) -> str | None: - """Find target branch matching the pattern.""" + """Find target branch by verifying refs directly. + + Uses ``git rev-parse --verify`` for exact ref resolution instead of + scanning ``git branch -a`` output with a regex. Strips common regex + anchors (``^``, ``$``) from the pattern to obtain a branch name, + then attempts to verify it as a local ref first, falling back to + the remote tracking ref under ``origin/``. + + :param pattern: The raw regex pattern from the rule config (e.g. + ``"^main$"`` or ``"main"``). + :returns: The resolved branch name if verified, ``None`` otherwise. + """ import subprocess - import re + # Strip common regex anchors to obtain a clean branch name + branch_name = pattern.lstrip("^").rstrip("$").strip() + if not branch_name: + return None + + # Try local branch first (refs/heads/ avoids ambiguity with tags) try: - all_branches = subprocess.check_output( - ["git", "branch", "-a"], encoding="utf-8" - ).splitlines() + subprocess.run( + ["git", "rev-parse", "--verify", f"refs/heads/{branch_name}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + return branch_name + except subprocess.CalledProcessError: + pass - for branch in all_branches: - clean_branch = ( - branch.strip().replace("* ", "").replace("remotes/origin/", "") - ) - if re.match(pattern, clean_branch): - return clean_branch + # Try remote tracking branch under origin/ + try: + subprocess.run( + ["git", "rev-parse", "--verify", f"refs/remotes/origin/{branch_name}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + return branch_name except subprocess.CalledProcessError: pass @@ -479,23 +508,6 @@ def validate(self, context: ValidationContext) -> ValidationResult: self._print_failure(message) return ValidationResult.FAIL - def _get_commit_message(self, context: ValidationContext) -> str: - """Get commit message from context or git.""" - if context.stdin_text: - return context.stdin_text.strip() - - if context.commit_file: - try: - with open(context.commit_file, "r") as f: - return f.read().strip() - except FileNotFoundError: - pass - - # Fallback to git log - subject = get_commit_info("s") - body = get_commit_info("b") - return f"{subject}\n\n{body}".strip() - class BodyValidator(BaseValidator): """Validates that commit messages contain a body when required.""" @@ -527,23 +539,6 @@ def validate(self, context: ValidationContext) -> ValidationResult: self._print_failure(message) return ValidationResult.FAIL - def _get_commit_message(self, context: ValidationContext) -> str: - """Get commit message from context or git.""" - if context.stdin_text: - return context.stdin_text.strip() - - if context.commit_file: - try: - with open(context.commit_file, "r") as f: - return f.read().strip() - except FileNotFoundError: - pass - - # Fallback to git log - subject = get_commit_info("s") - body = get_commit_info("b") - return f"{subject}\n\n{body}".strip() - class ForcePushValidator(BaseValidator): """Validates that no force push is being performed. @@ -706,25 +701,70 @@ def _is_fixup_commit_allowed(self, message: str) -> bool: def _is_wip_commit_allowed(self, message: str) -> bool: """Check if WIP commits are allowed.""" - is_wip = message.upper().startswith("WIP:") + upper_msg = message.upper() + is_wip = ( + upper_msg.startswith("WIP:") # wip: / WIP: + or upper_msg.startswith("[WIP]") # [wip] / [WIP] + or upper_msg.startswith("WIP ") # WIP at start with space + or upper_msg == "WIP" # exact WIP + ) return not is_wip or self.rule.value - def _get_commit_message(self, context: ValidationContext) -> str: - """Get commit message from context or git.""" - if context.stdin_text: - return context.stdin_text.strip() - if context.commit_file: - try: - with open(context.commit_file, "r") as f: - return f.read().strip() - except FileNotFoundError: - pass +class AiAttributionValidator(BaseValidator): + """Validates commit messages against AI attribution policy. - # Fallback to git log - subject = get_commit_info("s") - body = get_commit_info("b") - return f"{subject}\n\n{body}".strip() + Single responsibility: when configured to ``forbid``, rejects any commit + that contains known AI tool signatures. When set to ``ignore`` (the + default), the check is a no-op. + """ + + 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" | "forbid" + if policy != "forbid": + return ValidationResult.PASS + + signatures = detect_ai_signatures(message) + if not signatures: + 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="This project forbids AI-assisted commits. Remove AI trailers and re-commit.", + ) + return ValidationResult.FAIL + + 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: + # 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( + rule_dict, + value, + no_banner=self._no_banner, + compact=self._compact, + ) class ValidationEngine: @@ -749,6 +789,7 @@ class ValidationEngine: "allow_wip_commits": CommitTypeValidator, "ignore_authors": CommitTypeValidator, "no_force_push": ForcePushValidator, + "ai_attribution": AiAttributionValidator, } def __init__(self, rules: list[ValidationRule]): diff --git a/commit_check/main.py b/commit_check/main.py index 3fe46293..c5c814eb 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -293,6 +293,16 @@ 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", "forbid"], + metavar="POLICY", + help="AI attribution policy: ignore (default) or forbid. " + "'forbid' rejects commits with known AI tool signatures.", + ) + # Branch configuration options branch_group = parser.add_argument_group( "branch options", "Configuration options for --branch validation" @@ -429,6 +439,7 @@ def _get_requested_checks(args: argparse.Namespace) -> list[str]: "allow_empty_commits", "allow_fixup_commits", "allow_wip_commits", + "ai_attribution", ] ) if args.branch: diff --git a/commit_check/rule_builder.py b/commit_check/rule_builder.py index 506502ac..cc768a00 100644 --- a/commit_check/rule_builder.py +++ b/commit_check/rule_builder.py @@ -15,6 +15,7 @@ DEFAULT_BRANCH_NAMES, DEFAULT_BOOLEAN_RULES, DEFAULT_PUSH_RULES, + DEFAULT_AI_ATTRIBUTION, ) @@ -138,6 +139,8 @@ 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 == "merge_base": return self._build_merge_base_rule(catalog_entry) else: @@ -248,6 +251,25 @@ 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. + + Only active when policy is ``"forbid"`` — rejects any commit with + known AI tool signatures. + """ + policy = self.commit_config.get("ai_attribution", DEFAULT_AI_ATTRIBUTION) + if policy != "forbid": + return None + + return ValidationRule( + check=catalog_entry.check, + value=policy, + 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: @@ -295,7 +317,7 @@ def _get_allowed_branch_names(self) -> list[str]: def _build_conventional_commit_regex(self, allowed_types: list[str]) -> str: """Build regex for conventional commit messages.""" types_pattern = "|".join(sorted(set(allowed_types))) - return rf"^({types_pattern})(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)" + return rf"^({types_pattern})(\([\w\-\.]+\))?(!)?: [^\n]+([\s\S]*)|(Merge).*|(fixup!.*)" def _build_conventional_branch_regex( self, allowed_types: list[str], allowed_names: list[str] diff --git a/commit_check/rules_catalog.py b/commit_check/rules_catalog.py index 0c1b8f50..108b4134 100644 --- a/commit_check/rules_catalog.py +++ b/commit_check/rules_catalog.py @@ -104,6 +104,12 @@ 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", + suggest="This project forbids AI-assisted commits. Remove AI trailers and re-commit.", + ), ] # Push rules @@ -121,7 +127,7 @@ class RuleCatalogEntry: RuleCatalogEntry( check="branch", regex=None, # Built dynamically from config - error="The branch should follow Conventional Branch. See https://conventional-branch.github.io/", + error="The branch should follow Conventional Branch. See https://conventionalbranch.org", suggest="Use / with allowed types or add branch name to allow_branch_names in config, or use ignore_authors in config branch section to bypass", ), RuleCatalogEntry( diff --git a/commit_check/util.py b/commit_check/util.py index 2354554a..2d9c9703 100644 --- a/commit_check/util.py +++ b/commit_check/util.py @@ -8,21 +8,8 @@ from __future__ import annotations import os import subprocess -import yaml -from pathlib import Path, PurePath -from typing import Any from subprocess import CalledProcessError from commit_check import RED, GREEN, YELLOW, RESET_COLOR -from commit_check.rule_builder import RuleBuilder - -# Prefer stdlib tomllib (3.11+); fall back to tomli if available; else disabled -try: # pragma: no cover - import paths differ by Python version - import tomllib as _toml # type: ignore[attr-defined] -except Exception: # pragma: no cover - try: - import tomli as _toml # type: ignore[no-redef] - except Exception: # pragma: no cover - _toml = None # type: ignore[assignment] def _find_check(checks: list, check_type: str) -> dict | None: @@ -276,70 +263,6 @@ def cmd_output(commands: list) -> str: return "" -def _load_toml(path: PurePath) -> dict[str, Any]: - """Load TOML from file, tolerant if toml support missing.""" - if _toml is None: - return {} - try: - with open(path, "rb") as f: - return _toml.load(f) # type: ignore[call-arg] - except FileNotFoundError: - return {} - except Exception: - return {} - - -def _find_config_file(path_hint: str) -> PurePath | None: - """Resolve config file. - - - If a directory is passed, search in priority: cchk.toml, commit-check.toml, .github/cchk.toml, .github/commit-check.toml - - If a file ending with .toml is passed, use it if exists. - - Ignore legacy .commit-check.yml entirely. - """ - p = Path(path_hint) - if p.is_dir(): - for name in ( - "cchk.toml", - "commit-check.toml", - ".github/cchk.toml", - ".github/commit-check.toml", - ): - candidate = p / name - if candidate.exists(): - return candidate - return None - # If explicit file path provided - if str(p).endswith((".toml",)) and p.exists(): - return p - return None - - -def validate_config(path_hint: str) -> dict[str, Any]: - """Validate and load configuration from TOML. - - Returns a dict containing a 'checks' list or empty dict if not found/invalid. - """ - cfg_path = _find_config_file(path_hint) - if cfg_path: - raw = _load_toml(cfg_path) - if not raw: - return {} - # Use new rule builder system - rule_builder = RuleBuilder(raw) - rules = rule_builder.build_all_rules() - return {"checks": [rule.to_dict() for rule in rules]} - - # Legacy YAML fallback (maintained for test compatibility) - try: - with open(PurePath(path_hint)) as f: - data = yaml.safe_load(f) # type: ignore[no-redef] - return data or {} - except FileNotFoundError: - return {} - except Exception: - return {} - - def track_print_call(func): def wrapper(*args, **kwargs): wrapper.has_been_called = True diff --git a/docs/configuration.rst b/docs/configuration.rst index 355ec865..1a4416d2 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -126,13 +126,14 @@ 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 allow_force_push = true # Set to false to block force pushes [branch] - # https://conventional-branch.github.io/ + # https://conventionalbranch.org conventional_branch = true allow_branch_types = ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix"] # allow_branch_names = [] # Optional - additional standalone branch names (e.g., ["develop", "staging"]) @@ -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 @@ -407,7 +416,7 @@ Options Table Description - allow_branch_types - list[str] - ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix"] - - Allowed branch types when conventional_branch is true. + - Allowed branch types when conventional_branch is true. AI agent prefixes (``ai/``, ``claude/``, ``codex/``, ``copilot/``, ``cursor/``) and bot prefixes (``dependabot/``) are also included by default. * - branch - allow_branch_names - list[str] diff --git a/docs/demo.gif b/docs/demo.gif index cbcf1a7a..09de3032 100644 Binary files a/docs/demo.gif and b/docs/demo.gif differ diff --git a/docs/demo.tape b/docs/demo.tape index 5fc8fae5..e2e2fd84 100644 --- a/docs/demo.tape +++ b/docs/demo.tape @@ -1,41 +1,39 @@ -# demo.tape — Generate demo.gif with: brew install vhs && vhs demo.tape +# demo.tape — Generate demo.gif with: brew install vhs && vhs docs/demo.tape Output demo.gif -Set Shell "fish" -Set FontSize 14 -Set Width 900 -Set Height 500 +Set Shell "zsh" + +Set FontSize 16 +Set Width 1000 +Set Height 550 Set Theme "Dracula" -Set Padding 20 -Set TypingSpeed 60ms +Set Padding 24 +Set TypingSpeed 50ms Set PlaybackSpeed 1.0 -# --- Scene 1: Show the tool --- -Type "commit-check --version" -Enter -Sleep 1s - -# --- Scene 2: Invalid commit message (triggers error) --- +# --- Scene 1: Invalid commit message --- Type "echo 'add user login' | commit-check -m" Enter Sleep 2s -# --- Scene 3: Valid commit message --- +# --- Scene 2: Valid commit message --- Type "echo 'feat: add user login' | commit-check -m" Enter -Sleep 1.5s +Sleep 2s -# --- Scene 4: Invalid branch name check --- -Type "git checkout -b user-login -q 2>/dev/null; commit-check -b" +# --- Scene 4: Invalid branch name --- +Type "git checkout -b user-login" +Enter +Sleep 2s +Type "commit-check -b" Enter Sleep 2s # --- Scene 5: Valid branch name --- -Type "git checkout -b feature/user-login -q 2>/dev/null; commit-check -b" +Type "git checkout -b feature/user-login" Enter Sleep 2s - -# --- End --- -Type "For more details, please checkout https://commit-check.github.io" +Type "commit-check -b" +Enter Sleep 2s diff --git a/docs/example.rst b/docs/example.rst index 72c7de54..4ac73653 100644 --- a/docs/example.rst +++ b/docs/example.rst @@ -288,7 +288,7 @@ Error Output Examples Type branch check failed ==> test-branch It doesn't match regex: ^(feature|bugfix|hotfix|release|chore|feat|fix)\/.+|(master)|(main)|(HEAD)|(PR-.+) - The branch should follow Conventional Branch. See https://conventional-branch.github.io/ + The branch should follow Conventional Branch. See https://conventionalbranch.org Suggest: Use / with allowed types or ignore_authors in config branch section to bypass **Commit Signature Validation Failure:** diff --git a/docs/migration.rst b/docs/migration.rst index 99e05fdc..c0118de1 100644 --- a/docs/migration.rst +++ b/docs/migration.rst @@ -116,7 +116,7 @@ YAML (v1.x) vs TOML (v2.0+) ignore_authors = ["dependabot[bot]", "copilot[bot]"] [branch] - # https://conventional-branch.github.io/ + # https://conventionalbranch.org conventional_branch = true allow_branch_types = ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix"] require_rebase_target = "main" diff --git a/docs/what-is-new.rst b/docs/what-is-new.rst index a94421be..d9db1cbb 100644 --- a/docs/what-is-new.rst +++ b/docs/what-is-new.rst @@ -3,6 +3,70 @@ What's New This document highlights the major changes and improvements in each version of commit-check. +Version 2.11.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.10.0 — Bot Branch Types as Default +--------------------------------------------- + +``dependabot/`` and ``renovate/`` branches now pass by default +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``dependabot`` and ``renovate`` are now included in ``DEFAULT_BRANCH_TYPES``, +so branches like ``dependabot/go_modules/go-deps-c57c3fe1e0`` and +``renovate/lodash-5.x`` are automatically accepted without manual +``allow_branch_types`` configuration. + +Version 2.9.0 — AI Agent Branch Prefixes +---------------------------------------- + +Conventional Branch v1.1.0 AI agent prefixes supported by default +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``ai/``, ``claude/``, ``codex/``, ``copilot/``, and ``cursor/`` have been +added to ``DEFAULT_BRANCH_TYPES`` as defined in `Conventional Branch +v1.1.0 `_. Branches created by AI +coding agents are now valid out of the box without extra configuration. + Version 2.7.0 — Force Push Blocking ----------------------------------- @@ -234,7 +298,7 @@ Standardize branch naming with conventional patterns. conventional_branch = true allow_branch_types = ["bugfix", "feature", "release", "hotfix", "task", "chore"] -**Benefits**: Built-in `Conventional Branch `_ support, automatic handling of special branches (main, master, HEAD, PR-\*). +**Benefits**: Built-in `Conventional Branch `_ support, automatic handling of special branches (main, master, HEAD, PR-\*). Author Validation ^^^^^^^^^^^^^^^^^ diff --git a/pyproject.toml b/pyproject.toml index 89d33444..97ec3bcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", - "Topic :: Utilities" , + "Topic :: Utilities", "Topic :: Software Development :: Build Tools", "Topic :: Software Development :: Libraries :: Python Modules", ] @@ -38,7 +38,8 @@ commit-check = "commit_check.main:main" cchk = "commit_check.main:main" # short alias without clobbering system cc [project.urls] -source = "https://github.com/commit-check/commit-check" +documentation = "https://commit-check.github.io/commit-check/" +source = "https://github.com/commit-check/commit-check" tracker = "https://github.com/commit-check/commit-check/issues" # ... other project metadata fields as specified in: diff --git a/tests/ai_signatures_test.py b/tests/ai_signatures_test.py new file mode 100644 index 00000000..4a755e25 --- /dev/null +++ b/tests/ai_signatures_test.py @@ -0,0 +1,308 @@ +"""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, + 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_with_noreply_email(self): + """Co-authored-by: Claude with anthropic noreply 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_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_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 + 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 <175728472+Copilot@users.noreply.github.com>" + ) + 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.""" + 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) + # 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: sess_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_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.""" + 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, kind in ALL_PATTERNS: + assert regex is not None, f"{tool_name}: {desc} has None regex" + 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: + """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 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: + """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, kind 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", + "Assisted-by: Claude:claude-3-opus coccinelle sparse", + "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 + + @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 024b2b28..7cea27dd 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -1,5 +1,6 @@ """Tests for commit_check.engine module.""" +import subprocess import pytest import tempfile import os @@ -20,6 +21,7 @@ BodyValidator, MergeBaseValidator, ForcePushValidator, + AiAttributionValidator, ) from commit_check.rule_builder import ValidationRule @@ -307,6 +309,45 @@ def test_branch_validator_staging_branch_allowed( result = validator.validate(context) assert result == ValidationResult.PASS + @patch("commit_check.engine.has_commits") + @patch("commit_check.engine.get_branch_name") + @pytest.mark.benchmark + def test_branch_validator_dependabot_branch_allowed( + self, mock_get_branch_name, mock_has_commits + ): + """Test BranchValidator with dependabot branch (default type).""" + mock_has_commits.return_value = True + mock_get_branch_name.return_value = "dependabot/go_modules/go-deps-c57c3fe1e0" + # Regex pattern that includes dependabot as a type prefix + rule = ValidationRule( + check="branch", + regex=r"^(feature|bugfix|hotfix|dependabot)\/.+", + ) + validator = BranchValidator(rule) + config = {"branch": {"ignore_authors": []}} + context = ValidationContext(config=config) + result = validator.validate(context) + assert result == ValidationResult.PASS + + @patch("commit_check.engine.has_commits") + @patch("commit_check.engine.get_branch_name") + @pytest.mark.benchmark + def test_branch_validator_renovate_branch_allowed( + self, mock_get_branch_name, mock_has_commits + ): + """Test BranchValidator with renovate branch (default type).""" + mock_has_commits.return_value = True + mock_get_branch_name.return_value = "renovate/lodash-5.x" + rule = ValidationRule( + check="branch", + regex=r"^(feature|bugfix|hotfix|dependabot|renovate)\/.+", + ) + validator = BranchValidator(rule) + config = {"branch": {"ignore_authors": []}} + context = ValidationContext(config=config) + result = validator.validate(context) + assert result == ValidationResult.PASS + @patch("commit_check.engine.has_commits") @patch("commit_check.engine.get_branch_name") @pytest.mark.benchmark @@ -803,6 +844,70 @@ def test_validate_with_merge_base_skip_conditions(self): result = validator.validate(context) assert result == ValidationResult.PASS # Skipped + # ------------------------------------------------------------------ # + # _find_target_branch —— unit tests for the new impl + # ------------------------------------------------------------------ # + + @patch("subprocess.run") + def test_find_target_branch_local_found(self, mock_run): + """Local branch exists: returns the stripped branch name.""" + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0) + validator = MergeBaseValidator(ValidationRule(check="merge_base")) + result = validator._find_target_branch("^main$") + assert result == "main" + # First call: local branch verification + assert mock_run.call_args_list[0][0][0][:4] == [ + "git", + "rev-parse", + "--verify", + "refs/heads/main", + ] + + @patch("subprocess.run") + def test_find_target_branch_local_missing_remote_found(self, mock_run): + """Local missing, remote tracking exists: returns the stripped branch name.""" + mock_run.side_effect = [ + subprocess.CalledProcessError(1, []), # local fails + subprocess.CompletedProcess(args=[], returncode=0), # remote succeeds + ] + validator = MergeBaseValidator(ValidationRule(check="merge_base")) + result = validator._find_target_branch("develop") + assert result == "develop" + assert mock_run.call_args_list[1][0][0][:5] == [ + "git", + "rev-parse", + "--verify", + "refs/remotes/origin/develop", + ] + + @patch("subprocess.run") + def test_find_target_branch_not_found(self, mock_run): + """Neither local nor remote exists: returns None.""" + mock_run.side_effect = [ + subprocess.CalledProcessError(1, []), + subprocess.CalledProcessError(1, []), + ] + validator = MergeBaseValidator(ValidationRule(check="merge_base")) + result = validator._find_target_branch("nonexistent-branch") + assert result is None + + @patch("subprocess.run") + def test_find_target_branch_empty_pattern(self, mock_run): + """Empty or anchor-only pattern: returns None without calling subprocess.""" + validator = MergeBaseValidator(ValidationRule(check="merge_base")) + result = validator._find_target_branch("") + assert result is None + mock_run.assert_not_called() + + @patch("subprocess.run") + def test_find_target_branch_plain_name(self, mock_run): + """Plain branch name (no regex anchors) works correctly.""" + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0) + validator = MergeBaseValidator(ValidationRule(check="merge_base")) + result = validator._find_target_branch("main") + assert result == "main" + assert mock_run.call_args_list[0][0][0][3] == "refs/heads/main" + class TestValidationEngine: @pytest.mark.benchmark @@ -912,6 +1017,7 @@ def test_validation_engine_validator_map(self): "allow_fixup_commits": CommitTypeValidator, "allow_wip_commits": CommitTypeValidator, "ignore_authors": CommitTypeValidator, + "ai_attribution": AiAttributionValidator, } for check, validator_class in expected_mappings.items(): @@ -1506,3 +1612,93 @@ 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_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 + + result = validator.validate(context) + assert result == ValidationResult.PASS diff --git a/tests/rule_builder_test.py b/tests/rule_builder_test.py index 2892de1c..c4f4763f 100644 --- a/tests/rule_builder_test.py +++ b/tests/rule_builder_test.py @@ -216,8 +216,8 @@ def test_rule_builder_allow_branch_names_empty_list(self): assert "(PR-.+)" in rule.regex @pytest.mark.benchmark - def test_ai_agent_branch_types_in_default(self): - """AI agent prefixes from conventional branch spec v1.1.0 are valid by default.""" + def test_ai_agent_and_bot_branch_types_in_default(self): + """AI agent and bot prefixes are valid by default.""" import re from commit_check import DEFAULT_BRANCH_TYPES @@ -226,6 +226,8 @@ def test_ai_agent_branch_types_in_default(self): assert "codex" in DEFAULT_BRANCH_TYPES assert "copilot" in DEFAULT_BRANCH_TYPES assert "cursor" in DEFAULT_BRANCH_TYPES + assert "dependabot" in DEFAULT_BRANCH_TYPES + assert "renovate" in DEFAULT_BRANCH_TYPES config = {"branch": {"conventional_branch": True}} builder = RuleBuilder(config) @@ -233,15 +235,22 @@ def test_ai_agent_branch_types_in_default(self): rule = builder._build_conventional_branch_rule(catalog_entry) assert rule is not None - ai_agent_branches = [ + valid_branches = [ + # AI agent branches "ai/refactor-auth-flow", "claude/stoic-hypatia-v65p1f", "claude/fix-login-bug", "codex/optimize-query", "copilot/add-login-page", "cursor/fix-header-bug", + # Bot/automation branches + "dependabot/go_modules/go-deps-c57c3fe1e0", + "dependabot/npm_and_yarn/lodash-4.17.21", + "dependabot/pip/certifi-2022.12.7", + "renovate/lodash-5.x", + "renovate/major-lodash-5.x", ] - for branch in ai_agent_branches: + for branch in valid_branches: assert re.match(rule.regex, branch), f"Branch '{branch}' should be valid" @pytest.mark.benchmark @@ -363,3 +372,54 @@ 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" + + @pytest.mark.benchmark + def test_ai_attribution_require_returns_none(self): + """ai_attribution='require' returns None (only forbid supported).""" + config = {"commit": {"ai_attribution": "require"}} + builder = RuleBuilder(config) + entry = RuleCatalogEntry(check="ai_attribution") + rule = builder._build_ai_attribution_rule(entry) + assert rule is None + + @pytest.mark.benchmark + 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.startswith("ai_")] + assert len(ai_rules) == 1 + assert ai_rules[0].check == "ai_attribution" + + @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.startswith("ai_")] + assert len(ai_rules) == 0 diff --git a/tests/util_test.py b/tests/util_test.py index 8579fdf4..7b2bbf0f 100644 --- a/tests/util_test.py +++ b/tests/util_test.py @@ -1,8 +1,5 @@ import pytest import subprocess -import tempfile -import os -from pathlib import Path, PurePath from commit_check.util import ( fetch_remote_ref, fetch_upstream_ref, @@ -19,12 +16,9 @@ print_error_message, print_suggestion, _find_check, - _load_toml, - _find_config_file, - validate_config, ) from subprocess import CalledProcessError, PIPE -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock # String constants used across tests REFS_HEADS_MAIN = "refs/heads/main" @@ -647,212 +641,6 @@ def test_find_check_empty_list(self): result = _find_check(checks, "commit-message") assert result is None - def test_load_toml_file_not_found(self): - """Test _load_toml with non-existent file.""" - result = _load_toml(PurePath("/nonexistent/path/config.toml")) - assert result == {} - - def test_load_toml_invalid_toml(self): - """Test _load_toml with invalid TOML content.""" - with tempfile.NamedTemporaryFile( - mode="w", suffix=".toml", delete=False - ) as f: - f.write("invalid toml { content") - temp_path = f.name - - try: - result = _load_toml(PurePath(temp_path)) - assert result == {} - finally: - os.unlink(temp_path) - - def test_load_toml_valid(self): - """Test _load_toml with valid TOML content.""" - with tempfile.NamedTemporaryFile( - mode="w", suffix=".toml", delete=False - ) as f: - f.write('[checks]\ncommit_message = { pattern = ".*" }\n') - temp_path = f.name - - try: - result = _load_toml(PurePath(temp_path)) - assert isinstance(result, dict) - assert "checks" in result - finally: - os.unlink(temp_path) - - def test_find_config_file_directory_commit_check_toml(self): - """Test _find_config_file finds commit-check.toml in directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - config_file = Path(tmpdir) / "commit-check.toml" - config_file.write_text("[checks]") - - result = _find_config_file(tmpdir) - assert result == config_file - - def test_find_config_file_directory_cchk_toml(self): - """Test _find_config_file finds cchk.toml in directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - config_file = Path(tmpdir) / "cchk.toml" - config_file.write_text("[checks]") - - result = _find_config_file(tmpdir) - assert result == config_file - - def test_find_config_file_directory_priority(self): - """Test _find_config_file prefers cchk.toml over commit-check.toml.""" - with tempfile.TemporaryDirectory() as tmpdir: - config1 = Path(tmpdir) / "cchk.toml" - config2 = Path(tmpdir) / "commit-check.toml" - config1.write_text("[checks]") - config2.write_text("[checks]") - - result = _find_config_file(tmpdir) - assert result == config1 - - def test_find_config_file_github_directory_cchk_toml(self): - """Test _find_config_file finds .github/cchk.toml in directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - github_dir = Path(tmpdir) / ".github" - github_dir.mkdir() - config_file = github_dir / "cchk.toml" - config_file.write_text("[checks]") - - result = _find_config_file(tmpdir) - assert result == config_file - - def test_find_config_file_github_directory_commit_check_toml(self): - """Test _find_config_file finds .github/commit-check.toml in directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - github_dir = Path(tmpdir) / ".github" - github_dir.mkdir() - config_file = github_dir / "commit-check.toml" - config_file.write_text("[checks]") - - result = _find_config_file(tmpdir) - assert result == config_file - - def test_find_config_file_priority_root_over_github(self): - """Test _find_config_file prefers root configs over .github configs.""" - with tempfile.TemporaryDirectory() as tmpdir: - # Create both root and .github configs - root_config = Path(tmpdir) / "cchk.toml" - root_config.write_text("[checks]") - - github_dir = Path(tmpdir) / ".github" - github_dir.mkdir() - github_config = github_dir / "cchk.toml" - github_config.write_text("[checks]") - - result = _find_config_file(tmpdir) - # Should prefer root over .github - assert result == root_config - - def test_find_config_file_directory_no_config(self): - """Test _find_config_file returns None when no config found in directory.""" - with tempfile.TemporaryDirectory() as tmpdir: - result = _find_config_file(tmpdir) - assert result is None - - def test_find_config_file_explicit_toml_exists(self): - """Test _find_config_file with explicit .toml file path.""" - with tempfile.NamedTemporaryFile( - mode="w", suffix=".toml", delete=False - ) as f: - f.write("[checks]") - temp_path = f.name - - try: - result = _find_config_file(temp_path) - assert result == Path(temp_path) - finally: - os.unlink(temp_path) - - def test_find_config_file_explicit_toml_not_exists(self): - """Test _find_config_file with non-existent .toml file path.""" - result = _find_config_file("/nonexistent/config.toml") - assert result is None - - def test_find_config_file_non_toml_file(self): - """Test _find_config_file with non-.toml file.""" - with tempfile.NamedTemporaryFile( - mode="w", suffix=".yml", delete=False - ) as f: - f.write("checks:") - temp_path = f.name - - try: - result = _find_config_file(temp_path) - assert result is None - finally: - os.unlink(temp_path) - - def test_validate_config_with_toml(self): - """Test validate_config loads and validates TOML config.""" - with tempfile.TemporaryDirectory() as tmpdir: - config_file = Path(tmpdir) / "commit-check.toml" - config_file.write_text(""" -[checks.commit_message] -pattern = "^(feat|fix|docs|style|refactor|test|chore).*" -""") - - result = validate_config(tmpdir) - assert "checks" in result - assert isinstance(result["checks"], list) - - def test_validate_config_yaml_fallback(self): - """Test validate_config falls back to YAML when TOML not found.""" - with tempfile.NamedTemporaryFile( - mode="w", suffix=".yml", delete=False - ) as f: - f.write(""" -checks: - - check: commit-message - regex: ".*" -""") - temp_path = f.name - - try: - result = validate_config(temp_path) - assert isinstance(result, dict) - finally: - os.unlink(temp_path) - - def test_validate_config_yaml_not_found(self): - """Test validate_config returns empty dict when YAML not found.""" - result = validate_config("/nonexistent/config.yml") - assert result == {} - - def test_validate_config_yaml_invalid(self): - """Test validate_config handles invalid YAML.""" - with tempfile.NamedTemporaryFile( - mode="w", suffix=".yml", delete=False - ) as f: - f.write("invalid: yaml: : content:") - temp_path = f.name - - try: - result = validate_config(temp_path) - # Should return empty dict on error - assert isinstance(result, dict) - finally: - os.unlink(temp_path) - - def test_validate_config_empty_toml(self): - """Test validate_config with empty TOML file.""" - with tempfile.TemporaryDirectory() as tmpdir: - config_file = Path(tmpdir) / "commit-check.toml" - config_file.write_text("") - - result = validate_config(tmpdir) - assert result == {} - - @patch("commit_check.util._toml", None) - def test_load_toml_when_toml_not_available(self): - """Test _load_toml returns empty dict when toml library not available.""" - result = _load_toml(PurePath("/some/path/config.toml")) - assert result == {} - class TestGetGitConfigValue: """Tests for get_git_config_value utility function."""