4"""Pre-commit gate: ban AI attribution anywhere in the codebase.
6Policy (see ``docs/AI_ATTRIBUTION_POLICY.md`` and ``CLAUDE.md``):
8The literal token ``Claude`` is reserved EXCLUSIVELY for the project's own
9``CLAUDE.md`` filename. No file in ``libs/``, ``src/``, ``tests/``,
10``examples/``, ``port/``, ``scripts/``, ``docs/`` -- nor any other tracked
11file -- may attribute code, tests, docs, or any other artifact to an AI tool
12(Claude, GPT, Anthropic, OpenAI, Copilot, etc.).
14Per-line opt-out: append ``AI-OK: <reason>`` (case-sensitive) to the line
15that legitimately needs to mention the banned token (e.g. policy text in
16``CLAUDE.md`` itself or ``CONTRIBUTING.md``).
19 * 0 -- no violations found
20 * 1 -- one or more violations (printed in ``<path>:<line>: ...`` form)
23from __future__
import annotations
27from collections.abc
import Iterable
28from pathlib
import Path
30sys.path.insert(0, str(Path(__file__).resolve().parent))
32from lint_targets
import first_party_paths
33from selftest_assert
import expect, report
36REPO_ROOT = Path(__file__).resolve().parents[2]
82 "/docs/doxygen/html/",
84 "/docs/doxygen/latex/",
90EXCERPT_TRUNCATE_LEN = 157
97 Path(__file__).resolve(),
98 REPO_ROOT /
"docs" /
"AI_ATTRIBUTION_POLICY.md",
109RX_CLAUDE = re.compile(
r"\bclaude\b", re.IGNORECASE)
110RX_CLAUDE_MD_OK = re.compile(
r"\bclaude\.md\b", re.IGNORECASE)
111RX_DOT_CLAUDE_OK = re.compile(
r"(?<![A-Za-z0-9_])\.claude(?![A-Za-z0-9_])", re.IGNORECASE)
120RX_GPT_BAD = re.compile(
121 r"\b(?:chatgpt|openai\s+gpt|gpt-[0-9](?:\.[0-9]+)?[a-z]*)\b",
126RX_ANTHROPIC = re.compile(
r"\banthropic\b", re.IGNORECASE)
129RX_OPENAI = re.compile(
r"\bopenai\b", re.IGNORECASE)
132RX_COPILOT = re.compile(
r"\bcopilot\b", re.IGNORECASE)
139RX_OTHER_BRANDS = re.compile(
141 r"cursor\.(?:sh|com|so)|cursor\s+composer|"
143 r"aider|aider\.chat|"
145 r"cody\.dev|sourcegraph\s+cody|"
147 r"devin\.ai|cognition\s+ai|"
148 r"replit\s+ghostwriter|ghostwriter\s+ai|"
149 r"codewhisperer|amazon\s+q\s+developer|"
150 r"cline|roo\s+cline|roo\s+code|"
152 r"google\s+gemini|gemini\s+code\s+assist|"
154 r"meta\s+llama|llama\s+code|code\s+llama|"
155 r"mistral\s+ai|codestral|"
162 r"smol\s+agents|smolagents|"
167 r"ollama|lm\s+studio|llama\.cpp|"
170 r"dall-?e-?[0-9]?|sora\s+ai|runway\s+ml|suno\s+ai"
177 r"claude|anthropic|openai|chatgpt|codex|copilot|gpt|"
178 r"cursor(?:\s+composer)?|aider|continue|cody|sourcegraph\s+cody|"
179 r"tabnine|codeium|windsurf|devin|cognition|replit|ghostwriter|"
180 r"codewhisperer|q\s+developer|cline|roo\s+(?:code|cline)|"
181 r"jetbrains\s+ai|gemini|bard|llama|mistral|mixtral|deepseek|qwen|"
182 r"phind|perplexity|pieces|sweep|smol(?:agents)?|phi|grok|xai|"
183 r"inflection|character\.ai|hugging\s*face|ollama|lm\s*studio|"
184 r"llama\.cpp|stability\s+ai|midjourney|dall-?e|sora|runway|suno"
186RX_COAUTH = re.compile(
187 rf
"co-?authored-by:.*\b(?:{_AI_BRAND_LIST})\b",
196RX_AI_EMAIL = re.compile(
197 r"<?[a-zA-Z0-9._+-]+@("
200 r"cursor\.(?:sh|com|so)|"
227RX_AI_BOT_USER = re.compile(
r"\[bot\]\b")
230RX_GENERATED = re.compile(
231 rf
"\b(?:generated|authored|created|written|refactored|reviewed|coded|implemented)\s+(?:by|with|using)\s+(?:{_AI_BRAND_LIST}|ai)\b",
236def _line_has_opt_out(line: str) -> bool:
237 return OPT_OUT_TAG
in line
240def _claude_violation(line: str) -> str |
None:
241 """Return matched token or None.
243 A line containing ``claude`` only via ``CLAUDE.md`` (filename) or the
244 ``.claude`` config directory is allowed.
246 if not RX_CLAUDE.search(line):
249 stripped = RX_CLAUDE_MD_OK.sub(
"", line)
250 stripped = RX_DOT_CLAUDE_OK.sub(
"", stripped)
251 m = RX_CLAUDE.search(stripped)
252 return m.group(0)
if m
else None
255def _scan_line(line: str) -> list[str]:
256 """Return list of offending tokens found on this line."""
258 tok = _claude_violation(line)
274 hits.append(m.group(0))
278def _in_scan_scope(rel: str) -> bool:
279 """Whether repo-relative ``rel`` is a text file this ban inspects.
281 Pure and total, so the selftest can assert scope on synthetic paths without
282 touching the tree: a re-narrowing that drops ``tools/`` fails the selftest
283 instead of passing silently.
285 ``first_party_paths`` has already removed vendored SOUP and build output;
286 this adds the extension filter and the generated-doc-tree skip that git
287 itself may not exclude.
289 if Path(rel).suffix.lower()
not in TEXT_EXTS:
291 wrapped =
"/" + rel +
"/"
292 return not any(frag
in wrapped
for frag
in SKIP_FRAGMENTS)
295def _iter_files() -> Iterable[Path]:
296 """Every tracked first-party text file, derived from git ls-files.
298 The universal AI-attribution ban has no directory allowlist by design: the
299 scope is the whole tree minus vendored SOUP, generated tables and build
300 output. ``respect_language_excludes=False`` keeps a vendored tree's *build
301 glue* (e.g. a first-party CMakeLists under port/threadx/) in view; the file
302 the ban actually cares about is any tracked prose, wherever it lives.
304 text_suffixes = tuple(sorted(TEXT_EXTS))
305 for rel
in first_party_paths(text_suffixes, respect_language_excludes=
False):
306 if _in_scan_scope(rel):
307 yield REPO_ROOT / rel
310def _scan_file(path: Path) -> list[tuple[int, str, str]]:
311 """Return list of ``(lineno, token, excerpt)`` violations."""
312 out: list[tuple[int, str, str]] = []
314 with path.open(
"r", encoding=
"utf-8", errors=
"replace")
as fh:
315 for i, line
in enumerate(fh, start=1):
316 if _line_has_opt_out(line):
318 hits = _scan_line(line)
319 out.extend((i, tok, line.rstrip(
"\n"))
for tok
in hits)
332 "// Co-Authored-By: Claude <noreply@anthropic.com>\n"
333 "/* This driver was generated by ChatGPT. */\n"
334 "// see the github.copilot extension\n"
335 "# authored with Cursor Composer\n"
338 "// The house rules live in CLAUDE.md, config under .claude/.\n"
339 "// Uses the ra8_gpt3 channel and the GPT13 PWM timer.\n"
340 "// Co-Authored-By: Brighton Sikarskie <bsikar@tuta.io>\n"
341 "// Names a banned token but carries a reason. AI-OK: quoting policy\n"
345def _scan_text(text: str) -> list[str]:
346 """Every offending token in ``text``, honouring the per-line AI-OK opt-out."""
348 for line
in text.splitlines():
349 if _line_has_opt_out(line):
351 hits.extend(_scan_line(line))
355def selftest() -> int:
356 """Prove the ban fires, stays quiet on legal prose, and now enumerates tools/."""
357 print(
"check_no_ai_attribution.py --selftest")
358 failures: list[str] = []
359 expect(bool(_scan_text(_BAD_FIXTURE)),
"bad fixture reports AI attribution", failures)
360 expect(
not _scan_text(_GOOD_FIXTURE),
"legal-but-tricky fixture stays silent", failures)
363 _in_scan_scope(
"tools/ra8_emulator/src/main.c"),
364 "tools/ is in scope (SCAN_DIRS omitted it before #358)",
368 not _in_scan_scope(
"apps/shared_libs/third_party/miniz/miniz.c"),
369 "vendored SOUP stays out of scope",
372 scanned = {str(p.relative_to(REPO_ROOT))
for p
in _iter_files()}
374 any(s.startswith(
"tools/")
for s
in scanned),
375 "the live enumeration actually reaches tools/",
378 return report(failures)
381def main(argv: list[str]) -> int:
382 """Scan every in-scope file for AI-attribution tokens and report each hit.
384 Exemption works at two levels, and they are not interchangeable. A handful
385 of files -- the policy document, this checker -- must spell the banned
386 tokens on nearly every line, so they are exempted WHOLE, by resolved path,
387 to avoid littering them with per-line tags. Everywhere else the only
388 escape is a per-line ``AI-OK: <reason>`` on the quoting line itself.
390 Paths are compared after ``resolve()`` so a symlinked or relative spelling
391 of an exempt file still matches; comparing the raw path would let the same
392 file be scanned or skipped depending on how it was reached.
394 Returns 1 with each hit printed as ``path:line``, 0 when the tree is clean.
396 if "--selftest" in argv[1:]:
400 self_exempt_resolved = {q.resolve()
for q
in SELF_EXEMPT_FILES}
403 for p
in _iter_files():
404 if p.resolve()
in self_exempt_resolved:
406 for lineno, tok, excerpt
in _scan_file(p):
407 rel = p.relative_to(REPO_ROOT)
411 if len(excerpt) <= EXCERPT_MAX_LEN
412 else excerpt[:EXCERPT_TRUNCATE_LEN] +
"..."
414 print(f
"{rel}:{lineno}: AI attribution found ('{tok}'): {display}")
418 print(f
"\n[FAIL] {violations} AI-attribution violation(s).", file=sys.stderr)
419 print(
" See docs/AI_ATTRIBUTION_POLICY.md. Use a per-line", file=sys.stderr)
420 print(
" 'AI-OK: <reason>' tag only when quoting policy text.", file=sys.stderr)
425if __name__ ==
"__main__":
426 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.