scripts.check_ai_attribution
Reference › scripts › check_ai_attribution
The AI-attribution gate, the executable mechanism for ruling RUL-007. It scans commit messages and tag messages for AI-attribution text, so that Mohamed remains the sole stated author. tests/scripts/test_check_ai_attribution.py imports it. It is meant to run from a commit-message hook, from a gate over a revision range, or by hand.
Invocation: main takes --repo-root (default the current directory) and at least one mode, otherwise exits 2. The modes are --message-file PATH (a message being committed), a revision range option or --all (existing commits), --tags (tag messages) and --stdin. It also takes a baseline file of already-known findings (--baseline, with a default location under the repo) and --json for a machine-readable report. The exit code comes from the report: findings not covered by the baseline fail the gate, and operational errors return 2.
scan_text applies the patterns to one text and returns Finding records. strip_commit_comments removes lines Git treats as comments before scanning, using the repository's comment character (_comment_char). iter_commit_messages and iter_tag_messages read history through git (_git). check_message_file, check_stdin, check_range and check_tags wrap those for each mode, and build_report and print_text_report produce the output.
At a glance
| Source | scripts/check_ai_attribution.py |
| Lines of code | 460 |
| Classes | 1 |
| Functions | 17 |
| Imports (internal) | none |
| Imported by | none |
| Tests that import it | tests/scripts/test_check_ai_attribution.py |
Class diagram
Dependency diagram
No internal imports and no importers.
API
Constants
| Name | Type | Value |
|---|---|---|
_RULES |
tuple[tuple[str, re.Pattern[str]], ...] |
(('R1', _R1), ('R2', _R2)) |
Classes
Finding (dataclass)
Decorators: dataclasses.dataclass(frozen=True) · Line 100
One violating line, located precisely enough to fix by hand.
| Attribute | Type | Default |
|---|---|---|
kind |
str |
|
ref |
str |
|
rule |
str |
|
line_no |
int |
|
line |
str |
|
match |
str |
|
baselined |
bool |
False |
baseline_reason |
str |
'' |
| Method | Signature | Summary |
|---|---|---|
as_dict |
as_dict() -> dict[str, object] |
Plain-dict form for --json output. |
render |
render() -> str |
One-line human-readable form: where it is and which rule caught it. |
Functions
| Function | Signature | Summary |
|---|---|---|
scan_text |
scan_text(text: str, *, kind: str, ref: str) -> list[Finding] |
Return every violating line in text (one finding per line). |
strip_commit_comments |
strip_commit_comments(text: str, comment_char: str = '#') -> str |
Blank out git comment lines and truncate at the verbose-diff scissors. |
load_baseline |
load_baseline(path: Path) -> dict[str, str] |
Parse <40-hex-sha> <reason> lines. |
_git |
_git(repo_root: Path, **args: str) -> str |
|
_comment_char |
_comment_char(repo_root: Path) -> str |
|
iter_commit_messages |
iter_commit_messages(repo_root: Path, rev_range: str | None) -> Iterator[tuple[str, str]] |
Yield (full_sha, message) for every commit in rev_range. |
iter_tag_messages |
iter_tag_messages(repo_root: Path) -> Iterator[tuple[str, str]] |
Yield (tag_name, message) for every ANNOTATED tag. |
check_message_file |
check_message_file(path: Path, comment_char: str = '#') -> list[Finding] |
Scan a commit-message file, minus git comments and the verbose diff. |
check_stdin |
check_stdin(text: str) -> list[Finding] |
Scan arbitrary text verbatim — PR bodies are markdown, so # is a heading, not a comment, and nothing is stripped. |
check_range |
check_range(repo_root: Path, rev_range: str | None, baseline: dict[str, str]) -> list[Finding] |
Scan commit messages in rev_range, marking baselined shas. |
check_tags |
check_tags(repo_root: Path) -> list[Finding] |
Scan annotated tag messages. |
build_report |
build_report(findings: Sequence[Finding], modes: Sequence[str]) -> dict |
Split findings into new vs baselined and derive the exit code. |
print_text_report |
print_text_report(report: dict, findings: Sequence[Finding]) -> None |
Human-readable report: every finding, then the PASS/FAIL verdict. |
_add_mode_arguments |
_add_mode_arguments(parser: argparse.ArgumentParser) -> None |
Register the four scan modes; at least one is required at runtime. |
build_parser |
build_parser() -> argparse.ArgumentParser |
The CLI surface: four scan modes plus output/scoping options. |
collect_findings |
collect_findings(args: argparse.Namespace, repo_root: Path, baseline: dict[str, str]) -> tuple[list[Finding], list[str]] |
Run every requested mode; return its findings and the mode names. |
main |
main(argv: list[str] | None = None) -> int |
Entry point: parse, scan, report, and return the gate's exit code. |
Source
#!/usr/bin/env python3
"""AI-attribution gate — the executable mechanism for ruling RUL-007.
Scans **commit / tag / PR message TEXT only** — never file contents, never the
diff. That scoping decision is what keeps the gate silent on its own source, on
``CLAUDE.md``, on the wiki ruling page that quotes the forbidden trailer
verbatim, and on the whole SPEC-021 / SPEC-025 Claude-Code-producer subsystem,
all of which legitimately contain the exact strings this gate forbids in a
message.
Modes (combinable):
``--message-file PATH``
The ``commit-msg`` hook moment: git passes ``$1``. Blocks *before* the
commit object exists, the only point where blocking costs nothing.
``--range [REV_RANGE]``
Repo-history scan for CI. With no value (or with ``--all``) every commit
reachable from every ref is scanned. Baseline-aware.
``--tags``
Annotated tag messages via ``git for-each-ref``. Strict, no baseline.
``--stdin``
Arbitrary text, e.g. ``gh pr create --body-file -``.
Exit codes: ``0`` clean (baselined findings are reported but do not fail),
``1`` at least one new violation, ``2`` usage / environment error.
"""
from __future__ import annotations
import argparse
import dataclasses
import json
import re
import subprocess
import sys
from collections.abc import Iterator, Sequence
from pathlib import Path
# ---------------------------------------------------------------------------
# The ruling this script mechanises. Kept verbatim so `scripts/rulings_lint.py`
# can prove the `gate scripts/check_ai_attribution.py RUL-007` declaration on
# the wiki page resolves to a script that actually names its rule.
# ---------------------------------------------------------------------------
RULING_TEXT = (
"RUL-007 — No AI attribution in commits, tags, or PRs. Do not add "
"`Co-Authored-By: Claude ...` (or any equivalent attribution to any AI "
"assistant) to git commits, tags, PR bodies, or any other artifact. Every "
"commit/tag carries Mohamed's identity only. Currently declared "
"`none (judgment)`; the page's own Verification section admits "
'"no mechanism currently strips a stray Co-Authored-By trailer '
'automatically." Ruling page: '
"/home/mohamed/workspace/knowledge-wiki/rulings/RUL-007-no-ai-attribution.md"
)
RULE_ID = "RUL-007"
DEFAULT_BASELINE = Path("scripts") / "rul007-baseline.txt"
# ---------------------------------------------------------------------------
# Grammar. Both families match attribution SHAPE plus an AI-identity token —
# never a bare vendor word, so a commit whose SUBJECT is Claude Code stays legal.
# ---------------------------------------------------------------------------
_AI_IDENT = (
r"\b(?:claude|anthropic|chatgpt|openai|gpt-[0-9]|copilot|gemini|codex"
r"|cursor|devin|aider|kimi|deepseek|qwen|minimax|codewhale|llm"
r"|ai assistant)"
)
_TRAILER_KEY = (
r"(?:co-authored-by|co-authored-with|assisted-by|generated-by"
r"|authored-by|claude-session|ai-assisted-by)"
)
# R1 — attribution trailer. `^[ \t]*` anchoring is load-bearing: a trailer
# quoted MID-LINE (e.g. a commit message describing this very gate) passes.
_R1 = re.compile(rf"^[ \t]*{_TRAILER_KEY}[ \t]*:.*{_AI_IDENT}", re.IGNORECASE)
# R2 — generator marker. `\bgenerated with\b` is load-bearing: the leading `e`
# of "regenerated" kills the word boundary, so "regenerated with file_meta"
# — a real commit subject in this repo — never matches.
_R2 = re.compile(
r"\U0001f916[ \t]*\bgenerated with\b"
rf"|\bgenerated with \[?{_AI_IDENT}"
r"|https?://(?:claude\.ai/code|claude\.com/claude-code)"
rf"|\bco-authored with\b[ \t]+{_AI_IDENT}",
re.IGNORECASE,
)
_RULES: tuple[tuple[str, re.Pattern[str]], ...] = (
("R1", _R1),
("R2", _R2),
)
# git's scissors marker: everything below it is the verbose diff, not the
# message. Scanning past it would smuggle file content into a message-only gate.
_SCISSORS = re.compile(r"^\s*\S?\s*-{3,}\s*>8\s*-{3,}")
@dataclasses.dataclass(frozen=True)
class Finding:
"""One violating line, located precisely enough to fix by hand."""
kind: str
ref: str
rule: str
line_no: int
line: str
match: str
baselined: bool = False
baseline_reason: str = ""
def as_dict(self) -> dict[str, object]:
"""Plain-dict form for ``--json`` output."""
return dataclasses.asdict(self)
def render(self) -> str:
"""One-line human-readable form: where it is and which rule caught it."""
head = f"{self.kind} {self.ref} L{self.line_no}: {self.line.strip()}"
tail = f" [{self.rule}]"
if self.baselined:
tail += f" BASELINED ({self.baseline_reason})"
return head + tail
# ---------------------------------------------------------------------------
# Text scanning
# ---------------------------------------------------------------------------
def scan_text(text: str, *, kind: str, ref: str) -> list[Finding]:
"""Return every violating line in ``text`` (one finding per line)."""
findings: list[Finding] = []
for line_no, line in enumerate(text.splitlines(), start=1):
for rule, pattern in _RULES:
match = pattern.search(line)
if match is not None:
findings.append(
Finding(
kind=kind,
ref=ref,
rule=rule,
line_no=line_no,
line=line.rstrip(),
match=match.group(0),
)
)
break
return findings
def strip_commit_comments(text: str, comment_char: str = "#") -> str:
"""Blank out git comment lines and truncate at the verbose-diff scissors.
Line numbering is preserved (comments become empty lines) so reported
positions still point at the right line of the on-disk message file.
"""
kept: list[str] = []
for line in text.splitlines():
if _SCISSORS.match(line):
break
if comment_char and line.lstrip().startswith(comment_char):
kept.append("")
else:
kept.append(line)
return "\n".join(kept)
# ---------------------------------------------------------------------------
# Baseline
# ---------------------------------------------------------------------------
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
def load_baseline(path: Path) -> dict[str, str]:
"""Parse ``<40-hex-sha> <reason>`` lines. Missing file => empty baseline.
Full-SHA pinning is deliberate: a baseline entry can never widen to cover a
future commit.
"""
if not path.exists():
return {}
baseline: dict[str, str] = {}
for lineno, raw in enumerate(
path.read_text(encoding="utf-8").splitlines(), start=1
):
line = raw.strip()
if not line or line.startswith("#"):
continue
sha, _, reason = line.partition(" ")
if not _FULL_SHA.match(sha):
raise ValueError(
f"{path}:{lineno}: baseline entries must start with a full "
f"40-hex commit sha, got {sha!r}"
)
baseline[sha] = reason.strip() or "(no reason recorded)"
return baseline
# ---------------------------------------------------------------------------
# git plumbing
# ---------------------------------------------------------------------------
def _git(repo_root: Path, *args: str) -> str:
proc = subprocess.run(
["git", *args],
cwd=str(repo_root),
capture_output=True,
text=True,
check=False,
)
if proc.returncode != 0:
raise RuntimeError(
f"git {' '.join(args)} failed (rc={proc.returncode}): "
f"{proc.stderr.strip()}"
)
return proc.stdout
def _comment_char(repo_root: Path) -> str:
proc = subprocess.run(
["git", "config", "--get", "core.commentChar"],
cwd=str(repo_root),
capture_output=True,
text=True,
check=False,
)
value = proc.stdout.strip()
if not value or value == "auto":
return "#"
return value
def iter_commit_messages(
repo_root: Path, rev_range: str | None
) -> Iterator[tuple[str, str]]:
"""Yield ``(full_sha, message)`` for every commit in ``rev_range``.
``rev_range is None`` means every commit reachable from every ref.
"""
args = ["log", "--no-color", "--format=%H%n%B%x00"]
args.append(rev_range if rev_range else "--all")
for record in _git(repo_root, *args).split("\0"):
record = record.lstrip("\n")
if not record.strip():
continue
sha, _, message = record.partition("\n")
yield sha.strip(), message
def iter_tag_messages(repo_root: Path) -> Iterator[tuple[str, str]]:
"""Yield ``(tag_name, message)`` for every ANNOTATED tag."""
fmt = "--format=%(objecttype)%09%(refname:short)%n%(contents)%00"
for record in _git(repo_root, "for-each-ref", fmt, "refs/tags").split("\0"):
record = record.lstrip("\n")
if not record.strip():
continue
header, _, contents = record.partition("\n")
objtype, _, name = header.partition("\t")
if objtype.strip() != "tag":
continue
yield name.strip(), contents
# ---------------------------------------------------------------------------
# Modes
# ---------------------------------------------------------------------------
def check_message_file(path: Path, comment_char: str = "#") -> list[Finding]:
"""Scan a commit-message file, minus git comments and the verbose diff."""
text = path.read_text(encoding="utf-8", errors="replace")
return scan_text(
strip_commit_comments(text, comment_char),
kind="message-file",
ref=str(path),
)
def check_stdin(text: str) -> list[Finding]:
"""Scan arbitrary text verbatim — PR bodies are markdown, so `#` is a
heading, not a comment, and nothing is stripped."""
return scan_text(text, kind="stdin", ref="-")
def check_range(
repo_root: Path, rev_range: str | None, baseline: dict[str, str]
) -> list[Finding]:
"""Scan commit messages in ``rev_range``, marking baselined shas."""
findings: list[Finding] = []
for sha, message in iter_commit_messages(repo_root, rev_range):
for finding in scan_text(message, kind="commit", ref=sha[:12]):
if sha in baseline:
finding = dataclasses.replace(
finding, baselined=True, baseline_reason=baseline[sha]
)
findings.append(finding)
return findings
def check_tags(repo_root: Path) -> list[Finding]:
"""Scan annotated tag messages. Strict: tag messages carry no baseline."""
return [
finding
for name, message in iter_tag_messages(repo_root)
for finding in scan_text(message, kind="tag", ref=name)
]
# ---------------------------------------------------------------------------
# Report
# ---------------------------------------------------------------------------
def build_report(findings: Sequence[Finding], modes: Sequence[str]) -> dict:
"""Split findings into new vs baselined and derive the exit code."""
new = [f for f in findings if not f.baselined]
baselined = [f for f in findings if f.baselined]
return {
"rule_id": RULE_ID,
"modes": list(modes),
"ok": not new,
"exit_code": 1 if new else 0,
"new_violations": [f.as_dict() for f in new],
"baselined": [f.as_dict() for f in baselined],
}
def print_text_report(report: dict, findings: Sequence[Finding]) -> None:
"""Human-readable report: every finding, then the PASS/FAIL verdict."""
modes = ", ".join(report["modes"]) or "(none)"
print(f"{RULE_ID} AI-attribution gate — modes: {modes}")
for finding in findings:
print(f" {finding.render()}")
new_count = len(report["new_violations"])
base_count = len(report["baselined"])
if new_count:
print(
f"FAIL: {new_count} AI-attribution violation(s) "
f"({base_count} baselined, not counted)."
)
print(
" Remove the attribution trailer/marker. "
"Every commit and tag carries Mohamed's identity only."
)
else:
print(f"PASS: no new AI-attribution violations ({base_count} baselined).")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _add_mode_arguments(parser: argparse.ArgumentParser) -> None:
"""Register the four scan modes; at least one is required at runtime."""
parser.add_argument(
"--message-file",
default=None,
help="Scan a commit-message file (the commit-msg hook's $1).",
)
parser.add_argument(
"--range",
dest="rev_range",
nargs="?",
const="",
default=None,
help="Scan commit messages in a rev-range (default: every ref).",
)
parser.add_argument(
"--all",
action="store_true",
help="With --range: scan every commit reachable from every ref.",
)
parser.add_argument(
"--tags", action="store_true", help="Scan annotated tag messages."
)
parser.add_argument(
"--stdin", action="store_true", help="Scan arbitrary text from stdin."
)
def build_parser() -> argparse.ArgumentParser:
"""The CLI surface: four scan modes plus output/scoping options."""
parser = argparse.ArgumentParser(
prog="check_ai_attribution.py",
description=(
f"{RULE_ID} gate — reject AI attribution in commit / tag / PR "
"message text. Scans messages only, never file contents."
),
)
_add_mode_arguments(parser)
parser.add_argument(
"--repo-root", default=None, help="Repository to scan (default: cwd)."
)
parser.add_argument(
"--baseline",
default=None,
help=f"Baseline file (default: {DEFAULT_BASELINE}).",
)
parser.add_argument("--json", action="store_true", help="Machine-readable output.")
return parser
def collect_findings(
args: argparse.Namespace, repo_root: Path, baseline: dict[str, str]
) -> tuple[list[Finding], list[str]]:
"""Run every requested mode; return its findings and the mode names."""
findings: list[Finding] = []
modes: list[str] = []
if args.message_file:
modes.append("message-file")
findings.extend(
check_message_file(Path(args.message_file), _comment_char(repo_root))
)
if args.rev_range is not None or args.all:
modes.append("range")
findings.extend(check_range(repo_root, args.rev_range or None, baseline))
if args.tags:
modes.append("tags")
findings.extend(check_tags(repo_root))
if args.stdin:
modes.append("stdin")
findings.extend(check_stdin(sys.stdin.read()))
return findings, modes
def main(argv: list[str] | None = None) -> int:
"""Entry point: parse, scan, report, and return the gate's exit code."""
args = build_parser().parse_args(argv)
repo_root = Path(args.repo_root).resolve() if args.repo_root else Path.cwd()
if not repo_root.is_dir():
print(f"ERROR: repo root does not exist: {repo_root}", file=sys.stderr)
return 2
do_range = args.rev_range is not None or args.all
if not (args.message_file or do_range or args.tags or args.stdin):
print(
"ERROR: pick at least one mode "
"(--message-file / --range / --tags / --stdin).",
file=sys.stderr,
)
return 2
baseline_path = (
Path(args.baseline) if args.baseline else repo_root / DEFAULT_BASELINE
)
try:
baseline = load_baseline(baseline_path)
except (OSError, ValueError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
try:
findings, modes = collect_findings(args, repo_root, baseline)
except (OSError, RuntimeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
report = build_report(findings, modes)
if args.json:
print(json.dumps(report, indent=2, default=str))
else:
print_text_report(report, findings)
return int(report["exit_code"])
if __name__ == "__main__":
raise SystemExit(main())