4"""Gate: shellcheck for first-party shell scripts.
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.
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
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
28Exit 0 if clean, exit 1 on findings, exit 2 on a tool error or a scope that
29collapsed below SCRIPT_FLOOR.
32from __future__
import annotations
40from pathlib
import Path
42sys.path.insert(0, str(Path(__file__).resolve().parent))
44from lint_targets
import is_build_output_path
46REPO_ROOT = Path(__file__).resolve().parents[2]
49Findings = dict[str, dict[str, int]]
53SHELLCHECK_SEVERITY =
"style"
58 "avoid-negated-conditions",
59 "avoid-nullary-conditions",
60 "check-unassigned-uppercase",
62 "quote-safe-variables",
103SHELLCHECK_DISABLED_OPTIONAL = (
104 "check-set-e-suppressed",
105 "check-extra-masked-returns",
106 "require-variable-braces",
107 "require-double-brackets",
113 "apps/shared_libs/third_party/",
126def _shellcheck_args() -> list[str]:
127 """The severity + opt-in flags every ShellCheck invocation here shares."""
137 f
"--severity={SHELLCHECK_SEVERITY}",
138 "--enable=" +
",".join(SHELLCHECK_ENABLE),
142def _find(env_var: str, name: str) -> str |
None:
143 env = os.environ.get(env_var)
144 if env
and Path(env).exists():
146 return shutil.which(name)
149def _git_ls(*pathspec: str) -> list[str]:
150 """Tracked plus untracked-but-not-ignored paths matching `pathspec`.
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.
157 git_tool = shutil.which(
"git")
or "git"
158 proc = subprocess.run(
159 [git_tool,
"ls-files",
"--cached",
"--others",
"--exclude-standard",
"--", *pathspec],
165 if proc.returncode != 0:
166 sys.stderr.write(proc.stderr)
167 sys.stderr.write(f
"git ls-files failed (exit {proc.returncode})\n")
169 return _existing_worktree_paths(proc.stdout.splitlines())
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()]
177def _has_shell_shebang(rel: str) -> bool:
178 """True when `rel` opens with a ``#!`` line naming sh, bash or zsh."""
180 with (REPO_ROOT / rel).open(
"rb")
as handle:
181 first = handle.readline(200)
184 if not first.startswith(b
"#!"):
186 line = first.decode(
"utf-8", errors=
"replace")
187 return any(tok
in line
for tok
in (
"bash",
"zsh",
"/sh",
"env sh"))
190def first_party_scripts() -> list[str]:
191 """First-party shell scripts: by ``*.sh`` suffix OR by shebang.
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,
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)]
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)
213def _run_shellcheck(tool: str, files: list[str], cwd: Path |
None =
None) -> Findings:
214 proc = subprocess.run(
215 [tool, *_shellcheck_args(),
"-f",
"json", *files],
216 cwd=cwd
or REPO_ROOT,
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")
225 findings: Findings = {}
226 for item
in json.loads(proc.stdout
or "[]"):
228 code = f
"SC{item['code']}"
229 findings.setdefault(rel, {})
230 findings[rel][code] = findings[rel].get(code, 0) + 1
244SELFTEST_CASES: tuple[tuple[str, str, str, bool], ...] = (
247 "SC2086 unquoted expansion (info-level: invisible at the old warning bar)",
249 "#!/usr/bin/env bash\nf=/a/b\nrm -f $f\n",
253 "SC2006 legacy backticks (style-level: needs severity=style)",
255 '#!/usr/bin/env bash\nd="`date`"\necho "$d"\n',
259 "SC2248 unquoted safe variable (opt-in: quote-safe-variables)",
261 "#!/usr/bin/env bash\nrc=0\nexit $rc\n",
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',
271 "SC2230 which instead of command -v (opt-in: deprecate-which)",
273 "#!/usr/bin/env bash\nwhich gcc >/dev/null\n",
277 "SC2002 useless cat (opt-in: useless-use-of-cat)",
279 "#!/usr/bin/env bash\ncat /etc/hosts | grep -q localhost\n",
283 "SC2244 nullary condition (opt-in: avoid-nullary-conditions)",
285 '#!/usr/bin/env bash\nv=x\nif [ "$v" ]; then echo hi; fi\n',
289 "SC2164 cd without a failure guard (warning-level baseline)",
291 '#!/usr/bin/env bash\ncd /nonexistent-selftest-dir\necho "ran on regardless"\n',
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",
304 "printf with a constant format and variable arguments",
306 "#!/usr/bin/env bash\nset -euo pipefail\ncolor=$'\\033[0;32m'\n"
307 'printf \'%sdone%s\\n\' "$color" "$color"\n',
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",
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"
329def selftest(tmp: Path) -> int:
330 """Assert the gate fires on every enforced class and stays quiet otherwise."""
331 sc_tool = _find(
"SHELLCHECK",
"shellcheck")
333 sys.stderr.write(
"check_shell.py --selftest: shellcheck not found\n")
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")
343 for label, fname, body, must_fire
in SELFTEST_CASES:
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 ''}")
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])
356 sys.stderr.write(
"check_shell.py --selftest: FAILED\n\n")
357 sys.stderr.write(
"\n".join(failures) +
"\n")
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
363 f
"check_shell.py --selftest: PASS "
364 f
"({fires + quiets} cases: {fires} must fire, {quiets} must stay quiet)"
369def _report(checks: Findings) ->
None:
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")
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"
381def main(argv: list[str]) -> int:
382 """Run shellcheck over every first-party worktree shell script.
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.
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.
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.
398 Returns 0 when clean, 1 on findings, 2 when the
399 scope collapsed below SCRIPT_FLOOR, and 1 on a missing tool under
402 if "--selftest" in argv[1:]:
403 with tempfile.TemporaryDirectory()
as td:
404 return selftest(Path(td))
410 if "--list-files" in argv[1:]:
411 print(
"\n".join(first_party_scripts()))
414 sc_tool = _find(
"SHELLCHECK",
"shellcheck")
416 msg =
"check_shell.py: shellcheck not found"
417 if "--require" in argv[1:]:
418 sys.stderr.write(msg +
" (--require set)\n")
420 print(msg +
" -- skipping (install to enforce locally).")
423 files = first_party_scripts()
424 if len(files) < SCRIPT_FLOOR:
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"
431 checks = _run_shellcheck(sc_tool, files)
434 f
"check_shell.py: clean ({len(files)} file(s), "
435 f
"severity={SHELLCHECK_SEVERITY} + {len(SHELLCHECK_ENABLE)} opt-in check(s))."
442if __name__ ==
"__main__":
443 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.