#!/usr/bin/env python3 """Reject Bats assertions that cannot fail. Bats runs test bodies under `set -e`, but bash 3.2, which is /bin/bash on macOS and what `env bash` resolves to there, does not trigger errexit for a failing `[[ ]]` or a failing `(( ))` comparison. Only the last statement's status decides the test, so a bare `[[ ]]` or `(( a < b ))` anywhere before it asserts nothing. A `(( n++ ))` without a comparison is arithmetic, not an assertion, and is left alone. A `!`-negated pipeline never triggers errexit in any bash, so a bare `! cmd` before the last statement asserts nothing either. All three rules also cover the body of a heredoc fed to a shell (`run bash <<'EOF'`), which is judged only by its own last statement. Function bodies are mocks rather than assertions, and other heredocs are data, so both are skipped. A statement runs across line continuations and open quotes, so a multi-line `bash -c "..."` script is one statement of its test. """ from __future__ import annotations import re import sys from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] TEST_START = re.compile(r"^@test\s.*\{\s*$") HEREDOC = re.compile(r"(?>`, `<<=`, `>>=`): `(( n++ ))`, `(( n += 1 ))` # and `(( n <<= 1 ))` only compute. ARITHMETIC_COMPARISON = re.compile(r"==|!=|(?])<=|(?])>=|(?])[<>](?![<>=])") def is_bare_assertion(statement: str) -> bool: return statement.startswith("[[") and statement.endswith("]]") def is_bare_arithmetic(statement: str) -> bool: return ( statement.startswith("((") and statement.endswith("))") and ARITHMETIC_COMPARISON.search(statement) is not None ) def is_bare_negation(statement: str) -> bool: return statement.startswith("! ") and "||" not in statement def scan_quotes(line: str, open_quotes: tuple[str, ...]) -> tuple[str, ...]: """Return the quoting still open after `line`, innermost last. Each entry is a quote (`'`, `$'`, `"`) or a command substitution (`$(`), which starts a fresh unquoted context inside double quotes. """ stack = list(open_quotes) index = 0 while index < len(line): char = line[index] top = stack[-1] if stack else "" if top == "'": if char == "'": stack.pop() elif top == "$'": if char == "\\": index += 1 elif char == "'": stack.pop() elif top == '"': if char == "\\": index += 1 elif char == '"': stack.pop() elif line.startswith("$(", index): stack.append("$(") index += 1 elif char == "\\": index += 1 elif char == "#" and (index == 0 or line[index - 1].isspace()): break elif line.startswith("$'", index): stack.append("$'") index += 1 elif line.startswith("$(", index): stack.append("$(") index += 1 elif char in "'\"": stack.append(char) elif top and char == "(": stack.append("(") elif top and char == ")": stack.pop() index += 1 return tuple(stack) class Body: """Top-level statements of a test body or of a shell heredoc body.""" def __init__(self, shell_heredoc: bool) -> None: self.shell_heredoc = shell_heredoc self.statements: list[tuple[int, str]] = [] self.buffer: list[str] = [] self.buffer_start = 0 self.quote: tuple[str, ...] = () self.function_depth = 0 self.terminator = "" self.child: Body | None = None def idle(self) -> bool: return self.child is None and not self.terminator and not self.buffer and not self.function_depth def findings(self) -> list[tuple[int, str]]: earlier = self.statements[:-1] found = [(line, "negation") for line, text in earlier if is_bare_negation(text)] found += [(line, "bracket") for line, text in earlier if is_bare_assertion(text)] found += [(line, "arithmetic") for line, text in earlier if is_bare_arithmetic(text)] return found def feed(self, number: int, raw: str, findings: list[tuple[int, str]]) -> None: stripped = raw.strip() if self.child is not None: if stripped == self.terminator: findings.extend(self.child.findings()) self.child = None self.terminator = "" else: self.child.feed(number, raw, findings) return if self.terminator: if stripped == self.terminator: self.terminator = "" return if not self.buffer and (not stripped or stripped.startswith("#")): return if not self.buffer: self.buffer_start = number self.buffer.append(stripped) self.quote = scan_quotes(stripped, self.quote) heredoc = HEREDOC.search(stripped) if heredoc: # The heredoc body starts on the next line even when the command # that reads it, such as `x="$(bash <<'EOF'`, continues after it. self.terminator = heredoc.group(2) opener = " ".join(self.buffer) opener = opener[: opener.find("<<")] if not self.function_depth and SHELL_COMMAND.search(opener) and not opener.startswith("cat "): self.child = Body(shell_heredoc=True) if self.quote or stripped.endswith(CONTINUATION): return statement = " ".join(self.buffer) self.buffer = [] if self.function_depth: if statement.endswith("{"): self.function_depth += 1 elif statement.startswith("}"): self.function_depth -= 1 return if FUNCTION_START.match(statement): if not statement.endswith("}"): self.function_depth = 1 return self.statements.append((self.buffer_start, statement)) def inspect_file(path: Path) -> list[tuple[int, str]]: findings: list[tuple[int, str]] = [] body: Body | None = None for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): if body is not None and body.terminator: body.feed(number, raw, findings) continue if TEST_START.match(raw): if body is not None: findings.append((number, "unparsed")) body = Body(shell_heredoc=False) continue if body is None: continue if raw == "}" and body.idle(): findings.extend(body.findings()) body = None continue body.feed(number, raw, findings) return sorted(findings) MESSAGES = { "bracket": "bare [[ ]] before the last statement never fails on bash 3.2; append '|| return 1', or '|| exit 1' in a heredoc script", "arithmetic": "bare (( )) comparison before the last statement never fails on bash 3.2; append '|| return 1', or '|| exit 1' in a heredoc script", "unparsed": "the previous test never closed; an unbalanced quote, heredoc, or function body hid it from this audit", "negation": "bare '! cmd' before the last statement never fails; append '|| return 1', or '|| exit 1' in a heredoc script", } def main(argv: list[str]) -> int: sources = [Path(arg).resolve() for arg in argv] if argv else sorted((PROJECT_ROOT / "tests").glob("*.bats")) findings = [(source, line, kind) for source in sources for line, kind in inspect_file(source)] if findings: for source, line, kind in findings: print(f"{source}:{line}: {MESSAGES[kind]}") return 1 print(f"bats-assertion-audit-ok files={len(sources)}") return 0 if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))