4"""Gate: one canonical copyright + SPDX attribution, same place in every file.
6Two forms, because this tree has two comment conventions and the attribution
7belongs where each convention already puts its metadata.
9C family (a Doxygen ``@file`` block)
10------------------------------------
11The attribution lives INSIDE the file-header block, as its closing tag group,
12never in a separate comment above it::
19 * @author Brighton Sikarskie <- kept when present
20 * @date 2026-04-29 <- kept when present
21 * @copyright Copyright (c) 2026 Brighton Sikarskie
22 * SPDX-License-Identifier: MIT
23 * @since 0.1.0 <- kept when present
26``@copyright`` immediately followed by the SPDX line is the invariant this
27gate enforces, placed as the closing group of the block (before ``@since``
28when the file carries one). ``@author`` / ``@date`` / ``@since`` are PRESERVED
29exactly as written and are never invented: 2190 of the 2297 C-family files
30have never carried ``@author`` or ``@date``, and manufacturing them would be
31fabricated provenance, not a header standard.
33A standalone ``/* SPDX ... */`` block above the ``@file`` block is a
34violation, and ``--fix`` merges it back into the block rather than leaving the
35attribution split across two comments.
37Linker scripts use a plain (non-Doxygen) leading block, so they carry the bare
38``Copyright`` line followed by the SPDX line at the end of that block.
40Hash-comment files (shell, python, cmake, just, yaml)
41-----------------------------------------------------
42No doc-comment convention to live inside, so the attribution leads the file,
43immediately after the shebang when there is one::
46 # SPDX-License-Identifier: MIT
47 # Copyright (c) 2026 Brighton Sikarskie
49Security-pinned shell and Python entry points put their exact
50``SHEBANG-SECURITY`` rationale immediately after this pair. The licence pair
51does not move: the combined preamble is shebang, SPDX, copyright, rationale.
53Scope is derived from ``lint_targets`` (``git ls-files``), so a new top-level
54directory is covered the day it lands. Vendored SOUP, generated tables and
55build output are out of scope; ``.md`` is documentation, not code.
59 check-copyright.py FILE [FILE ...] # named files (pre-commit hook)
60 check-copyright.py --all # every first-party file (gate)
61 check-copyright.py --fix --all # rewrite headers to canonical form
62 check-copyright.py --selftest # prove the rules fire and stay quiet
64Exit 0 clean, 1 on a violation or failing selftest, 2 on a collapsed scan.
67from __future__
import annotations
70from pathlib
import Path
72sys.path.insert(0, str(Path(__file__).resolve().parent))
74from lint_targets
import files_for, is_build_output_path
76REPO_ROOT = Path(__file__).resolve().parents[2]
82COPY_TEXT =
"Copyright (c) 2026 Brighton Sikarskie"
83COPY_TAG = f
"@copyright {COPY_TEXT}"
84SPDX_TEXT =
"SPDX-License-Identifier: MIT"
99ENFORCED_LANGS = tuple(LANG_STYLE)
114 ".cmake": STYLE_HASH,
121 "CMakeLists.txt": STYLE_HASH,
122 "justfile": STYLE_HASH,
123 "Justfile": STYLE_HASH,
126_GENERATED = (
"font_fixture.h",
"fixture_ahem.h",
"epub_fixture.h")
136def _hashless(line: str) -> str:
137 return line.lstrip().removeprefix(
"#").strip()
140def _starless(line: str) -> str:
142 for lead
in (
"/**",
"/*",
"*/",
"*"):
143 if body.startswith(lead):
144 body = body[len(lead) :]
146 return body.removesuffix(
"*/").strip()
149def _block_span(lines: list[str], *, doxygen: bool) -> tuple[int, int] |
None:
150 """Span of the file-header comment block, or None.
152 ``doxygen`` selects the first ``/**`` block -- the ``@file`` header -- and
153 is what the C family uses. A one-line ``/* SPDX ... */`` above that block
154 is NOT the header: treating it as one is what once inserted the tag pair
155 outside any comment and left dangling ``* @copyright`` lines at file
156 scope. Linker scripts have no Doxygen block, so they take the first
157 MULTI-line ``/*`` block instead, single-line comments skipped for the same
161 for i, ln
in enumerate(lines):
162 stripped = ln.strip()
165 if stripped.startswith(
"/**"):
167 if "*/" in stripped[3:]:
170 if stripped.startswith(
"/*"):
171 if "*/" in stripped[2:]:
182_GROUP_PREFIXES = (
"@author",
"@date",
"@since")
185def _group_indices(body: list[str], want_copy: str) -> list[int]:
186 """Indices of the attribution tags present in a header block.
189 body: Comment-stripped lines of the file-header block.
190 want_copy: The copyright spelling this comment style expects.
193 Sorted indices of every attribution tag found, possibly empty.
197 for i, b
in enumerate(body)
198 if b.startswith(_GROUP_PREFIXES)
or b
in {want_copy, COPY_TEXT, COPY_TAG, SPDX_TEXT}
202def _group_blank_split(body: list[str], want_copy: str) -> int |
None:
203 """Index of the first blank line splitting the attribution group, or None.
205 The group runs from the first attribution tag through the last, and must
206 read as one unbroken stanza. A blank line BEFORE the group -- the one that
207 separates it from the ``@details`` prose above -- is deliberately allowed;
208 only a break INSIDE it is a finding.
211 body: Comment-stripped lines of the file-header block.
212 want_copy: The copyright spelling this comment style expects.
215 The offending index, or None when the group is contiguous.
217 idxs = _group_indices(body, want_copy)
220 return next((i
for i
in range(idxs[0], idxs[-1])
if not body[i]),
None)
223def _attribution_outside(lines: list[str], span: tuple[int, int]) -> list[int]:
224 """Indices of attribution lines living OUTSIDE the header block.
226 Covers both rejected shapes: the standalone ``/* SPDX ... */`` block V1
227 added above the ``@file`` block, and the pair of one-line ``/* ... */``
228 comments some headers carried above it.
232 for i, ln
in enumerate(lines):
233 if start <= i <= end:
236 if body
in {SPDX_TEXT, COPY_TEXT, COPY_TAG}:
246def classify(lines: list[str], style: str) -> str |
None:
247 """Judge a file's header, returning a violation reason or None."""
248 stripped = [ln.rstrip(
"\r\n")
for ln
in lines]
249 if style == STYLE_HASH:
250 return _classify_hash(stripped)
251 return _classify_block(stripped, style)
254def _classify_hash(lines: list[str]) -> str |
None:
255 idx = 1
if lines
and lines[0].startswith(
"#!")
else 0
256 want = [f
"# {SPDX_TEXT}", f
"# {COPY_TEXT}"]
257 if lines[idx : idx + 2] == want:
259 joined =
"\n".join(lines[:60])
260 if SPDX_TEXT
not in joined
and COPY_TEXT
not in joined:
261 return "missing the SPDX + copyright preamble entirely"
262 if SPDX_TEXT
not in joined:
263 return "missing the SPDX-License-Identifier line"
264 if COPY_TEXT
not in joined:
265 return "missing the copyright line"
267 f
"preamble is not the canonical leading pair (expected '# {SPDX_TEXT}' "
268 f
"then '# {COPY_TEXT}' at line {idx + 1})"
272def _classify_block(lines: list[str], style: str) -> str |
None:
273 doxygen = style == STYLE_DOXY
274 span = _block_span(lines, doxygen=doxygen)
277 "no @file Doxygen block to carry the attribution"
279 else "no leading comment block to carry the attribution"
281 if _attribution_outside(lines, span):
283 "attribution sits in a comment outside the file-header block; it "
284 "belongs INSIDE that block as its closing tag group"
287 want_copy = COPY_TAG
if doxygen
else COPY_TEXT
288 return _classify_group([_starless(x)
for x
in lines[start : end + 1]], want_copy, doxygen)
291def _classify_group(body: list[str], want_copy: str, doxygen: bool) -> str |
None:
292 """Judge the attribution tag group inside a file-header block.
295 body: Comment-stripped lines of the block.
296 want_copy: The copyright spelling this comment style expects.
297 doxygen: Whether the block is a Doxygen ``@file`` header.
300 A short reason when the group is wrong, else None.
303 for i, b
in enumerate(body):
308 if ci
is None and si
is None:
309 return "the file-header block carries no copyright or SPDX line"
311 kind =
"@copyright" if doxygen
else "copyright"
312 return f
"the file-header block has no {kind} line"
314 return "the file-header block has no SPDX-License-Identifier line"
317 f
"copyright and SPDX are not adjacent in the file-header block "
318 f
"(copyright at block line {ci + 1}, SPDX at {si + 1})"
320 blank = _group_blank_split(body, want_copy)
321 if blank
is not None:
323 f
"a blank line at block line {blank + 1} splits the attribution "
324 "group; @author / @date / @copyright / SPDX / @since must be "
335def _rewrite(text: str, style: str) -> str |
None:
336 lines = text.splitlines()
337 if classify(lines, style)
is None:
339 fixed = _rewrite_hash(lines)
if style == STYLE_HASH
else _rewrite_block(lines, style)
342 trailing =
"\n" if text.endswith(
"\n")
else ""
343 return "\n".join(fixed) + trailing
346def _rewrite_hash(lines: list[str]) -> list[str]:
349 if lines
and lines[0].startswith(
"#!"):
350 shebang, body = lines[0], lines[1:]
355 if in_region
and (s ==
"" or s.startswith(
"#")):
356 if s.startswith(
"#")
and _hashless(line)
in {SPDX_TEXT, COPY_TEXT}:
362 head = [shebang]
if shebang
else []
363 return [*head, f
"# {SPDX_TEXT}", f
"# {COPY_TEXT}", *kept]
366def _rewrite_block(lines: list[str], style: str) -> list[str] |
None:
367 """Merge the attribution INTO the file-header block, in canonical order."""
368 doxygen = style == STYLE_DOXY
369 span = _block_span(lines, doxygen=doxygen)
372 want_copy = COPY_TAG
if doxygen
else COPY_TEXT
378 drop = set(_attribution_outside(lines, span))
380 for i
in range(start + 1, end):
381 if _starless(lines[i])
in {want_copy, COPY_TEXT, COPY_TAG, SPDX_TEXT}:
383 kept = [ln
for i, ln
in enumerate(lines)
if i
not in drop]
384 span = _block_span(kept, doxygen=doxygen)
388 pair = [f
" * {want_copy}", f
" * {SPDX_TEXT}"]
391 for i
in range(start + 1, end):
392 if _starless(kept[i]).startswith(
"@since"):
395 merged = kept[:insert_at] + pair + kept[insert_at:]
396 return _close_group_gaps(merged, want_copy, doxygen)
399def _close_group_gaps(lines: list[str], want_copy: str, doxygen: bool) -> list[str]:
400 """Delete blank comment lines that split the attribution group.
402 The group must read as one unbroken stanza. Removing the pair and
403 re-inserting it can strand the blank line that used to sit between the
404 pair and ``@since``, so this runs as the last step of every repair.
407 lines: The file's lines after the pair has been placed.
408 want_copy: The copyright spelling this comment style expects.
409 doxygen: Whether the header block is a Doxygen ``@file`` block.
412 The lines with any intra-group blank comment lines removed.
414 span = _block_span(lines, doxygen=doxygen)
418 body = [_starless(x)
for x
in lines[start : end + 1]]
419 idxs = _group_indices(body, want_copy)
422 drop = {start + i
for i
in range(idxs[0], idxs[-1])
if not body[i]}
428 while cursor > 0
and not body[cursor]:
431 drop.update(start + i
for i
in above[1:])
432 return [ln
for i, ln
in enumerate(lines)
if i
not in drop]
440def _style_for(path: Path) -> str |
None:
441 if path.name
in _BASENAME_STYLE:
442 return _BASENAME_STYLE[path.name]
443 return _SUFFIX_STYLE.get(path.suffix.lower())
446def _is_generated(rel: str) -> bool:
447 return rel.endswith(_GENERATED)
450def enumerate_all() -> list[tuple[str, str]]:
451 """Every first-party file this gate judges, as ``(path, style)`` pairs."""
452 out: list[tuple[str, str]] = []
453 for lang, rels
in files_for(ENFORCED_LANGS).items():
454 style = LANG_STYLE[lang]
455 out.extend((rel, style)
for rel
in rels
if not _is_generated(rel))
459def _check_one(rel: str, style: str) -> str |
None:
460 path = REPO_ROOT / rel
if not Path(rel).is_absolute()
else Path(rel)
462 lines = path.read_text(encoding=
"utf-8", errors=
"replace").splitlines()
463 except OSError
as exc:
464 return f
"unreadable: {exc}"
465 return classify(lines, style)
477 " * @author Brighton Sikarskie",
478 " * @date 2026-04-29",
484_GOOD_DOXY_MINIMAL = [
"/**",
" * @file x.c", f
" * {COPY_TAG}", f
" * {SPDX_TEXT}",
" */"]
485_GOOD_PLAIN = [
"/*",
" * linker script", f
" * {COPY_TEXT}", f
" * {SPDX_TEXT}",
" */"]
486_GOOD_HASH = [
"#!/usr/bin/env bash", f
"# {SPDX_TEXT}", f
"# {COPY_TEXT}",
"#",
"# body"]
487_GOOD_HASH_NOSB = [f
"# {SPDX_TEXT}", f
"# {COPY_TEXT}",
"",
"x = 1"]
489MUST_STAY_QUIET: tuple[tuple[str, str, list[str]], ...] = (
490 (
"doxy full tag group", STYLE_DOXY, _GOOD_DOXY),
491 (
"doxy minimal pair", STYLE_DOXY, _GOOD_DOXY_MINIMAL),
492 (
"plain linker block", STYLE_PLAIN, _GOOD_PLAIN),
493 (
"hash with shebang", STYLE_HASH, _GOOD_HASH),
495 "hash with privileged rationale",
501 "# SHEBANG-SECURITY: exact reviewed startup boundary.",
504 (
"hash without shebang", STYLE_HASH, _GOOD_HASH_NOSB),
507MUST_FIRE: tuple[tuple[str, str, list[str]], ...] = (
509 "V1 standalone block above @file",
511 [
"/*", f
" * {SPDX_TEXT}", f
" * {COPY_TEXT}",
" */",
"/**",
" * @file x.c",
" */"],
517 "one-line attribution comments above @file",
520 f
"/* {SPDX_TEXT} */",
521 f
"/* {COPY_TEXT} */",
528 "doxy reversed pair",
530 [
"/**",
" * @file x.c", f
" * {SPDX_TEXT}", f
" * {COPY_TAG}",
" */"],
535 [
"/**",
" * @file x.c", f
" * {COPY_TAG}",
" * @since 0.1.0", f
" * {SPDX_TEXT}",
" */"],
540 "blank line between the pair and @since",
553 "blank line between @date and the pair",
558 " * @author Brighton Sikarskie",
559 " * @date 2026-04-29",
567 (
"doxy missing spdx", STYLE_DOXY, [
"/**",
" * @file x.c", f
" * {COPY_TAG}",
" */"]),
568 (
"doxy missing both", STYLE_DOXY, [
"/**",
" * @file x.c",
" * @brief y",
" */"]),
569 (
"plain missing spdx", STYLE_PLAIN, [
"/*",
" * ld", f
" * {COPY_TEXT}",
" */"]),
573 [
"#!/usr/bin/env bash", f
"# {COPY_TEXT}", f
"# {SPDX_TEXT}"],
578 [
"#!/usr/bin/env bash",
"# prose", f
"# {SPDX_TEXT}", f
"# {COPY_TEXT}"],
581 "security rationale before hash attribution",
585 "# SHEBANG-SECURITY: exact reviewed startup boundary.",
590 (
"hash missing both", STYLE_HASH, [
"#!/usr/bin/env bash",
"# just prose",
"echo hi"]),
594def _selftest_fix() -> list[str]:
596 for label, style, lines
in MUST_FIRE:
597 text =
"\n".join(lines) +
"\n"
598 fixed = _rewrite(text, style)
600 out.append(f
" fix: {label} was already canonical (unexpected)")
602 if classify(fixed.splitlines(), style)
is not None:
603 out.append(f
" fix: {label} still non-canonical:\n{fixed}")
605 if fixed.count(SPDX_TEXT) != 1
or fixed.count(COPY_TEXT) != 1:
606 out.append(f
" fix: {label} left a duplicate:\n{fixed}")
608 if _rewrite(fixed, style)
is not None:
609 out.append(f
" fix: {label} is not idempotent")
615 " * @author Someone Else",
616 " * @date 2020-01-02",
623 fixed = _rewrite(src +
"\n", STYLE_DOXY)
or ""
625 f
" fix: dropped '{needle}' -- provenance must be preserved"
626 for needle
in (
"@author Someone Else",
"@date 2020-01-02",
"@since 0.1.0")
627 if needle
not in fixed
629 if f
"{COPY_TAG}\n * {SPDX_TEXT}" not in fixed:
630 out.append(f
" fix: pair not adjacent in order after repair:\n{fixed}")
634def selftest() -> int:
635 """Prove every wrong shape fires and every canonical shape stays silent."""
637 f
" must-stay-quiet: {label} rejected ({classify(lines, style)})"
638 for label, style, lines
in MUST_STAY_QUIET
639 if classify(lines, style)
is not None
642 f
" must-fire: {label} accepted as canonical"
643 for label, style, lines
in MUST_FIRE
644 if classify(lines, style)
is None
646 failures += _selftest_fix()
648 sys.stderr.write(
"check-copyright.py --selftest: FAILED\n\n")
649 sys.stderr.write(
"\n".join(failures) +
"\n")
651 total = len(MUST_FIRE) + len(MUST_STAY_QUIET)
653 f
"check-copyright.py --selftest: OK ({total} cases: {len(MUST_FIRE)} must fire, "
654 f
"{len(MUST_STAY_QUIET)} must stay quiet; fixer merges, preserves "
655 "@author/@date/@since, and is idempotent)."
665def _process(targets: list[tuple[str, str]], fix: bool) -> int:
668 for rel, style
in targets:
669 path = REPO_ROOT / rel
if not Path(rel).is_absolute()
else Path(rel)
671 text = path.read_text(encoding=
"utf-8", errors=
"replace")
674 fixed = _rewrite(text, style)
675 if fixed
is not None and fixed != text:
676 path.write_text(fixed, encoding=
"utf-8")
678 print(f
"check-copyright.py --fix: {changed} file(s) rewritten.")
680 failures = [(rel, r)
for rel, style
in targets
if (r := _check_one(rel, style))
is not None]
682 print(f
"check-copyright.py: {len(targets)} file(s) scanned, all headers canonical.")
684 sys.stderr.write(f
"check-copyright.py: {len(failures)} file(s) with a non-canonical header:\n")
685 for rel, reason
in sorted(failures):
686 sys.stderr.write(f
" {rel}: {reason}\n")
688 "\nC family: the attribution lives INSIDE the @file block as its closing\n"
689 f
"group -- '{COPY_TAG}' then '{SPDX_TEXT}'.\n"
690 f
"Hash files: '# {SPDX_TEXT}' then '# {COPY_TEXT}' after any shebang.\n"
691 " Run: python3 scripts/checks/check-copyright.py --fix --all\n"
696def main(argv: list[str]) -> int:
697 """Dispatch on the flags described in the module docstring."""
699 if "--selftest" in args:
701 fix =
"--fix" in args
703 targets = enumerate_all()
704 if len(targets) < FILE_FLOOR:
706 f
"check-copyright.py: FATAL -- only {len(targets)} file(s) in scope, "
707 f
"floor is {FILE_FLOOR}. A collapsed scan reports a clean tree "
708 "because it scanned nothing.\n"
711 return _process(targets, fix)
712 files = [a
for a
in args
if not a.startswith(
"-")]
714 sys.stderr.write(
"usage: check-copyright.py FILE ... | --all | --fix --all | --selftest\n")
720 if path.is_absolute()
and path.is_relative_to(REPO_ROOT):
721 rel = str(path.relative_to(REPO_ROOT))
722 if is_build_output_path(rel)
or _is_generated(rel):
724 style = _style_for(path)
725 if style
is not None:
726 targets.append((rel, style))
727 return _process(targets, fix)
730if __name__ ==
"__main__":
731 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.