4"""fix-encoding.py -- normalise source files to pure 7-bit ASCII.
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``.
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.
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.
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
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).
34from __future__
import annotations
41sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] /
"checks"))
43from lint_targets
import files_for, first_party_paths
48REPLACEMENTS: dict[str, str] = {
98EXCLUDED_PARTS = {
"third_party",
"_deps",
"build",
"build-cov",
"doxygen_theme",
"fixtures"}
101MAX_ASCII_CODEPOINT = 127
115class EncodingScanError(RuntimeError):
116 """One in-scope text file could not be read as trusted UTF-8."""
119def rewrite(text: str) -> tuple[str, int]:
120 """Return replaced text and the number of substitutions."""
122 for bad, good
in REPLACEMENTS.items():
124 count += text.count(bad)
125 text = text.replace(bad, good)
129 if ord(ch) <= MAX_ASCII_CODEPOINT:
134 return "".join(cleaned), count
137def process(path: pathlib.Path, *, check_only: bool) -> int:
138 """Transliterate one file's non-ASCII characters, or just count them.
140 Returns the number of offending characters found (0 when clean).
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.
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
153 replaced, changed = rewrite(original)
158 print(f
"[NEEDS-FIX] {path}: {changed} non-ASCII characters")
160 path.write_text(replaced, encoding=
"utf-8")
161 print(f
"[FIXED] {path}: {changed} replacements")
165def walk(target: pathlib.Path, *, check_only: bool) -> int:
166 """Process one file, or recurse through a directory.
168 Returns the total count of non-ASCII characters across everything visited.
171 return process(target, check_only=check_only)
173 for path
in target.rglob(
"*"):
174 if not path.is_file():
176 if path.suffix.lower()
not in EXTENSIONS:
178 if any(part
in EXCLUDED_PARTS
for part
in path.parts):
180 total += process(path, check_only=check_only)
184def extensionless_entry_targets() -> list[pathlib.Path]:
185 """Return tracked first-party shebang entry points with no suffix.
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.
193 Sorted repo-relative extensionless shell/Python entry points.
195 language_paths = files_for((
"shell",
"python"))
198 pathlib.Path(relative)
199 for paths
in language_paths.values()
200 for relative
in paths
201 if not pathlib.Path(relative).suffix
206def derived_targets() -> list[pathlib.Path]:
207 """Every first-party file the encoding policy covers, derived from git.
210 Repo-relative paths with a text suffix this script understands, plus
211 extensionless first-party shebang entry points.
214 SystemExit: Via `first_party_paths` when ``git ls-files`` collapses.
216 suffixed = {pathlib.Path(relative)
for relative
in first_party_paths(tuple(sorted(EXTENSIONS)))}
217 return sorted(suffixed | set(extensionless_entry_targets()))
220def _run_all(*, check_only: bool) -> int:
221 """Scan the derived first-party set, refusing a collapsed enumeration.
224 check_only: Report without writing.
227 A process exit status.
229 targets = derived_targets()
230 if len(targets) < FILE_FLOOR:
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.",
238 changed = sum(process(path, check_only=check_only)
for path
in targets)
239 except EncodingScanError:
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
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"
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")
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),
258 "MUST FIRE: an extensionless shebang entry contains non-ASCII",
259 process(dirty_entry, check_only=
True) > 0,
262 "MUST FIRE: a directory walk reaches the offending file",
263 walk(root, check_only=
True) > 0,
266 process(dirty, check_only=
False)
267 invalid = root /
"invalid.md"
268 invalid.write_bytes(b
"\xff")
269 unreadable = root /
"unreadable.md"
273 (
"MUST NOT FIRE: the rewritten file is clean", process(dirty, check_only=
True) == 0),
275 "rewrite transliterated rather than deleted",
276 dirty.read_text(encoding=
"utf-8") ==
"an em--dash and a u\n",
279 "MUST FIRE: invalid UTF-8 refuses a trusted scan",
280 _process_refuses(invalid),
283 "MUST FIRE: a read error refuses a trusted scan",
284 _process_refuses(unreadable),
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,
295def _process_refuses(path: pathlib.Path) -> bool:
296 """Return whether one unreadable fixture fails closed."""
298 process(path, check_only=
True)
299 except EncodingScanError:
304def selftest() -> int:
305 """Assert the detector fires on a non-ASCII byte and stays quiet on ASCII.
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.
312 ``EXIT_OK`` when every case holds, ``EXIT_VACUOUS`` otherwise.
314 with tempfile.TemporaryDirectory()
as tmp:
315 cases = _selftest_file_cases(pathlib.Path(tmp))
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))
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,
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)
332 print(f
"fix-encoding.py: selftest passed ({len(cases)} cases, both directions).")
336def walk_or_fail(target: pathlib.Path, *, check_only: bool) -> int |
None:
337 """Walk `target`, or return None when it does not exist.
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).
343 target: File or directory to scan.
344 check_only: Report without writing.
347 The non-ASCII character count, or None when `target` is absent.
349 if not target.exists():
351 return walk(target, check_only=check_only)
355 """Rewrite non-ASCII characters to ASCII equivalents, or check for them.
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.
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.
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")
373 help=
"scan the derived first-party set (the gate mode)",
375 parser.add_argument(
"--selftest", action=
"store_true", help=
"prove both directions, then exit")
376 args = parser.parse_args()
380 if args.all == (args.target
is not None):
381 parser.error(
"pass exactly one of --all or a target path")
383 return _run_all(check_only=args.check)
386 changed = walk_or_fail(args.target, check_only=args.check)
387 except EncodingScanError:
390 print(f
"fix-encoding.py: FATAL -- '{args.target}' does not exist.", file=sys.stderr)
392 return EXIT_FINDINGS
if (args.check
and changed)
else EXIT_OK
395if __name__ ==
"__main__":
396 raise SystemExit(
main())
void main(void)
The application entry point Reset_Handler hands control to.