ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
fix-encoding.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"""fix-encoding.py -- normalise source files to pure 7-bit ASCII.
5
6Replaces common non-ASCII Unicode characters with their ASCII equivalents
7(em-dash -> --, smart quotes -> plain quotes, Greek mu -> u, etc.), and is the
8detector behind the ``ascii`` CI gate and ``just quality::local::ascii``.
9
10``--all`` is the gate mode and DERIVES its scope from ``git ls-files`` via
11``lint_targets.first_party_paths()``. It used to be driven by a hardcoded root
12list in two places -- ``gate_ascii`` and the legacy quality task both looped over
13``src libs tests examples port scripts tools docs`` -- so the encoding policy
14never saw the repo root, ``.github/``, ``cmake/``, ``coprocessor/``, ``infra/``
15or the task-runner configuration. 106 files, including ``CLAUDE.md``, the file that *states* the
16policy (#533). A derived scope puts a new top-level directory in the gate the
17day it lands, with no list to remember.
18
19Aggravating the old form: a path that does not exist used to produce a silent
200, because ``rglob`` over a missing directory yields nothing -- so renaming a
21root would have turned the gate green rather than red. A missing target is now
22a hard error, and ``--all`` carries a non-vacuity floor.
23
24Usage:
25 python3 scripts/fix/fix-encoding.py --all # rewrite in place
26 python3 scripts/fix/fix-encoding.py --check --all # the gate mode
27 python3 scripts/fix/fix-encoding.py --check path/to/file_or_dir
28 python3 scripts/fix/fix-encoding.py --selftest # both directions
29
30Exit 0 if clean, 1 when a non-ASCII character was found, 2 when the scan could
31not be trusted (a missing target, a collapsed enumeration, a failed selftest).
32"""
33
34from __future__ import annotations
35
36import argparse
37import pathlib
38import sys
39import tempfile
40
41sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "checks"))
42
43from lint_targets import files_for, first_party_paths
44
45# U+NNNN -> ASCII replacement. Keep this list in sync with the
46# rx72n project's equivalent script so both trees use the same
47# canonical replacements.
48REPLACEMENTS: dict[str, str] = {
49 "\u2014": "--", # em dash
50 "\u2013": "-", # en dash
51 "\u2018": "'", # left single quote
52 "\u2019": "'", # right single quote
53 "\u201c": '"', # left double quote
54 "\u201d": '"', # right double quote
55 "\u2026": "...", # ellipsis
56 "\u00a0": " ", # non-breaking space
57 "\u00b0": " deg", # degree sign
58 "\u00b1": "+/-", # plus-minus
59 "\u00b5": "u", # micro
60 "\u03bc": "u", # Greek mu
61 "\u2264": "<=", # less than or equal
62 "\u2265": ">=", # greater than or equal
63 "\u2260": "!=", # not equal
64 "\u2192": "->", # right arrow
65 "\u2190": "<-", # left arrow
66 "\u00d7": "x", # times
67 "\u00f7": "/", # divide
68}
69
70
71EXTENSIONS = {
72 ".c",
73 ".h",
74 ".cpp",
75 ".hpp",
76 ".dox",
77 ".md",
78 ".yml",
79 ".yaml",
80 ".sh",
81 ".py",
82 ".cmake",
83 ".json",
84 ".toml",
85 ".cfg",
86 ".conf",
87 ".tex",
88 ".txt",
89 ".ini",
90 ".ld",
91 ".s",
92 ".m",
93}
94
95
96# Vendored / generated trees we don't author -- skip wholesale.
97# Mirrors check_no_ai_attribution.py and check_line_citations.py.
98EXCLUDED_PARTS = {"third_party", "_deps", "build", "build-cov", "doxygen_theme", "fixtures"}
99
100# Largest code point in 7-bit ASCII (U+007F).
101MAX_ASCII_CODEPOINT = 127
102
103# Non-vacuity floor for --all. The derived first-party set holds 3388 files
104# with these suffixes today (2026-07-28); this is not a per-file target but a
105# trip-wire for a scope that collapses wholesale, which would report the whole
106# tree ASCII-clean having read almost none of it. Same shape as check_ruff.py
107# and check_lint_coverage.py.
108FILE_FLOOR = 2500
109
110EXIT_OK = 0
111EXIT_FINDINGS = 1
112EXIT_VACUOUS = 2
113
114
115class EncodingScanError(RuntimeError):
116 """One in-scope text file could not be read as trusted UTF-8."""
117
118
119def rewrite(text: str) -> tuple[str, int]:
120 """Return replaced text and the number of substitutions."""
121 count = 0
122 for bad, good in REPLACEMENTS.items():
123 if bad in text:
124 count += text.count(bad)
125 text = text.replace(bad, good)
126 # Any remaining non-ASCII character becomes '?'.
127 cleaned = []
128 for ch in text:
129 if ord(ch) <= MAX_ASCII_CODEPOINT:
130 cleaned.append(ch)
131 else:
132 cleaned.append("?")
133 count += 1
134 return "".join(cleaned), count
135
136
137def process(path: pathlib.Path, *, check_only: bool) -> int:
138 """Transliterate one file's non-ASCII characters, or just count them.
139
140 Returns the number of offending characters found (0 when clean).
141
142 Raises:
143 EncodingScanError: The in-scope first-party text could not be read as
144 UTF-8. Such a failure cannot be reported as a clean ASCII scan.
145 """
146 try:
147 original = path.read_text(encoding="utf-8")
148 except (UnicodeDecodeError, OSError) as exc:
149 message = f"[ERROR] {path}: {exc}"
150 print(message, file=sys.stderr)
151 raise EncodingScanError(message) from exc
152
153 replaced, changed = rewrite(original)
154 if changed == 0:
155 return 0
156
157 if check_only:
158 print(f"[NEEDS-FIX] {path}: {changed} non-ASCII characters")
159 else:
160 path.write_text(replaced, encoding="utf-8")
161 print(f"[FIXED] {path}: {changed} replacements")
162 return changed
163
164
165def walk(target: pathlib.Path, *, check_only: bool) -> int:
166 """Process one file, or recurse through a directory.
167
168 Returns the total count of non-ASCII characters across everything visited.
169 """
170 if target.is_file():
171 return process(target, check_only=check_only)
172 total = 0
173 for path in target.rglob("*"):
174 if not path.is_file():
175 continue
176 if path.suffix.lower() not in EXTENSIONS:
177 continue
178 if any(part in EXCLUDED_PARTS for part in path.parts):
179 continue
180 total += process(path, check_only=check_only)
181 return total
182
183
184def extensionless_entry_targets() -> list[pathlib.Path]:
185 """Return tracked first-party shebang entry points with no suffix.
186
187 The hook files are executable source even though names such as
188 ``commit-msg`` and ``pre-commit`` carry no extension. Language discovery
189 already classifies them from their shebang, so reuse that authority rather
190 than inventing a second basename list.
191
192 Returns:
193 Sorted repo-relative extensionless shell/Python entry points.
194 """
195 language_paths = files_for(("shell", "python"))
196 return sorted(
197 {
198 pathlib.Path(relative)
199 for paths in language_paths.values()
200 for relative in paths
201 if not pathlib.Path(relative).suffix
202 }
203 )
204
205
206def derived_targets() -> list[pathlib.Path]:
207 """Every first-party file the encoding policy covers, derived from git.
208
209 Returns:
210 Repo-relative paths with a text suffix this script understands, plus
211 extensionless first-party shebang entry points.
212
213 Raises:
214 SystemExit: Via `first_party_paths` when ``git ls-files`` collapses.
215 """
216 suffixed = {pathlib.Path(relative) for relative in first_party_paths(tuple(sorted(EXTENSIONS)))}
217 return sorted(suffixed | set(extensionless_entry_targets()))
218
219
220def _run_all(*, check_only: bool) -> int:
221 """Scan the derived first-party set, refusing a collapsed enumeration.
222
223 Args:
224 check_only: Report without writing.
225
226 Returns:
227 A process exit status.
228 """
229 targets = derived_targets()
230 if len(targets) < FILE_FLOOR:
231 print(
232 f"fix-encoding.py: FATAL -- only {len(targets)} file(s) in scope, floor is "
233 f"{FILE_FLOOR}. A collapsed scope reports a clean tree because it read nothing.",
234 file=sys.stderr,
235 )
236 return EXIT_VACUOUS
237 try:
238 changed = sum(process(path, check_only=check_only) for path in targets)
239 except EncodingScanError:
240 return EXIT_VACUOUS
241 print(f"fix-encoding.py: {changed} non-ASCII character(s) across {len(targets)} file(s)")
242 return EXIT_FINDINGS if (check_only and changed) else EXIT_OK
243
244
245def _selftest_file_cases(root: pathlib.Path) -> list[tuple[str, bool]]:
246 """Exercise direct, extensionless, recursive, rewrite, and missing targets."""
247 clean = root / "clean.md"
248 clean.write_text("plain ASCII -- nothing exotic\n", encoding="utf-8")
249 dirty = root / "dirty.md"
250 # Escapes keep this policy implementation inside its own ASCII scope.
251 dirty.write_text("an em\u2014dash and a \u00b5\n", encoding="utf-8")
252 dirty_entry = root / "extensionless-hook"
253 dirty_entry.write_text("#!/bin/sh\n# em\u2014dash\n", encoding="utf-8")
254 cases = [
255 ("MUST NOT FIRE: a pure-ASCII file", process(clean, check_only=True) == 0),
256 ("MUST FIRE: an em-dash and a micro sign", process(dirty, check_only=True) > 0),
257 (
258 "MUST FIRE: an extensionless shebang entry contains non-ASCII",
259 process(dirty_entry, check_only=True) > 0,
260 ),
261 (
262 "MUST FIRE: a directory walk reaches the offending file",
263 walk(root, check_only=True) > 0,
264 ),
265 ]
266 process(dirty, check_only=False)
267 invalid = root / "invalid.md"
268 invalid.write_bytes(b"\xff")
269 unreadable = root / "unreadable.md"
270 unreadable.mkdir()
271 cases.extend(
272 [
273 ("MUST NOT FIRE: the rewritten file is clean", process(dirty, check_only=True) == 0),
274 (
275 "rewrite transliterated rather than deleted",
276 dirty.read_text(encoding="utf-8") == "an em--dash and a u\n",
277 ),
278 (
279 "MUST FIRE: invalid UTF-8 refuses a trusted scan",
280 _process_refuses(invalid),
281 ),
282 (
283 "MUST FIRE: a read error refuses a trusted scan",
284 _process_refuses(unreadable),
285 ),
286 (
287 "MUST FIRE: a target that does not exist is an error, not a silent 0",
288 walk_or_fail(root / "no-such-dir", check_only=True) is None,
289 ),
290 ]
291 )
292 return cases
293
294
295def _process_refuses(path: pathlib.Path) -> bool:
296 """Return whether one unreadable fixture fails closed."""
297 try:
298 process(path, check_only=True)
299 except EncodingScanError:
300 return True
301 return False
302
303
304def selftest() -> int:
305 """Assert the detector fires on a non-ASCII byte and stays quiet on ASCII.
306
307 The quiet direction alone proves nothing -- a checker whose scope had
308 collapsed to zero files is also perfectly quiet. Both directions plus the
309 live-scope floor are what make a clean run mean something.
310
311 Returns:
312 ``EXIT_OK`` when every case holds, ``EXIT_VACUOUS`` otherwise.
313 """
314 with tempfile.TemporaryDirectory() as tmp:
315 cases = _selftest_file_cases(pathlib.Path(tmp))
316
317 minimum_entry_count = 7
318 live_entries = extensionless_entry_targets()
319 cases.append(("the live derived scope clears the floor", len(derived_targets()) >= FILE_FLOOR))
320 cases.append(
321 (
322 "the live extensionless scope is non-vacuous",
323 len(live_entries) >= minimum_entry_count
324 and pathlib.Path("scripts/git/commit-msg") in live_entries,
325 )
326 )
327 for label, ok in cases:
328 print(f" {'ok ' if ok else 'FAIL'} {label}")
329 if not all(ok for _, ok in cases):
330 print("fix-encoding.py: selftest FAILED", file=sys.stderr)
331 return EXIT_VACUOUS
332 print(f"fix-encoding.py: selftest passed ({len(cases)} cases, both directions).")
333 return EXIT_OK
334
335
336def walk_or_fail(target: pathlib.Path, *, check_only: bool) -> int | None:
337 """Walk `target`, or return None when it does not exist.
338
339 A missing path used to walk to zero findings and exit 0, so renaming a
340 scanned root would have turned the gate green rather than red (#533).
341
342 Args:
343 target: File or directory to scan.
344 check_only: Report without writing.
345
346 Returns:
347 The non-ASCII character count, or None when `target` is absent.
348 """
349 if not target.exists():
350 return None
351 return walk(target, check_only=check_only)
352
353
354def main() -> int:
355 """Rewrite non-ASCII characters to ASCII equivalents, or check for them.
356
357 ``--check`` reports without writing, which is the gate mode; bare, it
358 rewrites in place. Exactly one of ``--all``, ``--selftest`` or a positional
359 target is required: a bare invocation used to be an argparse error only by
360 accident of ``target`` being positional, and the gate mode has to be named
361 explicitly so it cannot be entered by omission.
362
363 Returns:
364 0 when clean, 1 when a non-ASCII character was found in ``--check``
365 mode, 2 when the scan itself could not be trusted.
366 """
367 parser = argparse.ArgumentParser(description=__doc__)
368 parser.add_argument("target", type=pathlib.Path, nargs="?")
369 parser.add_argument("--check", action="store_true", help="Only report, do not modify")
370 parser.add_argument(
371 "--all",
372 action="store_true",
373 help="scan the derived first-party set (the gate mode)",
374 )
375 parser.add_argument("--selftest", action="store_true", help="prove both directions, then exit")
376 args = parser.parse_args()
377
378 if args.selftest:
379 return selftest()
380 if args.all == (args.target is not None):
381 parser.error("pass exactly one of --all or a target path")
382 if args.all:
383 return _run_all(check_only=args.check)
384
385 try:
386 changed = walk_or_fail(args.target, check_only=args.check)
387 except EncodingScanError:
388 return EXIT_VACUOUS
389 if changed is None:
390 print(f"fix-encoding.py: FATAL -- '{args.target}' does not exist.", file=sys.stderr)
391 return EXIT_VACUOUS
392 return EXIT_FINDINGS if (args.check and changed) else EXIT_OK
393
394
395if __name__ == "__main__":
396 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298