ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_shell.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: shellcheck for first-party shell scripts.
5
6ShellCheck (correctness, at ``--severity=style`` plus the opt-in checks listed
7in :data:`SHELLCHECK_ENABLE`) is the shell equivalent of ruff. Formatting
8(shfmt, 2-space case-indented) is enforced by the format gate
9(``format_tree.sh``), never here. This wrapper fails on any finding -- no
10grandfathering. ShellCheck must be on PATH (or named via ``SHELLCHECK``);
11without it the gate skips locally unless ``--require`` is passed, which CI
12uses to fail on a missing tool.
13
14``style`` is the tightest severity ShellCheck offers, so nothing is filtered by
15level. The opt-in checks are the ones that are both *fixable in place* and map
16to a defect class this tree has actually shipped -- unquoted expansions, values
17that are read but never assigned, ``which`` instead of ``command -v``. Five
18opt-in checks are deliberately NOT enabled; see ``docs/STYLE_GUIDE.md`` and the
19comment on :data:`SHELLCHECK_DISABLED_OPTIONAL` for the measurements behind
20that call.
21
22Run::
23
24 check_shell.py # gate (fail on any finding)
25 check_shell.py --require # fail (not skip) if a tool is absent
26 check_shell.py --selftest # prove the gate fires and stays quiet
27
28Exit 0 if clean, exit 1 on findings, exit 2 on a tool error or a scope that
29collapsed below SCRIPT_FLOOR.
30"""
31
32from __future__ import annotations
33
34import json
35import os
36import shutil
37import subprocess
38import sys
39import tempfile
40from pathlib import Path
41
42sys.path.insert(0, str(Path(__file__).resolve().parent))
43
44from lint_targets import is_build_output_path
45
46REPO_ROOT = Path(__file__).resolve().parents[2]
47
48# relative path -> {SC code: count}
49Findings = dict[str, dict[str, int]]
50
51
52# `style` is ShellCheck's tightest severity -- nothing is filtered out by level.
53SHELLCHECK_SEVERITY = "style"
54
55# Opt-in checks. ShellCheck ships these off by default, so no severity setting
56# reaches them; each has to be named. Every one below is fixable in place.
57SHELLCHECK_ENABLE = (
58 "avoid-negated-conditions",
59 "avoid-nullary-conditions",
60 "check-unassigned-uppercase",
61 "deprecate-which",
62 "quote-safe-variables",
63 "useless-use-of-cat",
64)
65
66# Deliberately NOT enabled. Re-measured for #363 on the first-party shell files
67# at ShellCheck 0.11.0, on top of the severity + opt-in set above:
68#
69# check-set-e-suppressed (SC2310/SC2311) -- 90 findings / 24 files.
70# Unsatisfiable by construction, and verified form by form: it fires on
71# `fn || rc=$?`, on the rewrite its own help text recommends
72# (`if fn; then rc=0; else rc=$?; fi`), on a bare one-line predicate, on
73# `! fn` and on `fn && ...`. The only two forms it ACCEPTS are worse than
74# the ones it rejects -- `set +e; fn; rc=$?; set -e` passes while a
75# brace-bodied callee still runs past a mid-body failure, and
76# `( fn ); rc=$?` passes while aborting the parent outright. Enabling it
77# would mean ~90 inline disables and would push authors toward the form it
78# cannot see. The signal is real, so it is covered instead by
79# scripts/checks/check_errexit_masking.py, which fires only where a
80# first-party function with two or more failable commands is invoked with
81# its status masked. The runtime regression for the specific gate-suite
82# failure remains asserted by suite_errexit_selftest in scripts/ci.sh.
83# check-extra-masked-returns (SC2312) -- 163 findings / 34 files.
84# Overwhelmingly command substitutions on commands that cannot meaningfully
85# fail (uname, date -Iseconds, basename, id -un) or inside `< <(...)`
86# process substitutions with no single-statement rewrite. Fixing them means
87# hoisting each into a preceding assignment: some of that is worth doing and
88# some is pure motion, and a blanket enable cannot tell the two apart. The
89# subset that actually matters -- masking a FUNCTION's status -- is exactly
90# what check_errexit_masking.py now covers.
91# require-variable-braces (SC2250) -- 2901 findings / 60 files.
92# Presentation. `$var` and `${var}` are identical outside the
93# disambiguation cases, which ShellCheck already flags separately at the
94# level of correctness.
95# require-double-brackets (SC2292) -- 176 findings / 21 files.
96# Presentation, and actively wrong here: `[` is correct in the POSIX-sh
97# scripts in this tree, so this would push them toward bash-only for no
98# behavioural gain.
99# add-default-case (SC2249) -- 56 findings / 15 files.
100# A default case is right for a dispatch on external input, which this tree
101# already writes; it is noise on an exhaustive match over a fixed internal
102# enum, and the check cannot distinguish them.
103SHELLCHECK_DISABLED_OPTIONAL = (
104 "check-set-e-suppressed",
105 "check-extra-masked-returns",
106 "require-variable-braces",
107 "require-double-brackets",
108 "add-default-case",
109)
110
111EXCLUDE_FRAGMENTS = (
112 "libs/third_party/",
113 "apps/shared_libs/third_party/",
114 "libs/ra8_fonts/",
115 "port/threadx/",
116)
117
118# A tree this size cannot legitimately collapse to a handful of scripts. If the
119# enumeration returns less than this, something broke (a failed `git ls-files`,
120# a runaway EXCLUDE_FRAGMENTS) and reporting "clean" would be a lie -- the old
121# `no shell scripts to scan` branch exited 0 on exactly that. Measured
122# 2026-07-28: 122 first-party shell scripts. Same trip-wire as check_ruff.py.
123SCRIPT_FLOOR = 95
124
125
126def _shellcheck_args() -> list[str]:
127 """The severity + opt-in flags every ShellCheck invocation here shares."""
128 return [
129 # -x follows `# shellcheck source=<path>` directives instead of only
130 # guessing the target from the sourcing script's own directory. Without
131 # it, a helper sourced across directories (`. "$SCRIPT_DIR/../builders/
132 # select_host_compiler.sh"`) is unresolvable, and every variable that
133 # helper exports is then reported as referenced-but-never-assigned --
134 # findings about the analysis, not about the code. Measured: -x adds
135 # zero new findings over this gate's file set and removes those.
136 "-x",
137 f"--severity={SHELLCHECK_SEVERITY}",
138 "--enable=" + ",".join(SHELLCHECK_ENABLE),
139 ]
140
141
142def _find(env_var: str, name: str) -> str | None:
143 env = os.environ.get(env_var)
144 if env and Path(env).exists():
145 return env
146 return shutil.which(name)
147
148
149def _git_ls(*pathspec: str) -> list[str]:
150 """Tracked plus untracked-but-not-ignored paths matching `pathspec`.
151
152 Enumerates via ``git ls-files`` instead of a filesystem walk so locally
153 present, git-excluded trees (``.git/info/exclude`` entries such as
154 ``recon/`` vendor drops) never enter the gate -- a raw ``rglob`` scanned
155 them and failed commits on third-party findings CI can never see.
156 """
157 git_tool = shutil.which("git") or "git"
158 proc = subprocess.run( # noqa: S603 -- fixed argv, trusted tool path
159 [git_tool, "ls-files", "--cached", "--others", "--exclude-standard", "--", *pathspec],
160 cwd=REPO_ROOT,
161 capture_output=True,
162 text=True,
163 check=False,
164 )
165 if proc.returncode != 0:
166 sys.stderr.write(proc.stderr)
167 sys.stderr.write(f"git ls-files failed (exit {proc.returncode})\n")
168 sys.exit(2)
169 return _existing_worktree_paths(proc.stdout.splitlines())
170
171
172def _existing_worktree_paths(paths: list[str], root: Path = REPO_ROOT) -> list[str]:
173 """Keep live worktree files, dropping deleted entries still present in the index."""
174 return [rel.strip() for rel in paths if rel.strip() and (root / rel.strip()).is_file()]
175
176
177def _has_shell_shebang(rel: str) -> bool:
178 """True when `rel` opens with a ``#!`` line naming sh, bash or zsh."""
179 try:
180 with (REPO_ROOT / rel).open("rb") as handle:
181 first = handle.readline(200)
182 except OSError:
183 return False
184 if not first.startswith(b"#!"):
185 return False
186 line = first.decode("utf-8", errors="replace")
187 return any(tok in line for tok in ("bash", "zsh", "/sh", "env sh"))
188
189
190def first_party_scripts() -> list[str]:
191 """First-party shell scripts: by ``*.sh`` suffix OR by shebang.
192
193 The shebang sweep is not hypothetical. Every git hook in ``scripts/git/``
194 -- pre-commit, pre-push, commit-msg, post-merge, post-commit,
195 post-checkout -- is an extensionless bash script, so a suffix-only scope
196 left the hooks that enforce this entire tree as the only shell in it that
197 nothing shellchecked. That is the #296/#332/#358/#359/#360
198 defect class exactly: a scope narrower than the thing it claims to cover,
199 reporting clean.
200 """
201 by_suffix = _git_ls("*.sh")
202 known = set(by_suffix)
203 by_shebang = [rel for rel in _git_ls() if rel not in known and _has_shell_shebang(rel)]
204 out = [
205 rel
206 for rel in known.union(by_shebang)
207 if not is_build_output_path(rel)
208 and not any(frag in f"/{rel}" for frag in EXCLUDE_FRAGMENTS)
209 ]
210 return sorted(out)
211
212
213def _run_shellcheck(tool: str, files: list[str], cwd: Path | None = None) -> Findings:
214 proc = subprocess.run( # noqa: S603 -- fixed argv, trusted tool path
215 [tool, *_shellcheck_args(), "-f", "json", *files],
216 cwd=cwd or REPO_ROOT,
217 capture_output=True,
218 text=True,
219 check=False,
220 )
221 if proc.returncode not in (0, 1):
222 sys.stderr.write(proc.stderr)
223 sys.stderr.write(f"shellcheck failed (exit {proc.returncode})\n")
224 sys.exit(2)
225 findings: Findings = {}
226 for item in json.loads(proc.stdout or "[]"):
227 rel = item["file"]
228 code = f"SC{item['code']}"
229 findings.setdefault(rel, {})
230 findings[rel][code] = findings[rel].get(code, 0) + 1
231 return findings
232
233
234# --------------------------------------------------------------------------
235# Self-test
236# --------------------------------------------------------------------------
237# Each case is (label, filename, body, must_fire). The "must fire" half proves
238# the gate still detects every class it claims to enforce -- a check silently
239# dropped from SHELLCHECK_ENABLE, or a severity quietly relaxed back to
240# `warning`, turns one of these green and fails the selftest. The "must stay
241# quiet" half proves the bar is survivable: the tricky-but-correct forms this
242# tree actually uses must not be flagged, or the gate becomes noise people
243# route around.
244SELFTEST_CASES: tuple[tuple[str, str, str, bool], ...] = (
245 # ---- must FIRE ------------------------------------------------------
246 (
247 "SC2086 unquoted expansion (info-level: invisible at the old warning bar)",
248 "fire_unquoted.sh",
249 "#!/usr/bin/env bash\nf=/a/b\nrm -f $f\n",
250 True,
251 ),
252 (
253 "SC2006 legacy backticks (style-level: needs severity=style)",
254 "fire_backtick.sh",
255 '#!/usr/bin/env bash\nd="`date`"\necho "$d"\n',
256 True,
257 ),
258 (
259 "SC2248 unquoted safe variable (opt-in: quote-safe-variables)",
260 "fire_quotesafe.sh",
261 "#!/usr/bin/env bash\nrc=0\nexit $rc\n",
262 True,
263 ),
264 (
265 "SC2154 referenced but never assigned (opt-in: check-unassigned-uppercase)",
266 "fire_unassigned.sh",
267 '#!/usr/bin/env bash\necho "${NEVER_SET_ANYWHERE}"\n',
268 True,
269 ),
270 (
271 "SC2230 which instead of command -v (opt-in: deprecate-which)",
272 "fire_which.sh",
273 "#!/usr/bin/env bash\nwhich gcc >/dev/null\n",
274 True,
275 ),
276 (
277 "SC2002 useless cat (opt-in: useless-use-of-cat)",
278 "fire_uuoc.sh",
279 "#!/usr/bin/env bash\ncat /etc/hosts | grep -q localhost\n",
280 True,
281 ),
282 (
283 "SC2244 nullary condition (opt-in: avoid-nullary-conditions)",
284 "fire_nullary.sh",
285 '#!/usr/bin/env bash\nv=x\nif [ "$v" ]; then echo hi; fi\n',
286 True,
287 ),
288 (
289 "SC2164 cd without a failure guard (warning-level baseline)",
290 "fire_cd.sh",
291 '#!/usr/bin/env bash\ncd /nonexistent-selftest-dir\necho "ran on regardless"\n',
292 True,
293 ),
294 # ---- must stay QUIET ------------------------------------------------
295 (
296 "empty-array guard for bash 3.2 set -u",
297 "quiet_array_guard.sh",
298 "#!/usr/bin/env bash\nset -euo pipefail\nargs=()\n"
299 'if [[ -n "${HOME:-}" ]]; then args+=(--home "$HOME"); fi\n'
300 "printf '%s\\n' ${args[@]+\"${args[@]}\"}\n",
301 False,
302 ),
303 (
304 "printf with a constant format and variable arguments",
305 "quiet_printf.sh",
306 "#!/usr/bin/env bash\nset -euo pipefail\ncolor=$'\\033[0;32m'\n"
307 'printf \'%sdone%s\\n\' "$color" "$color"\n',
308 False,
309 ),
310 (
311 "deliberate word-split routed through an array",
312 "quiet_array_split.sh",
313 "#!/usr/bin/env bash\nset -euo pipefail\nextra=()\n"
314 'read -r -a extra <<<"--flag value"\n'
315 "printf '<%s>' ${extra[@]+\"${extra[@]}\"}\n",
316 False,
317 ),
318 (
319 "command -v guard, quoted status variable, braced condition",
320 "quiet_idiomatic.sh",
321 "#!/usr/bin/env bash\nset -euo pipefail\nrc=0\n"
322 "if ! command -v gcc >/dev/null 2>&1; then rc=1; fi\n"
323 'exit "$rc"\n',
324 False,
325 ),
326)
327
328
329def selftest(tmp: Path) -> int:
330 """Assert the gate fires on every enforced class and stays quiet otherwise."""
331 sc_tool = _find("SHELLCHECK", "shellcheck")
332 if not sc_tool:
333 sys.stderr.write("check_shell.py --selftest: shellcheck not found\n")
334 return 2
335
336 failures: list[str] = []
337 live = tmp / "live.sh"
338 live.write_text("#!/bin/sh\nexit 0\n", encoding="ascii")
339 resolved = _existing_worktree_paths(["live.sh", "deleted.sh"], tmp)
340 if resolved != ["live.sh"]:
341 failures.append(" worktree scope did not retain a live file and drop a deleted index path")
342
343 for label, fname, body, must_fire in SELFTEST_CASES:
344 path = tmp / fname
345 path.write_text(body)
346 fired = bool(_run_shellcheck(sc_tool, [fname], cwd=tmp))
347 if fired != must_fire:
348 verb = "did not fire" if must_fire else "fired"
349 codes = _run_shellcheck(sc_tool, [fname], cwd=tmp).get(fname, {})
350 failures.append(f" shellcheck {verb} (unexpected): {label} {codes or ''}")
351
352 fires = sum(1 for c in SELFTEST_CASES if c[3])
353 quiets = sum(1 for c in SELFTEST_CASES if not c[3])
354
355 if failures:
356 sys.stderr.write("check_shell.py --selftest: FAILED\n\n")
357 sys.stderr.write("\n".join(failures) + "\n")
358 return 1
359
360 fires = sum(1 for c in SELFTEST_CASES if c[3]) + 1
361 quiets = sum(1 for c in SELFTEST_CASES if not c[3]) + 1
362 print(
363 f"check_shell.py --selftest: PASS "
364 f"({fires + quiets} cases: {fires} must fire, {quiets} must stay quiet)"
365 )
366 return 0
367
368
369def _report(checks: Findings) -> None:
370 if checks:
371 sys.stderr.write("check_shell.py: shellcheck finding(s):\n")
372 for relfile in sorted(checks):
373 for code, count in sorted(checks[relfile].items()):
374 sys.stderr.write(f" {relfile}: {code} x{count}\n")
375 sys.stderr.write(
376 "\nFix the finding. A `# shellcheck disable=SCxxxx` needs an\n"
377 "inline reason on the same line saying why the finding does not apply.\n"
378 )
379
380
381def main(argv: list[str]) -> int:
382 """Run shellcheck over every first-party worktree shell script.
383
384 ShellCheck is REQUIRED, not optional: a missing tool fails the gate rather
385 than reducing its scope, because a checker that quietly stops checking is
386 indistinguishable from a clean tree. Formatting (shfmt) is enforced by the
387 format gate, never here.
388
389 ``--list-files`` reports the covered scope for check_lint_coverage.py and
390 the format gate. It is deliberately independent of whether shellcheck is
391 installed -- the question is what this gate covers, and a missing binary
392 must not shrink the answer to nothing.
393
394 SCRIPT_FLOOR replaces the old ``no shell scripts to scan`` branch, which
395 exited 0 on an empty enumeration -- a result indistinguishable from a clean
396 tree and produced by having read nothing.
397
398 Returns 0 when clean, 1 on findings, 2 when the
399 scope collapsed below SCRIPT_FLOOR, and 1 on a missing tool under
400 ``--require``.
401 """
402 if "--selftest" in argv[1:]:
403 with tempfile.TemporaryDirectory() as td:
404 return selftest(Path(td))
405
406 # Scope introspection for check_lint_coverage.py and the format gate -- see
407 # the note in check_ruff.py's main(). Deliberately independent of whether
408 # tools are installed: the question is what this gate COVERS, and a missing
409 # tool must not silently shrink the answer to nothing.
410 if "--list-files" in argv[1:]:
411 print("\n".join(first_party_scripts()))
412 return 0
413
414 sc_tool = _find("SHELLCHECK", "shellcheck")
415 if not sc_tool:
416 msg = "check_shell.py: shellcheck not found"
417 if "--require" in argv[1:]:
418 sys.stderr.write(msg + " (--require set)\n")
419 sys.exit(1)
420 print(msg + " -- skipping (install to enforce locally).")
421 sys.exit(0)
422
423 files = first_party_scripts()
424 if len(files) < SCRIPT_FLOOR:
425 sys.stderr.write(
426 f"check_shell.py: FATAL -- only {len(files)} shell script(s) in scope, "
427 f"floor is {SCRIPT_FLOOR}.\n"
428 " A collapsed scope reports a clean tree because it checked nothing.\n"
429 )
430 return 2
431 checks = _run_shellcheck(sc_tool, files)
432 if not checks:
433 print(
434 f"check_shell.py: clean ({len(files)} file(s), "
435 f"severity={SHELLCHECK_SEVERITY} + {len(SHELLCHECK_ENABLE)} opt-in check(s))."
436 )
437 return 0
438 _report(checks)
439 return 1
440
441
442if __name__ == "__main__":
443 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298