4"""Gate: every first-party source file shall end in a trailing newline.
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,
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.
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
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.
33from __future__
import annotations
37from collections.abc
import Iterable
38from pathlib
import Path
40sys.path.insert(0, str(Path(__file__).resolve().parent))
42from lint_targets
import first_party_paths, is_build_output_path
43from selftest_assert
import expect, report
45REPO_ROOT = Path(__file__).resolve().parents[2]
68SOURCE_NAMES = (
"CMakeLists.txt",
"justfile",
"Justfile")
71 "apps/shared_libs/third_party/",
86def _ends_in_newline(path: Path) -> bool:
87 """Return True if `path` is empty or ends in a newline byte."""
89 data = path.read_bytes()
92 return (
not data)
or data.endswith(b
"\n")
95def _is_excluded(path: Path) -> bool:
96 return is_build_output_path(path)
or any(frag
in str(path)
for frag
in EXCLUDE_FRAGMENTS)
99def _is_source(path: Path) -> bool:
100 return path.suffix
in SOURCE_SUFFIXES
or path.name
in SOURCE_NAMES
103def _rel(path: Path) -> str:
104 if path.is_relative_to(REPO_ROOT):
105 return str(path.relative_to(REPO_ROOT))
109def _derived_sources() -> list[Path]:
110 """Every first-party source file, derived from git rather than a root list.
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``).
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)]
125def _enumerate_targets(arg_paths: Iterable[str]) -> list[Path]:
126 args = list(arg_paths)
131 if not path.is_absolute():
132 path = REPO_ROOT / path
134 out.extend(c
for c
in path.rglob(
"*")
if c.is_file()
and _is_source(c))
135 elif _is_source(path):
137 return [p
for p
in out
if not _is_excluded(p)]
139 return [p
for p
in _derived_sources()
if not _is_excluded(p)]
142def selftest() -> int:
143 """Prove the detector fires on a missing newline and that the scope is real.
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.
152 0 when every assertion held in both directions, 1 otherwise.
154 failures: list[str] = []
155 with tempfile.TemporaryDirectory()
as 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)
167 scope = _enumerate_targets([])
168 rels = {str(p.relative_to(REPO_ROOT))
for p
in scope
if p.is_relative_to(REPO_ROOT)}
170 len(scope) >= FILE_FLOOR,
171 f
"derived scope sees {len(scope)} file(s) (floor {FILE_FLOOR})",
174 for root_name
in (
"just",
"infra"):
176 any(rel.startswith(root_name +
"/")
for rel
in rels),
177 f
"the derived scope reaches {root_name}/ (previously omitted)",
180 return report(failures)
183def main(argv: list[str]) -> int:
184 """Fail any first-party source file that does not end in a newline.
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.
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.
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
203 if "--selftest" in argv[1:]:
206 targets = _enumerate_targets(paths)
207 if not paths
and len(targets) < FILE_FLOOR:
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.",
216 print(
"check_final_newline.py: no files to scan", file=sys.stderr)
219 missing = sorted(_rel(p)
for p
in targets
if not _ends_in_newline(p))
221 print(f
"check_final_newline.py: {len(targets)} file(s) scanned, all end in a newline.")
225 f
"check_final_newline.py: {len(missing)} file(s) missing a trailing newline:\n",
229 print(f
" {path}", file=sys.stderr)
230 print(
"\nAdd a single newline at end of file.", file=sys.stderr)
234if __name__ ==
"__main__":
235 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.