ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check-copyright.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"""Gate: one canonical copyright + SPDX attribution, same place in every file.
5
6Two forms, because this tree has two comment conventions and the attribution
7belongs where each convention already puts its metadata.
8
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::
13
14 /**
15 * @file foo.c
16 * @brief ...
17 * @details ...
18 *
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
24 */
25
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.
32
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.
36
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.
39
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::
44
45 #!/usr/bin/env bash
46 # SPDX-License-Identifier: MIT
47 # Copyright (c) 2026 Brighton Sikarskie
48
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.
52
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.
56
57Run::
58
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
63
64Exit 0 clean, 1 on a violation or failing selftest, 2 on a collapsed scan.
65"""
66
67from __future__ import annotations
68
69import sys
70from pathlib import Path
71
72sys.path.insert(0, str(Path(__file__).resolve().parent))
73
74from lint_targets import files_for, is_build_output_path
75
76REPO_ROOT = Path(__file__).resolve().parents[2]
77
78EXIT_OK = 0
79EXIT_FAIL = 1
80EXIT_CONFIG = 2
81
82COPY_TEXT = "Copyright (c) 2026 Brighton Sikarskie"
83COPY_TAG = f"@copyright {COPY_TEXT}"
84SPDX_TEXT = "SPDX-License-Identifier: MIT"
85
86STYLE_HASH = "hash"
87STYLE_DOXY = "doxy" # C family: attribution inside the @file block
88STYLE_PLAIN = "plain" # linker scripts: bare lines in the leading block
89
90LANG_STYLE = {
91 "c": STYLE_DOXY,
92 "ld": STYLE_PLAIN,
93 "shell": STYLE_HASH,
94 "python": STYLE_HASH,
95 "cmake": STYLE_HASH,
96 "just": STYLE_HASH,
97 "yaml": STYLE_HASH,
98}
99ENFORCED_LANGS = tuple(LANG_STYLE)
100
101_SUFFIX_STYLE = {
102 ".c": STYLE_DOXY,
103 ".h": STYLE_DOXY,
104 ".cpp": STYLE_DOXY,
105 ".hpp": STYLE_DOXY,
106 ".cc": STYLE_DOXY,
107 ".cxx": STYLE_DOXY,
108 ".hh": STYLE_DOXY,
109 ".hxx": STYLE_DOXY,
110 ".ld": STYLE_PLAIN,
111 ".py": STYLE_HASH,
112 ".sh": STYLE_HASH,
113 ".bash": STYLE_HASH,
114 ".cmake": STYLE_HASH,
115 ".mk": STYLE_HASH,
116 ".just": STYLE_HASH,
117 ".yml": STYLE_HASH,
118 ".yaml": STYLE_HASH,
119}
120_BASENAME_STYLE = {
121 "CMakeLists.txt": STYLE_HASH,
122 "justfile": STYLE_HASH,
123 "Justfile": STYLE_HASH,
124}
125
126_GENERATED = ("font_fixture.h", "fixture_ahem.h", "epub_fixture.h")
127
128FILE_FLOOR = 1500
129
130
131# ---------------------------------------------------------------------------
132# Comment-frame helpers
133# ---------------------------------------------------------------------------
134
135
136def _hashless(line: str) -> str:
137 return line.lstrip().removeprefix("#").strip()
138
139
140def _starless(line: str) -> str:
141 body = line.strip()
142 for lead in ("/**", "/*", "*/", "*"):
143 if body.startswith(lead):
144 body = body[len(lead) :]
145 break
146 return body.removesuffix("*/").strip()
147
148
149def _block_span(lines: list[str], *, doxygen: bool) -> tuple[int, int] | None:
150 """Span of the file-header comment block, or None.
151
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
158 reason.
159 """
160 start = None
161 for i, ln in enumerate(lines):
162 stripped = ln.strip()
163 if start is None:
164 if doxygen:
165 if stripped.startswith("/**"):
166 start = i
167 if "*/" in stripped[3:]:
168 return start, i
169 continue
170 if stripped.startswith("/*"):
171 if "*/" in stripped[2:]:
172 continue # a one-line comment is not the header block
173 start = i
174 continue
175 if "*/" in ln:
176 return start, i
177 return None
178
179
180#: Tag prefixes that belong to the attribution group, alongside the copyright
181#: and SPDX lines themselves.
182_GROUP_PREFIXES = ("@author", "@date", "@since")
183
184
185def _group_indices(body: list[str], want_copy: str) -> list[int]:
186 """Indices of the attribution tags present in a header block.
187
188 Args:
189 body: Comment-stripped lines of the file-header block.
190 want_copy: The copyright spelling this comment style expects.
191
192 Returns:
193 Sorted indices of every attribution tag found, possibly empty.
194 """
195 return [
196 i
197 for i, b in enumerate(body)
198 if b.startswith(_GROUP_PREFIXES) or b in {want_copy, COPY_TEXT, COPY_TAG, SPDX_TEXT}
199 ]
200
201
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.
204
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.
209
210 Args:
211 body: Comment-stripped lines of the file-header block.
212 want_copy: The copyright spelling this comment style expects.
213
214 Returns:
215 The offending index, or None when the group is contiguous.
216 """
217 idxs = _group_indices(body, want_copy)
218 if not idxs:
219 return None
220 return next((i for i in range(idxs[0], idxs[-1]) if not body[i]), None)
221
222
223def _attribution_outside(lines: list[str], span: tuple[int, int]) -> list[int]:
224 """Indices of attribution lines living OUTSIDE the header block.
225
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.
229 """
230 start, end = span
231 out = []
232 for i, ln in enumerate(lines):
233 if start <= i <= end:
234 continue
235 body = _starless(ln)
236 if body in {SPDX_TEXT, COPY_TEXT, COPY_TAG}:
237 out.append(i)
238 return out
239
240
241# ---------------------------------------------------------------------------
242# Classification
243# ---------------------------------------------------------------------------
244
245
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)
252
253
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:
258 return None
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"
266 return (
267 f"preamble is not the canonical leading pair (expected '# {SPDX_TEXT}' "
268 f"then '# {COPY_TEXT}' at line {idx + 1})"
269 )
270
271
272def _classify_block(lines: list[str], style: str) -> str | None:
273 doxygen = style == STYLE_DOXY
274 span = _block_span(lines, doxygen=doxygen)
275 if span is None:
276 return (
277 "no @file Doxygen block to carry the attribution"
278 if doxygen
279 else "no leading comment block to carry the attribution"
280 )
281 if _attribution_outside(lines, span):
282 return (
283 "attribution sits in a comment outside the file-header block; it "
284 "belongs INSIDE that block as its closing tag group"
285 )
286 start, end = span
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)
289
290
291def _classify_group(body: list[str], want_copy: str, doxygen: bool) -> str | None:
292 """Judge the attribution tag group inside a file-header block.
293
294 Args:
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.
298
299 Returns:
300 A short reason when the group is wrong, else None.
301 """
302 ci = si = None
303 for i, b in enumerate(body):
304 if b == want_copy:
305 ci = i
306 elif b == SPDX_TEXT:
307 si = i
308 if ci is None and si is None:
309 return "the file-header block carries no copyright or SPDX line"
310 if ci is None:
311 kind = "@copyright" if doxygen else "copyright"
312 return f"the file-header block has no {kind} line"
313 if si is None:
314 return "the file-header block has no SPDX-License-Identifier line"
315 if si != ci + 1:
316 return (
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})"
319 )
320 blank = _group_blank_split(body, want_copy)
321 if blank is not None:
322 return (
323 f"a blank line at block line {blank + 1} splits the attribution "
324 "group; @author / @date / @copyright / SPDX / @since must be "
325 "consecutive lines"
326 )
327 return None
328
329
330# ---------------------------------------------------------------------------
331# Fixer
332# ---------------------------------------------------------------------------
333
334
335def _rewrite(text: str, style: str) -> str | None:
336 lines = text.splitlines()
337 if classify(lines, style) is None:
338 return None
339 fixed = _rewrite_hash(lines) if style == STYLE_HASH else _rewrite_block(lines, style)
340 if fixed is None:
341 return None
342 trailing = "\n" if text.endswith("\n") else ""
343 return "\n".join(fixed) + trailing
344
345
346def _rewrite_hash(lines: list[str]) -> list[str]:
347 shebang = None
348 body = lines
349 if lines and lines[0].startswith("#!"):
350 shebang, body = lines[0], lines[1:]
351 kept: list[str] = []
352 in_region = True
353 for line in body:
354 s = line.strip()
355 if in_region and (s == "" or s.startswith("#")):
356 if s.startswith("#") and _hashless(line) in {SPDX_TEXT, COPY_TEXT}:
357 continue
358 kept.append(line)
359 continue
360 in_region = False
361 kept.append(line)
362 head = [shebang] if shebang else []
363 return [*head, f"# {SPDX_TEXT}", f"# {COPY_TEXT}", *kept]
364
365
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)
370 if span is None:
371 return None
372 want_copy = COPY_TAG if doxygen else COPY_TEXT
373 # Drop every attribution line, wherever it lives -- inside the block, or
374 # in the standalone/one-line comments above it -- then re-insert the pair
375 # inside the block. That repairs the split, reversed and outside-the-block
376 # shapes alike, and keeps the fixer idempotent. Blank comment lines left
377 # stranded by removing an outside comment are dropped with it.
378 drop = set(_attribution_outside(lines, span))
379 start, end = span
380 for i in range(start + 1, end):
381 if _starless(lines[i]) in {want_copy, COPY_TEXT, COPY_TAG, SPDX_TEXT}:
382 drop.add(i)
383 kept = [ln for i, ln in enumerate(lines) if i not in drop]
384 span = _block_span(kept, doxygen=doxygen)
385 if span is None:
386 return None
387 start, end = span
388 pair = [f" * {want_copy}", f" * {SPDX_TEXT}"]
389 # Insert before @since when the block has one, else just before the close.
390 insert_at = end
391 for i in range(start + 1, end):
392 if _starless(kept[i]).startswith("@since"):
393 insert_at = i
394 break
395 merged = kept[:insert_at] + pair + kept[insert_at:]
396 return _close_group_gaps(merged, want_copy, doxygen)
397
398
399def _close_group_gaps(lines: list[str], want_copy: str, doxygen: bool) -> list[str]:
400 """Delete blank comment lines that split the attribution group.
401
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.
405
406 Args:
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.
410
411 Returns:
412 The lines with any intra-group blank comment lines removed.
413 """
414 span = _block_span(lines, doxygen=doxygen)
415 if span is None:
416 return lines
417 start, end = span
418 body = [_starless(x) for x in lines[start : end + 1]]
419 idxs = _group_indices(body, want_copy)
420 if not idxs:
421 return lines
422 drop = {start + i for i in range(idxs[0], idxs[-1]) if not body[i]}
423 # Collapse a run of blank comment lines directly above the group to a
424 # single separator. Lifting the old pair out strands the blank that used
425 # to sit above it, which would otherwise leave two ` *` lines stacked.
426 above = []
427 cursor = idxs[0] - 1
428 while cursor > 0 and not body[cursor]:
429 above.append(cursor)
430 cursor -= 1
431 drop.update(start + i for i in above[1:])
432 return [ln for i, ln in enumerate(lines) if i not in drop]
433
434
435# ---------------------------------------------------------------------------
436# Scope
437# ---------------------------------------------------------------------------
438
439
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())
444
445
446def _is_generated(rel: str) -> bool:
447 return rel.endswith(_GENERATED)
448
449
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))
456 return sorted(out)
457
458
459def _check_one(rel: str, style: str) -> str | None:
460 path = REPO_ROOT / rel if not Path(rel).is_absolute() else Path(rel)
461 try:
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)
466
467
468# ---------------------------------------------------------------------------
469# Selftest -- both directions, in-memory only.
470# ---------------------------------------------------------------------------
471
472_GOOD_DOXY = [
473 "/**",
474 " * @file x.c",
475 " * @brief y",
476 " *",
477 " * @author Brighton Sikarskie",
478 " * @date 2026-04-29",
479 f" * {COPY_TAG}",
480 f" * {SPDX_TEXT}",
481 " * @since 0.1.0",
482 " */",
483]
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"]
488
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),
494 (
495 "hash with privileged rationale",
496 STYLE_HASH,
497 [
498 "#!/bin/bash -p",
499 f"# {SPDX_TEXT}",
500 f"# {COPY_TEXT}",
501 "# SHEBANG-SECURITY: exact reviewed startup boundary.",
502 ],
503 ),
504 ("hash without shebang", STYLE_HASH, _GOOD_HASH_NOSB),
505)
506
507MUST_FIRE: tuple[tuple[str, str, list[str]], ...] = (
508 (
509 "V1 standalone block above @file",
510 STYLE_DOXY,
511 ["/*", f" * {SPDX_TEXT}", f" * {COPY_TEXT}", " */", "/**", " * @file x.c", " */"],
512 ),
513 (
514 # The shape that once broke 12 files: a one-line /* ... */ comment
515 # above the block is NOT the header block. Treating it as one put the
516 # tag pair outside any comment, leaving ` * @copyright` at file scope.
517 "one-line attribution comments above @file",
518 STYLE_DOXY,
519 [
520 f"/* {SPDX_TEXT} */",
521 f"/* {COPY_TEXT} */",
522 "/**",
523 " * @file x.c",
524 " */",
525 ],
526 ),
527 (
528 "doxy reversed pair",
529 STYLE_DOXY,
530 ["/**", " * @file x.c", f" * {SPDX_TEXT}", f" * {COPY_TAG}", " */"],
531 ),
532 (
533 "doxy split pair",
534 STYLE_DOXY,
535 ["/**", " * @file x.c", f" * {COPY_TAG}", " * @since 0.1.0", f" * {SPDX_TEXT}", " */"],
536 ),
537 (
538 # A blank comment line inside the group: the pair, then ` *`, then
539 # @since. The tags are individually right and the stanza is still torn.
540 "blank line between the pair and @since",
541 STYLE_DOXY,
542 [
543 "/**",
544 " * @file x.c",
545 f" * {COPY_TAG}",
546 f" * {SPDX_TEXT}",
547 " *",
548 " * @since 0.1.0",
549 " */",
550 ],
551 ),
552 (
553 "blank line between @date and the pair",
554 STYLE_DOXY,
555 [
556 "/**",
557 " * @file x.c",
558 " * @author Brighton Sikarskie",
559 " * @date 2026-04-29",
560 " *",
561 f" * {COPY_TAG}",
562 f" * {SPDX_TEXT}",
563 " * @since 0.1.0",
564 " */",
565 ],
566 ),
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}", " */"]),
570 (
571 "hash reversed",
572 STYLE_HASH,
573 ["#!/usr/bin/env bash", f"# {COPY_TEXT}", f"# {SPDX_TEXT}"],
574 ),
575 (
576 "hash buried",
577 STYLE_HASH,
578 ["#!/usr/bin/env bash", "# prose", f"# {SPDX_TEXT}", f"# {COPY_TEXT}"],
579 ),
580 (
581 "security rationale before hash attribution",
582 STYLE_HASH,
583 [
584 "#!/bin/bash -p",
585 "# SHEBANG-SECURITY: exact reviewed startup boundary.",
586 f"# {SPDX_TEXT}",
587 f"# {COPY_TEXT}",
588 ],
589 ),
590 ("hash missing both", STYLE_HASH, ["#!/usr/bin/env bash", "# just prose", "echo hi"]),
591)
592
593
594def _selftest_fix() -> list[str]:
595 out: list[str] = []
596 for label, style, lines in MUST_FIRE:
597 text = "\n".join(lines) + "\n"
598 fixed = _rewrite(text, style)
599 if fixed is None:
600 out.append(f" fix: {label} was already canonical (unexpected)")
601 continue
602 if classify(fixed.splitlines(), style) is not None:
603 out.append(f" fix: {label} still non-canonical:\n{fixed}")
604 continue
605 if fixed.count(SPDX_TEXT) != 1 or fixed.count(COPY_TEXT) != 1:
606 out.append(f" fix: {label} left a duplicate:\n{fixed}")
607 continue
608 if _rewrite(fixed, style) is not None:
609 out.append(f" fix: {label} is not idempotent")
610 # Preservation: @author/@date/@since must survive a fix untouched.
611 src = "\n".join(
612 [
613 "/**",
614 " * @file x.c",
615 " * @author Someone Else",
616 " * @date 2020-01-02",
617 f" * {SPDX_TEXT}",
618 f" * {COPY_TAG}",
619 " * @since 0.1.0",
620 " */",
621 ]
622 )
623 fixed = _rewrite(src + "\n", STYLE_DOXY) or ""
624 out.extend(
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
628 )
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}")
631 return out
632
633
634def selftest() -> int:
635 """Prove every wrong shape fires and every canonical shape stays silent."""
636 failures = [
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
640 ]
641 failures += [
642 f" must-fire: {label} accepted as canonical"
643 for label, style, lines in MUST_FIRE
644 if classify(lines, style) is None
645 ]
646 failures += _selftest_fix()
647 if failures:
648 sys.stderr.write("check-copyright.py --selftest: FAILED\n\n")
649 sys.stderr.write("\n".join(failures) + "\n")
650 return EXIT_FAIL
651 total = len(MUST_FIRE) + len(MUST_STAY_QUIET)
652 print(
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)."
656 )
657 return EXIT_OK
658
659
660# ---------------------------------------------------------------------------
661# Drivers
662# ---------------------------------------------------------------------------
663
664
665def _process(targets: list[tuple[str, str]], fix: bool) -> int:
666 if fix:
667 changed = 0
668 for rel, style in targets:
669 path = REPO_ROOT / rel if not Path(rel).is_absolute() else Path(rel)
670 try:
671 text = path.read_text(encoding="utf-8", errors="replace")
672 except OSError:
673 continue
674 fixed = _rewrite(text, style)
675 if fixed is not None and fixed != text:
676 path.write_text(fixed, encoding="utf-8")
677 changed += 1
678 print(f"check-copyright.py --fix: {changed} file(s) rewritten.")
679 return EXIT_OK
680 failures = [(rel, r) for rel, style in targets if (r := _check_one(rel, style)) is not None]
681 if not failures:
682 print(f"check-copyright.py: {len(targets)} file(s) scanned, all headers canonical.")
683 return EXIT_OK
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")
687 sys.stderr.write(
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"
692 )
693 return EXIT_FAIL
694
695
696def main(argv: list[str]) -> int:
697 """Dispatch on the flags described in the module docstring."""
698 args = argv[1:]
699 if "--selftest" in args:
700 return selftest()
701 fix = "--fix" in args
702 if "--all" in args:
703 targets = enumerate_all()
704 if len(targets) < FILE_FLOOR:
705 sys.stderr.write(
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"
709 )
710 return EXIT_CONFIG
711 return _process(targets, fix)
712 files = [a for a in args if not a.startswith("-")]
713 if not files:
714 sys.stderr.write("usage: check-copyright.py FILE ... | --all | --fix --all | --selftest\n")
715 return EXIT_CONFIG
716 targets = []
717 for raw in files:
718 path = Path(raw)
719 rel = raw
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):
723 continue
724 style = _style_for(path)
725 if style is not None:
726 targets.append((rel, style))
727 return _process(targets, fix)
728
729
730if __name__ == "__main__":
731 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298