ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_final_newline.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: every first-party source file shall end in a trailing newline.
5
6POSIX defines a text line as ending in a newline; tools that read the last
7line (diff, cat, shell ``read``, parsers) behave better when the rule holds.
8``.editorconfig`` declares ``insert_final_newline`` but only editors honour
9it, and ``.clang-format``'s ``InsertNewlineAtEOF`` only covers C/C++ that
10reaches the formatter. Neither is a gate, and neither touches Python, shell,
11CMake, or YAML.
12
13This repo-wide backstop covers every first-party source file (C/C++, Python,
14shell, CMake, just, YAML, linker scripts) and fails if any is non-empty and
15does not end in a newline byte. The whole-tree set is DERIVED from
16``git ls-files`` via ``lint_targets.first_party_paths`` rather than a hardcoded
17root list, so a new top-level directory is covered the day it lands; a hardcoded
18list -- which this used to carry, omitting ``docs/``, ``just/``, ``infra/`` and
19``coprocessor/`` (#549) -- does not fail when it goes stale, it just reports
20success over a shrinking slice. Vendor trees and generated font tables are
21excluded. There is no grandfathering.
22
23Run::
24
25 check_final_newline.py # scan the whole tree
26 check_final_newline.py path/to/file ... # scan listed files
27 check_final_newline.py --selftest # prove both directions
28
29Exit 0 if every file ends in a newline, exit 1 (with a list) otherwise, exit 2
30when the whole-tree sweep collapses below FILE_FLOOR.
31"""
32
33from __future__ import annotations
34
35import sys
36import tempfile
37from collections.abc import Iterable
38from pathlib import Path
39
40sys.path.insert(0, str(Path(__file__).resolve().parent))
41
42from lint_targets import first_party_paths, is_build_output_path
43from selftest_assert import expect, report
44
45REPO_ROOT = Path(__file__).resolve().parents[2]
46
47SOURCE_SUFFIXES = (
48 ".c",
49 ".h",
50 ".cpp",
51 ".hpp",
52 ".cc",
53 ".cxx",
54 ".hh",
55 ".hxx",
56 ".m",
57 ".mm",
58 ".inl",
59 ".py",
60 ".sh",
61 ".cmake",
62 ".mk",
63 ".just",
64 ".yml",
65 ".yaml",
66 ".ld",
67)
68SOURCE_NAMES = ("CMakeLists.txt", "justfile", "Justfile")
69EXCLUDE_FRAGMENTS = (
70 "libs/third_party/",
71 "apps/shared_libs/third_party/",
72 "libs/ra8_fonts/",
73 "port/threadx/",
74 "_unsupported/",
75)
76
77# A tree this size cannot legitimately collapse to a handful of files. If the
78# whole-tree sweep returns less than this, something broke (an unreachable repo
79# root, a collapsed git enumeration, a runaway EXCLUDE_FRAGMENTS) and reporting
80# "all end in a newline" would be a lie. Measured 2026-08-02: 3116 first-party
81# source files (the derived scope, up from 2843 when it was a hardcoded root
82# list). Same trip-wire as check_ruff.py.
83FILE_FLOOR = 2200
84
85
86def _ends_in_newline(path: Path) -> bool:
87 """Return True if `path` is empty or ends in a newline byte."""
88 try:
89 data = path.read_bytes()
90 except OSError:
91 return True # unreadable: not this gate's problem
92 return (not data) or data.endswith(b"\n")
93
94
95def _is_excluded(path: Path) -> bool:
96 return is_build_output_path(path) or any(frag in str(path) for frag in EXCLUDE_FRAGMENTS)
97
98
99def _is_source(path: Path) -> bool:
100 return path.suffix in SOURCE_SUFFIXES or path.name in SOURCE_NAMES
101
102
103def _rel(path: Path) -> str:
104 if path.is_relative_to(REPO_ROOT):
105 return str(path.relative_to(REPO_ROOT))
106 return str(path)
107
108
109def _derived_sources() -> list[Path]:
110 """Every first-party source file, derived from git rather than a root list.
111
112 Enumeration goes through ``lint_targets.first_party_paths`` -- the shared
113 derived-scope primitive -- so a newly added top-level directory (``docs/``,
114 ``just/``, ``infra/`` and ``coprocessor/`` were the ones a hardcoded root
115 list had silently omitted, #549) is in scope the day it lands. The suffix
116 set is the sole language filter; ``SOURCE_NAMES`` catches the
117 extensionless-by-convention listfiles (``justfile``, ``CMakeLists.txt``).
118 """
119 rels = set(first_party_paths(SOURCE_SUFFIXES))
120 for name in SOURCE_NAMES:
121 rels |= {rel for rel in first_party_paths((name,)) if Path(rel).name == name}
122 return [REPO_ROOT / rel for rel in sorted(rels)]
123
124
125def _enumerate_targets(arg_paths: Iterable[str]) -> list[Path]:
126 args = list(arg_paths)
127 if args:
128 out: list[Path] = []
129 for raw in args:
130 path = Path(raw)
131 if not path.is_absolute():
132 path = REPO_ROOT / path
133 if path.is_dir():
134 out.extend(c for c in path.rglob("*") if c.is_file() and _is_source(c))
135 elif _is_source(path):
136 out.append(path)
137 return [p for p in out if not _is_excluded(p)]
138
139 return [p for p in _derived_sources() if not _is_excluded(p)]
140
141
142def selftest() -> int:
143 """Prove the detector fires on a missing newline and that the scope is real.
144
145 Both directions plus a scope probe: a non-empty file with no trailing
146 newline must FIRE, a newline-terminated file and an empty file must stay
147 QUIET, the derived whole-tree scope must clear ``FILE_FLOOR``, and it must
148 actually reach the roots a hardcoded list had dropped (``just/``, ``infra/``)
149 -- a clean run over a scope that never sees those roots proves nothing.
150
151 Returns:
152 0 when every assertion held in both directions, 1 otherwise.
153 """
154 failures: list[str] = []
155 with tempfile.TemporaryDirectory() as tmp:
156 root = Path(tmp)
157 good = root / "good.py"
158 good.write_text("x = 1\n", encoding="utf-8")
159 bad = root / "bad.py"
160 bad.write_bytes(b"x = 1")
161 empty = root / "empty.py"
162 empty.write_bytes(b"")
163 expect(_ends_in_newline(good), "MUST NOT FIRE: a newline-terminated file", failures)
164 expect(_ends_in_newline(empty), "MUST NOT FIRE: an empty file", failures)
165 expect(not _ends_in_newline(bad), "MUST FIRE: a file with no trailing newline", failures)
166
167 scope = _enumerate_targets([])
168 rels = {str(p.relative_to(REPO_ROOT)) for p in scope if p.is_relative_to(REPO_ROOT)}
169 expect(
170 len(scope) >= FILE_FLOOR,
171 f"derived scope sees {len(scope)} file(s) (floor {FILE_FLOOR})",
172 failures,
173 )
174 for root_name in ("just", "infra"):
175 expect(
176 any(rel.startswith(root_name + "/") for rel in rels),
177 f"the derived scope reaches {root_name}/ (previously omitted)",
178 failures,
179 )
180 return report(failures)
181
182
183def main(argv: list[str]) -> int:
184 """Fail any first-party source file that does not end in a newline.
185
186 Exists because the two existing mechanisms are not gates: .editorconfig's
187 ``insert_final_newline`` is honoured only by editors, and clang-format's
188 ``InsertNewlineAtEOF`` only reaches C/C++ that goes through the formatter,
189 leaving Python, shell, CMake and YAML unenforced.
190
191 An empty target set exits 0 rather than FATAL only when a file list was
192 passed on argv: the caller there is the pre-commit hook handing over a
193 staged file list, which legitimately filters to nothing when a commit
194 touches only excluded paths. The WHOLE-TREE sweep gets the opposite
195 treatment -- FILE_FLOOR, exit 2 -- because nothing about this tree can
196 legitimately reduce it to a handful of files, and a sweep that read almost
197 nothing reports a clean tree for exactly the wrong reason.
198
199 Returns 1 with each offending path listed, 0 when clean or when an argv
200 list filtered to nothing, 2 when the whole-tree sweep enumerated too few
201 files to trust.
202 """
203 if "--selftest" in argv[1:]:
204 return selftest()
205 paths = argv[1:]
206 targets = _enumerate_targets(paths)
207 if not paths and len(targets) < FILE_FLOOR:
208 print(
209 f"check_final_newline.py: FATAL -- only {len(targets)} file(s) in scope, "
210 f"floor is {FILE_FLOOR}. A collapsed sweep reports a clean tree because "
211 "it scanned nothing.",
212 file=sys.stderr,
213 )
214 return 2
215 if not targets:
216 print("check_final_newline.py: no files to scan", file=sys.stderr)
217 return 0
218
219 missing = sorted(_rel(p) for p in targets if not _ends_in_newline(p))
220 if not missing:
221 print(f"check_final_newline.py: {len(targets)} file(s) scanned, all end in a newline.")
222 return 0
223
224 print(
225 f"check_final_newline.py: {len(missing)} file(s) missing a trailing newline:\n",
226 file=sys.stderr,
227 )
228 for path in missing:
229 print(f" {path}", file=sys.stderr)
230 print("\nAdd a single newline at end of file.", file=sys.stderr)
231 return 1
232
233
234if __name__ == "__main__":
235 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298