diff --git a/README.md b/README.md
index 4b1e606..0f198ed 100644
--- a/README.md
+++ b/README.md
@@ -129,8 +129,9 @@ click away:
> Show all 4 checks
>
> ```text
-> Commit message
+> Pull request
> ✔ PR title (feat: add login page)
+> Commit message
> ✔ Commit 1/2 (d87faca) (feat: add login page)
> ✖ Commit 2/2 (5584f46) (1 failure)
> CC001 message
@@ -172,7 +173,7 @@ names the line to remove:
> | PR description | `Claude Code` | [CC013 ai-attribution](https://commit-check.com/rules/#cc013) |
>
> ```text
-> PR description
+> Pull request
> ✖ PR description (1 failure)
> CC013 ai-attribution
> value: Claude Code
diff --git a/commit-check.toml b/commit-check.toml
index e49cac4..3e5f77c 100644
--- a/commit-check.toml
+++ b/commit-check.toml
@@ -2,6 +2,11 @@
inherit_from = "github:commit-check/.github:commit-check.toml"
+[commit]
+# Exercises pr-body-ai-attribution in the self-test: under the default
+# "ignore" the PR description check runs no rule.
+ai_attribution = "forbid"
+
[branch]
require_rebase_target = "origin/main"
allow_branch_names = ["create-pull-request/.+"]
diff --git a/main.py b/main.py
index 4c49559..13c45c2 100755
--- a/main.py
+++ b/main.py
@@ -432,6 +432,35 @@ def skipped_author_scope(flag: str) -> ScopeResult:
)
+#: Why an AI-only scope ran no rule: under the default policy there is none to run.
+AI_POLICY_IGNORED = 'ai_attribution is "ignore": set it to "forbid" or "disclose"'
+
+
+def skip_when_no_ai_rule_ran(scope: ScopeResult) -> ScopeResult:
+ """Report an AI-only scope that ran no rule as skipped, not passed.
+
+ Under the default ``ai_attribution = "ignore"`` the CLI builds none of
+ the AI rules, so it returns an empty list of checks. Shown as a pass, that
+ read as "checked, nothing found" to someone who turned the input on and
+ forgot the policy.
+ """
+ if scope.checks or scope.raw_text:
+ return scope
+ scope.checks = [
+ {
+ "rule_id": "CC013",
+ "check": "ai_attribution",
+ "status": "skip",
+ "value": "",
+ "error": "",
+ "suggest": "",
+ "docs_url": "",
+ "reason": AI_POLICY_IGNORED,
+ }
+ ]
+ return scope
+
+
def get_pr_title() -> str | None:
"""Read PR title from GitHub event payload."""
if not is_pr_event():
@@ -856,7 +885,7 @@ def run_commit_check() -> tuple[int, list[ScopeResult]]:
)
if PR_BODY_AI_ATTRIBUTION_ENABLED:
name_the_line_changes(scope, pr_body)
- results.append(scope)
+ results.append(skip_when_no_ai_rule_ran(scope))
else:
PR_BODY_AI_TOOLS[:] = scope.ai_tools
@@ -924,7 +953,9 @@ def _rule_markdown_link(check: dict[str, str]) -> str:
def _scope_group(label: str) -> str:
"""Group name for a scope label, used to fold the step log output."""
- if label == "PR title" or label.startswith("Commit"):
+ if label in ("PR title", "PR description"):
+ return "Pull request"
+ if label.startswith("Commit"):
return "Commit message"
if label.startswith("Author"):
return "Author"
diff --git a/main_test.py b/main_test.py
index ad060cb..5ddc8a3 100644
--- a/main_test.py
+++ b/main_test.py
@@ -1735,11 +1735,22 @@ def test_groups_scopes_by_category(self):
fail_scope("Branch"),
]
output = self._run(results)
- # One group for commit-message scopes, one for the branch scope.
- self.assertEqual(output.count("::group::"), 2)
+ # One group each for the pull request, the commits and the branch.
+ self.assertEqual(output.count("::group::"), 3)
+ self.assertIn("::group::Pull request", output)
self.assertIn("::group::Commit message", output)
self.assertIn("::group::Branch", output)
+ def test_title_and_description_share_one_group(self):
+ """The commits come after both, so the commit group is not split."""
+ results = [
+ pass_scope("PR title"),
+ pass_scope("PR description"),
+ pass_scope("Commit 1/1"),
+ ]
+ groups = [name for name, _ in main._grouped(results)]
+ self.assertEqual(groups, ["Pull request", "Commit message"])
+
def test_raw_text_fallback_is_printed(self):
scope = main.ScopeResult(label="Branch", raw_text="unexpected output")
output = self._run([scope])
@@ -1801,8 +1812,9 @@ def test_success_golden_output(self):
"Show all 4 checks\n"
"\n"
"```text\n"
- "Commit message\n"
+ "Pull request\n"
" ✔ PR title (feat: add login page)\n"
+ "Commit message\n"
" ✔ Commit 1/2 (feat: add user auth)\n"
" ✔ Commit 2/2 (fix: resolve timeout)\n"
"Branch\n"
@@ -1839,8 +1851,9 @@ def test_failure_golden_output(self):
"Show all 3 checks\n"
"\n"
"```text\n"
- "Commit message\n"
+ "Pull request\n"
" ✔ PR title (feat: add login page)\n"
+ "Commit message\n"
" ✖ Commit 2/2 (1 failure)\n"
" CC001 message\n"
" value: bad message\n"
@@ -1890,8 +1903,9 @@ def test_failure_golden_output_with_commits(self):
"Show all 5 checks\n"
"\n"
"```text\n"
- "Commit message\n"
+ "Pull request\n"
" ✔ PR title (feat: Add login page)\n"
+ "Commit message\n"
" ✖ Commit 1/3 (d87faca) (1 failure)\n"
" CC001 message\n"
" value: bad message\n"
@@ -2950,8 +2964,9 @@ def test_all_skipped_golden_output(self):
"Show all 2 checks\n"
"\n"
"```text\n"
- "Commit message\n"
+ "Pull request\n"
" ⊘ PR title (skipped)\n"
+ "Commit message\n"
" ⊘ Commit 1/1 (skipped)\n"
"```\n"
"\n"
@@ -3198,7 +3213,7 @@ def test_warning_only_golden_output(self):
"Show all 2 checks\n"
"\n"
"```text\n"
- "Commit message\n"
+ "Pull request\n"
" ✔ PR title (feat: add login page)\n"
"Branch\n"
" ⚠ Branch (1 warning)\n"
@@ -3720,6 +3735,21 @@ def test_pr_body_is_a_scope_checked_with_the_ai_rules_only(self):
)
self.assertEqual([r.label for r in results], ["PR description"])
+ def test_no_ai_rule_ran_reads_as_skipped_not_passed(self):
+ """Under ai_attribution = "ignore" the CLI runs no rule on the description."""
+ results, _ = self._run(True, "false")
+ self.assertEqual(results[0].status, "skip")
+ self.assertEqual(results[0].skip_reason, main.AI_POLICY_IGNORED)
+
+ def test_a_rule_that_ran_keeps_its_verdict(self):
+ checks = [{"rule_id": "CC013", "check": "ai_attribution", "status": "pass"}]
+ scope = main.ScopeResult(label="PR description", checks=list(checks))
+ self.assertEqual(main.skip_when_no_ai_rule_ran(scope).checks, checks)
+
+ def test_unparsable_output_still_fails(self):
+ scope = main.ScopeResult(label="PR description", raw_text="Error: bad config")
+ self.assertEqual(main.skip_when_no_ai_rule_ran(scope).status, "fail")
+
def test_ai_label_alone_reads_it_but_reports_no_scope(self):
results, mock_scope = self._run(False, "true")
mock_scope.assert_called_once()