ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_no_ai_attribution.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Pre-commit gate: ban AI attribution anywhere in the codebase.
5
6Policy (see ``docs/AI_ATTRIBUTION_POLICY.md`` and ``CLAUDE.md``):
7
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.).
13
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``).
17
18Exit status:
19 * 0 -- no violations found
20 * 1 -- one or more violations (printed in ``<path>:<line>: ...`` form)
21"""
22
23from __future__ import annotations
24
25import re
26import sys
27from collections.abc import Iterable
28from pathlib import Path
29
30sys.path.insert(0, str(Path(__file__).resolve().parent))
31
32from lint_targets import first_party_paths
33from selftest_assert import expect, report
34
35# Repo root = two parents up from this file (scripts/checks/<this>).
36REPO_ROOT = Path(__file__).resolve().parents[2]
37
38# Scope is DERIVED, never a hardcoded directory list. The previous revision
39# named ``SCAN_DIRS = (libs, src, tests, examples, port, scripts, docs)`` and so
40# silently exempted every other tracked file -- tools/ (ra8_emulator, mdl,
41# ra8_viewer, ra8_fmt), .github/, .devcontainer/, cmake/ and the whole
42# repo root -- from a ban CLAUDE.md and docs/AI_ATTRIBUTION_POLICY.md apply to
43# "any tracked file" (#358). Enumeration now comes from git ls-files via
44# first_party_paths, so a new top-level directory is policed the day it lands.
45
46# File extensions we inspect. Anything else (binaries, PDFs) is skipped.
47TEXT_EXTS = {
48 ".c",
49 ".h",
50 ".cpp",
51 ".hpp",
52 ".cc",
53 ".md",
54 ".txt",
55 ".tsv",
56 ".csv",
57 ".rst",
58 ".py",
59 ".sh",
60 ".bash",
61 ".cmake",
62 ".yml",
63 ".yaml",
64 ".json",
65 ".toml",
66 ".cfg",
67 ".conf",
68 ".ini",
69 ".tex",
70 ".css",
71 ".html",
72 ".xml",
73 ".ld",
74 ".s",
75 ".S",
76}
77
78# Always-skip path fragments.
79SKIP_FRAGMENTS = (
80 "/third_party/",
81 "/.git/",
82 "/docs/doxygen/html/",
83 "/docs/doxygen/xml/",
84 "/docs/doxygen/latex/",
85)
86
87OPT_OUT_TAG = "AI-OK:"
88
89EXCERPT_MAX_LEN = 160 # Maximum characters shown from a violating line before truncation.
90EXCERPT_TRUNCATE_LEN = 157 # Length of truncated body (leaves room for "...").
91
92# Self-exempt files that legitimately spell the banned tokens.
93# This checker is derived from __file__, not spelled out: a hand-written
94# "scripts/<dir>/check_no_ai_attribution.py" stops matching the moment the file
95# moves, and the gate then reports its own rule table as a violation.
96SELF_EXEMPT_FILES = {
97 Path(__file__).resolve(),
98 REPO_ROOT / "docs" / "AI_ATTRIBUTION_POLICY.md",
99}
100
101# ---------------------------------------------------------------------------
102# Detection rules
103# ---------------------------------------------------------------------------
104
105# 1. ``claude`` -- case-insensitive word match, BUT allow:
106# - the literal filename ``CLAUDE.md`` (any case)
107# - paths that include ``CLAUDE.md``
108# - the ``.claude`` config directory (e.g. ``.claude``, ``./.claude/``)
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)
112
113# 2. ``gpt`` -- only flag suspicious AI-style usages. Renesas RA8D2 has
114# a GPT (general PWM timer) peripheral with channels ``GPT0``..``GPT13``
115# plus PDG sub-channels ``GPT320``..``GPT323``, so bare ``GPT``,
116# ``GPTn`` and ``ra8_gpt*`` are all legitimate hardware references.
117# Flag only the OpenAI product family (``ChatGPT``, ``GPT-3``,
118# ``GPT-4``, ``GPT-3.5``, ``GPT-4o``, ``OpenAI GPT``). Keep the dash
119# requirement -- ``GPT3`` without a separator is the Renesas channel.
120RX_GPT_BAD = re.compile(
121 r"\b(?:chatgpt|openai\s+gpt|gpt-[0-9](?:\.[0-9]+)?[a-z]*)\b",
122 re.IGNORECASE,
123)
124
125# 3. ``anthropic`` -- always banned (no legitimate use).
126RX_ANTHROPIC = re.compile(r"\banthropic\b", re.IGNORECASE)
127
128# 4. ``openai`` -- always banned.
129RX_OPENAI = re.compile(r"\bopenai\b", re.IGNORECASE)
130
131# 5. ``copilot`` -- always banned.
132RX_COPILOT = re.compile(r"\bcopilot\b", re.IGNORECASE)
133
134# 5b. Other AI-coding-tool brands -- always banned.
135# Tightened to require word boundaries OR explicit domain to avoid
136# unrelated false positives (e.g. ``cursor`` as a database term in
137# SQL docs, ``cody`` as a person's name, ``continue`` as a C
138# keyword).
139RX_OTHER_BRANDS = re.compile(
140 r"\b("
141 r"cursor\.(?:sh|com|so)|cursor\s+composer|"
142 r"codeium|windsurf|"
143 r"aider|aider\.chat|"
144 r"continue\.dev|"
145 r"cody\.dev|sourcegraph\s+cody|"
146 r"tabnine|"
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|"
151 r"jetbrains\s+ai|"
152 r"google\s+gemini|gemini\s+code\s+assist|"
153 r"google\s+bard|"
154 r"meta\s+llama|llama\s+code|code\s+llama|"
155 r"mistral\s+ai|codestral|"
156 r"deepseek\s+coder|"
157 r"qwen\s+coder|"
158 r"phind\.com|"
159 r"perplexity\.ai|"
160 r"pieces\.app|"
161 r"sweep\.dev|"
162 r"smol\s+agents|smolagents|"
163 r"phi-[0-9]|"
164 r"x\.ai|grok\s+ai|"
165 r"character\.ai|"
166 r"hugging\s*face|"
167 r"ollama|lm\s+studio|llama\.cpp|"
168 r"stability\s+ai|"
169 r"midjourney|"
170 r"dall-?e-?[0-9]?|sora\s+ai|runway\s+ml|suno\s+ai"
171 r")\b",
172 re.IGNORECASE,
173)
174
175# 6. ``Co-Authored-By: <ai-name>`` lines. Brand list.
176_AI_BRAND_LIST = (
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"
185)
186RX_COAUTH = re.compile(
187 rf"co-?authored-by:.*\b(?:{_AI_BRAND_LIST})\b",
188 re.IGNORECASE,
189)
190
191# 6b. AI-bot email-address domains and usernames. Matches both
192# ``Author: ... <foo@anthropic.com>`` and bare email mentions in
193# prose / commit bodies. The ``[bot]`` user-suffix is the GitHub
194# convention for app-installed bots (``copilot[bot]``, etc.) and is
195# a strong signal that the commit was authored by an AI assistant.
196RX_AI_EMAIL = re.compile(
197 r"<?[a-zA-Z0-9._+-]+@("
198 r"anthropic\.com|"
199 r"openai\.com|"
200 r"cursor\.(?:sh|com|so)|"
201 r"codeium\.com|"
202 r"tabnine\.com|"
203 r"aider\.chat|"
204 r"continue\.dev|"
205 r"sourcegraph\.com|"
206 r"perplexity\.ai|"
207 r"mistral\.ai|"
208 r"huggingface\.co|"
209 r"deepseek\.com|"
210 r"x\.ai|"
211 r"character\.ai|"
212 r"stability\.ai|"
213 r"midjourney\.com|"
214 r"runwayml\.com|"
215 r"suno\.ai|"
216 r"replit\.com|"
217 r"jetbrains-ai\.com"
218 r")>?",
219 re.IGNORECASE,
220)
221# Match ANY ``[bot]`` suffix. The GitHub convention is that app-
222# installed accounts append ``[bot]`` to their login name; legitimate
223# CI bots (``github-actions[bot]``, ``dependabot[bot]``) and AI bots
224# (``claude[bot]``, ``copilot[bot]``, ``cursor[bot]``) both use this
225# suffix. Project policy: zero bot authorship. Per-line ``AI-OK:``
226# opt-out is the escape hatch when a bot commit is genuinely desired.
227RX_AI_BOT_USER = re.compile(r"\‍[bot\‍]\b")
228
229# 7. ``<verb> by/with/using <AI>`` and similar.
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",
232 re.IGNORECASE,
233)
234
235
236def _line_has_opt_out(line: str) -> bool:
237 return OPT_OUT_TAG in line
238
239
240def _claude_violation(line: str) -> str | None:
241 """Return matched token or None.
242
243 A line containing ``claude`` only via ``CLAUDE.md`` (filename) or the
244 ``.claude`` config directory is allowed.
245 """
246 if not RX_CLAUDE.search(line):
247 return None
248 # Strip the allowed forms and re-check.
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
253
254
255def _scan_line(line: str) -> list[str]:
256 """Return list of offending tokens found on this line."""
257 hits: list[str] = []
258 tok = _claude_violation(line)
259 if tok:
260 hits.append(tok)
261 for rx in (
262 RX_GPT_BAD,
263 RX_ANTHROPIC,
264 RX_OPENAI,
265 RX_COPILOT,
266 RX_OTHER_BRANDS,
267 RX_AI_EMAIL,
268 RX_AI_BOT_USER,
269 RX_COAUTH,
270 RX_GENERATED,
271 ):
272 m = rx.search(line)
273 if m:
274 hits.append(m.group(0))
275 return hits
276
277
278def _in_scan_scope(rel: str) -> bool:
279 """Whether repo-relative ``rel`` is a text file this ban inspects.
280
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.
284
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.
288 """
289 if Path(rel).suffix.lower() not in TEXT_EXTS:
290 return False
291 wrapped = "/" + rel + "/"
292 return not any(frag in wrapped for frag in SKIP_FRAGMENTS)
293
294
295def _iter_files() -> Iterable[Path]:
296 """Every tracked first-party text file, derived from git ls-files.
297
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.
303 """
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
308
309
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]] = []
313 try:
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):
317 continue
318 hits = _scan_line(line)
319 out.extend((i, tok, line.rstrip("\n")) for tok in hits)
320 except OSError:
321 return []
322 return out
323
324
325# ---------------------------------------------------------------------------
326# Selftest -- both directions, plus a scope assertion under the root that was
327# invisible until #358. The dominant defect in this tree is a gate that looks
328# active and enforces nothing, so a scope that quietly re-narrows to omit
329# tools/ must fail here rather than pass green.
330# ---------------------------------------------------------------------------
331_BAD_FIXTURE = (
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"
336)
337_GOOD_FIXTURE = (
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"
342)
343
344
345def _scan_text(text: str) -> list[str]:
346 """Every offending token in ``text``, honouring the per-line AI-OK opt-out."""
347 hits: list[str] = []
348 for line in text.splitlines():
349 if _line_has_opt_out(line):
350 continue
351 hits.extend(_scan_line(line))
352 return hits
353
354
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)
361
362 expect(
363 _in_scan_scope("tools/ra8_emulator/src/main.c"),
364 "tools/ is in scope (SCAN_DIRS omitted it before #358)",
365 failures,
366 )
367 expect(
368 not _in_scan_scope("apps/shared_libs/third_party/miniz/miniz.c"),
369 "vendored SOUP stays out of scope",
370 failures,
371 )
372 scanned = {str(p.relative_to(REPO_ROOT)) for p in _iter_files()}
373 expect(
374 any(s.startswith("tools/") for s in scanned),
375 "the live enumeration actually reaches tools/",
376 failures,
377 )
378 return report(failures)
379
380
381def main(argv: list[str]) -> int:
382 """Scan every in-scope file for AI-attribution tokens and report each hit.
383
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.
389
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.
393
394 Returns 1 with each hit printed as ``path:line``, 0 when the tree is clean.
395 """
396 if "--selftest" in argv[1:]:
397 return selftest()
398 # Self-exempt files legitimately spell the banned tokens; they are
399 # matched explicitly so every internal line need not carry AI-OK.
400 self_exempt_resolved = {q.resolve() for q in SELF_EXEMPT_FILES}
401
402 violations = 0
403 for p in _iter_files():
404 if p.resolve() in self_exempt_resolved:
405 continue
406 for lineno, tok, excerpt in _scan_file(p):
407 rel = p.relative_to(REPO_ROOT)
408 # Trim long excerpts.
409 display = (
410 excerpt
411 if len(excerpt) <= EXCERPT_MAX_LEN
412 else excerpt[:EXCERPT_TRUNCATE_LEN] + "..."
413 )
414 print(f"{rel}:{lineno}: AI attribution found ('{tok}'): {display}")
415 violations += 1
416
417 if violations:
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)
421 return 1
422 return 0
423
424
425if __name__ == "__main__":
426 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298