4"""Gate: no first-party shell function is called with its errexit masked.
8Under ``set -e``, calling a function on the left of ``||`` puts it into bash's
9inherited "ignoring errors" state::
11 run_suite "$fast" || rc=$?
13Two things then go wrong. A failure part-way through the function body no
14longer aborts it, so every remaining statement still runs and the caller sees
15only the *last* command's status. Worse, that state propagates into nested
16subshells where a plain ``set -e`` cannot clear it -- ``$-`` reports ``e`` set
17while a failing command still does not abort. This tree shipped exactly that:
18the gate suite silently degraded to "did each gate's LAST command succeed", so
19a gate failing part-way -- including ``require_cmd`` reporting an absent tool
22The remedy, already documented at the ``run_suite`` call site in
23``scripts/ci.sh``, is to disable errexit around the CALL only::
30The callee then runs in a normal errexit context, so its own ``( set -e; ... )``
31subshells re-arm and a mid-body failure is not swallowed.
33Why this check and not ShellCheck's SC2310
34------------------------------------------
35``check-set-e-suppressed`` covers this defect but is unsatisfiable here: it
36fires on any function in a condition, including a bare one-line predicate and
37the rewrite its own help text recommends. Measured on this tree it produces 90
38findings, and the only two source forms it accepts are worse than what it
39rejects -- ``set +e; fn; rc=$?; set -e`` (which it passes) and a bare subshell
40(which aborts the parent). Adopting it would mean ~90 inline disables rather
41than ~90 fixes. See #363 for the full form-by-form evidence.
43This check keeps the signal and drops the noise: it fires only where a
44first-party function whose body has more than one command is invoked with its
45status masked. A one-command predicate is exempt by construction -- there is
46no statement after the failure for errexit to have protected.
50 check_errexit_masking.py # gate (fail on any finding)
51 check_errexit_masking.py --selftest # prove it fires AND stays quiet
53Exit 0 if clean, exit 1 on findings, exit 2 on an internal error.
56from __future__
import annotations
61from pathlib
import Path
63REPO_ROOT = Path(__file__).resolve().parents[2]
64sys.path.insert(0, str(Path(__file__).resolve().parent))
74SELFTEST_EXPECTED_HITS = 3
76_DEF_RE = re.compile(
r"^\s*(?:function\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(\)\s*[({]")
82 r"(?:^|[;&|()]|\bthen\b|\bdo\b|\belse\b)\s*"
83 r"([A-Za-z_][A-Za-z0-9_]*)"
85 r"\|\|\s*(?:true\b|:\s|:$|[A-Za-z_][A-Za-z0-9_]*=\$\?)"
89def _strip(line: str) -> str:
90 """Drop comments and neutralise quoted spans, preserving column count."""
92 quote: str |
None =
None
93 for index, char
in enumerate(line):
101 elif char ==
"#" and (index == 0
or line[index - 1].isspace()):
111_BLOCK_OPEN = frozenset({
"if",
"while",
"until",
"for",
"case"})
112_BODY_START = frozenset({
"then",
"do",
"in"})
113_BLOCK_CLOSE = frozenset({
"fi",
"done",
"esac"})
114_KEYWORD_RE = re.compile(
r"^(if|elif|while|until|for|case|then|do|else|fi|done|esac|\{|\})\b\s*")
117def _tokens(body: str) -> list[str]:
118 r"""Split a function body into statements at separator positions.
120 Newlines and `;` only separate at paren depth zero: a multi-line array
121 literal (`local -a args=(\n --hex ...\n)`) is ONE assignment, not one
124 joined = re.sub(
r"\\\n",
" ", body)
131 depth = max(0, depth - 1)
132 if char
in "\n;" and depth > 0:
137 for raw
in re.split(
r"[\n;]",
"".join(flat)):
140 match = _KEYWORD_RE.match(piece)
143 out.append(match.group(1))
144 piece = piece[match.end() :].strip()
155_ASSIGN_RE = re.compile(
156 r"^(?:(?:local|declare|readonly|export|typeset)\s+(?:-\w+\s+)*)?"
157 r"[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]]*\])?\+?=|"
158 r"^(?:local|declare|readonly|export|typeset)\s+-?\w*\s*[A-Za-z_]"
160_SUBST_RE = re.compile(
r"\$\(|`")
163def _is_failable(token: str) -> bool:
164 """False for assignments that cannot fail -- no substitution, no exit status."""
165 if _SUBST_RE.search(token):
167 return _ASSIGN_RE.match(token)
is None
170def _max_sequential(body: str) -> int:
171 """Max commands executed sequentially on any ONE path through `body`.
173 This is the number that decides whether errexit had anything to protect:
174 with two or more commands in a row, a failure in the first still lets the
175 rest run and the caller sees only the last one's status.
177 stack: list[list[int]] = []
180 for word
in _tokens(body):
181 if word
in _BLOCK_OPEN:
182 stack.append([0, run])
185 elif word
in _BODY_START:
187 elif word
in (
"elif",
"else"):
189 stack[-1][0] = max(stack[-1][0], run)
191 skipping = word ==
"elif"
192 elif word
in _BLOCK_CLOSE:
194 best, saved = stack.pop()
195 run = saved + max(best, run)
197 elif word
not in (
"{",
"}")
and not skipping
and _is_failable(word):
200 best, saved = stack.pop()
201 run = saved + max(best, run)
205def _function_sizes(lines: list[str]) -> dict[str, int]:
206 """Map every function defined in `lines` to its max sequential command count."""
207 sizes: dict[str, int] = {}
209 while index < len(lines):
210 match = _DEF_RE.match(lines[index])
214 end, body = _body_span(lines, index)
215 sizes[match.group(1)] = _max_sequential(body)
220def _body_span(lines: list[str], start: int) -> tuple[int, str]:
221 """Return (last line index, body text) for the function opening at `start`."""
224 collected: list[str] = []
226 while index < len(lines):
227 stripped = _strip(lines[index])
228 for char
in stripped:
234 collected.append(stripped.split(
"{", 1)[-1]
if index == start
else stripped)
235 if opened
and depth <= 0:
236 return index,
"\n".join(collected)
238 return index - 1,
"\n".join(collected)
241def _scan_file(rel: str) -> list[str]:
242 """Return one message per errexit-masked first-party call in `rel`."""
243 text = (REPO_ROOT / rel).read_text(encoding=
"utf-8", errors=
"replace")
244 lines = text.splitlines()
245 sizes = _function_sizes(lines)
246 findings: list[str] = []
247 for number, line
in enumerate(lines, 1):
248 for match
in _MASK_RE.finditer(_strip(line)):
249 name = match.group(1)
250 size = sizes.get(name)
251 if size
is None or size < MIN_BODY_COMMANDS:
254 f
"{rel}:{number}: `{name}` (~{size} commands) is invoked with its "
255 f
"exit status masked; a failure part-way through its body is "
256 f
"silently swallowed.\n"
258 f
" Use: set +e; {name} ...; rc=$?; set -e"
263def _targets() -> list[str]:
264 """First-party shell scripts -- the same scope `check_shell.py` gates.
266 Imported rather than re-derived: a second copy of the scope list is how a
267 file ends up covered by one shell gate and invisible to the other.
271 return check_shell.first_party_scripts()
274_SELFTEST_BAD =
"""#!/usr/bin/env bash
280predicate() { [ -e /tmp ]; }
284 go) work "$@" || rc=$? ;;
288if predicate; then echo yes; fi
289grep -q x /etc/hosts || rc=$?
292_SELFTEST_GOOD =
"""#!/usr/bin/env bash
298predicate() { [ -e /tmp ]; }
303if predicate; then echo yes; fi
304grep -q x /etc/hosts || rc=$?
305external_tool --flag || true
309def _selftest() -> int:
310 """Assert the check fires on the defect AND stays silent on the remedy."""
311 failures: list[str] = []
312 with tempfile.TemporaryDirectory(dir=REPO_ROOT)
as tmp:
314 bad = holder /
"bad.sh"
315 good = holder /
"good.sh"
316 bad.write_text(_SELFTEST_BAD, encoding=
"utf-8")
317 good.write_text(_SELFTEST_GOOD, encoding=
"utf-8")
318 bad_hits = _scan_file(str(bad.relative_to(REPO_ROOT)))
319 good_hits = _scan_file(str(good.relative_to(REPO_ROOT)))
322 if len(bad_hits) != SELFTEST_EXPECTED_HITS:
324 f
"must-fire: expected {SELFTEST_EXPECTED_HITS} findings on the bad "
325 f
"fixture, got {len(bad_hits)}"
327 if any(
"predicate" in hit
for hit
in bad_hits):
328 failures.append(
"must-fire: flagged the one-command predicate, which is exempt")
329 if any(
"grep" in hit
for hit
in bad_hits):
330 failures.append(
"must-fire: flagged an external command, which is out of scope")
334 failures.append(f
"must-be-silent: {len(good_hits)} finding(s) on the good fixture")
336 for line
in failures:
337 sys.stderr.write(f
"check_errexit_masking.py: SELFTEST FAIL -- {line}\n")
341 f
"check_errexit_masking.py: selftest OK "
342 f
"(fires on {len(bad_hits)} masked calls, silent on the remedy)\n"
348 """Entry point: `--selftest` proves non-vacuity, otherwise gate the tree."""
349 if "--selftest" in sys.argv[1:]:
351 findings: list[str] = []
352 for rel
in _targets():
353 findings.extend(_scan_file(rel))
355 sys.stderr.write(
"check_errexit_masking.py: errexit-masked first-party call(s):\n\n")
356 for finding
in findings:
357 sys.stderr.write(f
" {finding}\n\n")
359 f
"{len(findings)} finding(s). Disabling errexit around the CALL keeps the\n"
360 "callee in a normal errexit context; `||` does not, and the state it sets\n"
361 "propagates into nested subshells that `set -e` cannot rescue.\n"
367if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.