Files
Agent-System-Dev/tests/test_shell_pipe_hazard.py
T

638 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""托管 shell 脚本:管道任一读取级不得是提前退出的读取器(READER_FAMILIES 声明的各族:grep -q/-m、head、sed q、
awk exit/nextfile、read、mapfile/readarray -n、带 break 的 while/until 循环;含被子壳或花括组包裹、带绝对路径或
exec 等前缀的写法)。守护的承诺是“已声明的各族在发现到的脚本中处处被拦截”,而非“理解任意 bash 命令”。
pipefail 下读取器先退出,上游生产者写满 64 KiB 管道缓冲后收到 SIGPIPE,管道状态报 141,
`if producer | grep -q PAT` 与 `if ! ...` 的判定被静默翻转;读取器位于管道中段
(`producer | head -1 | sed ...`)时同理。改用 here-string、进程替换或 sed 打印即退出,
读取器的结论才不会丢。
"""
import re
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT_GLOBS = (":(glob)scripts/**/*.sh", ":(glob)scripts/hooks/*")
EXTRA_SCRIPTS = ("docker/verify-env.sh", "tests/load/collaboration/run_pgbench.sh")
# 发现到但检测器无法正确解析的脚本:路径 -> 理由。条目须仍被发现,否则视为过期。
EXEMPT = {}
READER_FAMILIES = (
("grep", "-q/--quiet/--silent、-m/--max-count(含 egrep/fgrep)", "得出匹配结论或读满 N 条匹配即退出"),
("head", "任意用法", "输出前 N 行/字节后即退出"),
("sed", "q/Q 命令", "执行 q/Q 即停止读取"),
("awk", "END 块之外的 exit/nextfile(含 gawk/mawk/nawk)", "exit 立即结束,nextfile 放弃当前输入剩余部分"),
("read", "任意用法(含 -n N/--chars N)", "只读一行,-n N 读满 N 个字符即返回"),
("mapfile", "-n/-n<N>/-n <N>/--n/--numlines(含 readarray)", "读满 N 行即停止;长选项被 bash 拒绝时一字节不读即退出"),
("while", "循环体 break 出本循环(含 until)", "break 后不再读取剩余输入"),
)
"""守护覆盖的提前退出读取器族:(命令, 触发选项, 提前退出的原因)。
检测器保证这些族(含子壳/花括组包裹、VAR= 与 env/exec 等前缀、绝对路径写法)位于管道读取级时处处被拦截;
不带触发选项的 mapfile/readarray、无 break 的 while read 读完全部输入,不在拦截之列。
"""
HEREDOC = re.compile(r"<<(-?)\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2")
SED_QUIT = re.compile(r"(?:^|[\s;{}'\"/$0-9])[qQ][0-9]*\s*(?:$|[\s;}'\"])")
AWK_END = re.compile(r"\bEND\s*\{")
AWK_EXIT = re.compile(r"\b(?:exit|nextfile)\b")
ASSIGNMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*=")
WORD = re.compile(r"(\S+)\s*")
BREAK_ARG = re.compile(r"[ \t]+([0-9]+)")
PREFIXES = {"!", "time", "command", "builtin", "env", "exec"}
GREP_ARG_OPTS = set("efABCdDX")
MAPFILE_ARG_OPTS = set("dOsuCc")
BOUNDARY = set(" \t\n;&|()<>")
def managed_scripts():
"""git 跟踪的托管 shell 脚本;未跟踪副本(如 runtime/ 下的陈旧克隆)不在其中。"""
out = subprocess.run(
["git", "-C", str(ROOT), "ls-files", "-z", "--", *SCRIPT_GLOBS, *EXTRA_SCRIPTS],
check=True, capture_output=True, text=True,
).stdout
return sorted(p for p in out.split("\0") if p)
class _Frame:
"""一层命令上下文(顶层、$(...)、(...)、<(...)、管道级的 { ...; }、while/until 循环)内正在累积的管道。"""
def __init__(self, line, depth, closer=None):
self.stages, self.stage, self.start, self.cases = [], [], line, []
self.depth, self.closer, self.dos, self.broken = depth, closer, 0, False
def _lex(src):
"""词法扫描 bash 源码;跳过注释与 heredoc 正文。
返回 (lists, loops):lists 为每个命令列表的 (帧深度, [(起始行号, 级文本), ...]),
loops 为每个 while/until 循环的 (帧深度, 循环体是否 break 出本循环)。
"""
found, loops, frames, modes, heredocs = [], [], [_Frame(1, 0)], ["cmd"], []
line, word, i, n = 1, "", 0, len(src)
def emit(s):
for f in frames:
f.stage.append(s)
def finish(f):
text = "".join(f.stage).strip()
if f.stages or text:
found.append((f.depth, f.stages + [(f.start, text)]))
f.stages, f.stage, f.start = [], [], line
def push(closer):
modes.append("cmd")
frames.append(_Frame(line, len(frames), closer))
def pop():
finish(frames[-1])
f = frames.pop()
modes.pop()
if f.closer == "done":
loops.append((f.depth, f.broken))
def case_state():
cases = frames[-1].cases
return cases[-1] if cases else None
def command_position(w):
j = i - len(w)
while j > 0 and src[j - 1] in " \t":
j -= 1
if j == 0 or src[j - 1] in ";\n&|(":
return True
k = j
while k > 0 and src[k - 1] not in BOUNDARY:
k -= 1
return src[k:j] in ("do", "then", "else", "{", "!")
def break_out(levels):
for f in reversed(frames):
nested = f.dos - (f.closer == "done" and f.dos > 0)
if levels <= nested:
return
levels -= nested
if f.closer == "done":
f.broken = True
levels -= 1
if levels <= 0:
return
elif f.closer != "}":
return
def keyword(w):
top = frames[-1]
cases = top.cases
if w == "case":
cases.append("in")
elif w == "in" and case_state() == "in":
cases[-1] = "pattern"
elif w == "esac" and cases:
cases.pop()
elif case_state() == "pattern":
pass
elif w in ("while", "until") and "".join(top.stage).strip() == w:
push("done")
elif w == "do" and command_position(w):
top.dos += 1
elif w == "done" and command_position(w):
top.dos -= 1
if top.closer == "done" and top.dos <= 0:
pop()
elif w == "break" and command_position(w):
m = BREAK_ARG.match(src, i)
break_out(int(m.group(1)) if m else 1)
def push_cmd(opener, closer=")"):
emit(opener)
push(closer)
while i < n:
c, nxt = src[i], src[i + 1 : i + 2]
mode = modes[-1]
if mode in ("sq", "ansi"):
if c == "\\" and mode == "ansi":
emit(src[i : i + 2])
i += 2
continue
emit(c)
line += c == "\n"
if c == "'":
modes.pop()
i += 1
continue
if mode in ("dq", "brace"):
if c == "\\":
emit(src[i : i + 2])
line += nxt == "\n"
i += 2
elif c == "$" and nxt == "(":
push_cmd("$(")
i += 2
elif c == "$" and nxt == "{":
emit("${")
modes.append("brace")
i += 2
elif c == '"':
emit(c)
if mode == "dq":
modes.pop()
else:
modes.append("dq")
i += 1
elif c == "}" and mode == "brace":
emit(c)
modes.pop()
i += 1
else:
emit(c)
line += c == "\n"
i += 1
continue
if c in BOUNDARY and word:
keyword(word)
word = ""
top = frames[-1]
if c == "\\":
if nxt == "\n":
emit(" ")
line += 1
else:
emit(src[i : i + 2])
word += "\0"
i += 2
elif c == "#" and (i == 0 or src[i - 1] in " \t\n;&|("):
j = src.find("\n", i)
i = n if j < 0 else j
elif c == "'" or (c == "$" and nxt == "'"):
emit(src[i : i + 2] if c == "$" else c)
modes.append("ansi" if c == "$" else "sq")
word += "\0"
i += 2 if c == "$" else 1
elif c == '"':
emit(c)
modes.append("dq")
word += "\0"
i += 1
elif c == "$" and nxt == "{":
emit("${")
modes.append("brace")
word += "\0"
i += 2
elif c == "$" and nxt == "(":
push_cmd("$(")
word += "\0"
i += 2
elif c == "(":
if case_state() == "pattern":
emit(c)
else:
push_cmd(c)
i += 1
elif c == ")":
if case_state() == "pattern":
top.cases[-1] = "body"
elif top.closer == ")":
pop()
emit(c)
i += 1
elif c == "{" and not word and nxt in (" ", "\t", "\n") and top.stages and not "".join(top.stage).strip():
push_cmd(c, "}")
i += 1
elif c == "}" and not word and top.closer == "}" and (nxt == "" or nxt in BOUNDARY) and command_position(""):
pop()
emit(c)
i += 1
elif c == "|":
if nxt == "|":
finish(top)
emit("||")
i += 2
elif case_state() == "pattern":
emit(c)
i += 1
else:
top.stages.append((top.start, "".join(top.stage).strip()))
emit(c)
top.stage, top.start = [], line
i += 2 if nxt == "&" else 1
elif c == ";":
if src.startswith(";;", i) or src.startswith(";&", i):
if case_state() == "body":
top.cases[-1] = "pattern"
k = 3 if src.startswith(";;&", i) else 2
else:
k = 1
finish(top)
emit(src[i : i + k])
i += k
elif c == "&":
if nxt == "&":
finish(top)
emit("&&")
i += 2
elif nxt == ">" or (i > 0 and src[i - 1] in "<>"):
emit(c)
i += 1
else:
finish(top)
emit(c)
i += 1
elif c == "<" and src.startswith("<<", i):
m = None if src.startswith("<<<", i) else HEREDOC.match(src, i)
if m:
heredocs.append((m.group(3), m.group(1) == "-"))
emit(m.group(0))
i = m.end()
else:
k = 3 if src.startswith("<<<", i) else 2
emit(src[i : i + k])
i += k
elif c == "\n":
line += 1
i += 1
for term, dash in heredocs:
while i < n:
j = src.find("\n", i)
body = src[i : n if j < 0 else j]
i = n if j < 0 else j + 1
line += 1
if (body.lstrip("\t") if dash else body) == term:
break
heredocs = []
if not (top.stages and not "".join(top.stage).strip()):
finish(top)
emit("\n")
else:
emit(c)
if c not in BOUNDARY:
word += c
i += 1
if word:
keyword(word)
if len(frames) != 1 or modes != ["cmd"]:
raise ValueError(f"unbalanced shell lexing at EOF: modes={modes}")
finish(frames[0])
return found, loops
def pipelines(src):
"""返回 bash 源码中每条多级管道各级的 [(起始行号, 文本), ...]。"""
return [stages for _, stages in _lex(src)[0] if len(stages) > 1]
def _grep_exits_early(words):
for w in words[1:]:
w = w.strip("'\"")
if w == "--":
break
if w.startswith("--"):
if w.split("=")[0] in ("--quiet", "--silent", "--max-count"):
return True
elif w.startswith("-") and len(w) > 1:
for ch in w[1:]:
if ch in "qm":
return True
if ch in GREP_ARG_OPTS:
break
return False
def _mapfile_exits_early(words):
"""mapfile/readarray 带 -n(-n<N>、-n <N>、选项簇内的 n)或 --n/--numlines 时只读 N 行即停止。"""
skip = False
for w in words[1:]:
w = w.strip("'\"")
if skip:
skip = False
continue
if w == "--" or not w.startswith("-") or len(w) == 1:
break
if w.startswith("--"):
if w.split("=")[0] in ("--n", "--numlines"):
return True
continue
for k, ch in enumerate(w[1:], 1):
if ch == "n":
return True
if ch in MAPFILE_ARG_OPTS:
skip = k == len(w) - 1
break
return False
def _awk_exits_early(prog):
"""awk 程序在 END 块之外含 exit 或 nextfile(含 BEGIN 内),即可能在读完输入前停止读取。"""
while m := AWK_END.search(prog):
depth, j = 0, m.end() - 1
while j < len(prog):
depth += {"{": 1, "}": -1}.get(prog[j], 0)
j += 1
if depth == 0:
break
prog = prog[: m.start()] + prog[j:]
return bool(AWK_EXIT.search(prog))
def _group_exits_early(group):
"""( ... ) 或 { ...; } 内任一命令列表的首级(读组 stdin 者)是提前退出的读取器。"""
end = group.rfind(")" if group[0] == "(" else "}")
inner = group[1:end] if end > 0 else group[1:]
return any(
early_exit_reader(stages[0][1].lstrip(";&| \t\n")) for depth, stages in _lex(inner)[0] if depth == 0
)
def early_exit_reader(stage):
"""读取级读到结论后是否会提前关闭 stdin(READER_FAMILIES:grep -q/-m、head、sed q、awk exit/nextfile、read、
mapfile/readarray -n、循环体 break 出本循环的 while/until)。
先剥去 VAR=value 与 !/time/command/builtin/env/exec 前缀、取命令基名;子壳或花括组展开后逐条判定。
"""
s = stage.strip()
while s:
if s[0] in "({":
return _group_exits_early(s)
m = WORD.match(s)
cmd = m.group(1).rsplit("/", 1)[-1]
if not (ASSIGNMENT.match(m.group(1)) or cmd in PREFIXES):
break
s = s[m.end() :]
if not s:
return False
if cmd in ("while", "until"):
return any(broken for depth, broken in _lex(s)[1] if depth == 1)
words = [cmd, *s[m.end() :].split()]
rest = " ".join(words[1:])
if cmd in ("grep", "egrep", "fgrep"):
return _grep_exits_early(words)
if cmd in ("head", "read"):
return True
if cmd in ("mapfile", "readarray"):
return _mapfile_exits_early(words)
if cmd == "sed":
return bool(SED_QUIT.search(rest))
if cmd in ("awk", "gawk", "mawk", "nawk"):
return _awk_exits_early(rest)
return False
def hazards(src):
"""各管道第二级起(读 pipe 的各级)中提前退出的读取器,返回 (行号, 该级文本)。"""
return [
(line, text)
for stages in pipelines(src)
for line, text in stages[1:]
if early_exit_reader(text)
]
class ShellPipeHazardTests(unittest.TestCase):
def test_detector_flags_synthetic_hazard(self):
found = hazards("set -euo pipefail\nseq 1 200000 | grep -q 1\n")
self.assertEqual([line for line, _ in found], [2], found)
def test_detector_flags_hazard_shapes(self):
for snippet in (
'if ! seq 1 200000 | grep -q 1; then echo miss; fi',
'x="$(sed -n \'s/^k=//p\' "$f" | head -1)"',
'if ! "$bin" serve -h 2>&1 | grep -Eq -- "(^|[[:space:]])-{1,2}config"; then :; fi',
'echo "${r:+($(printf \'%s\' "$r" | head -c 120))}"',
'find . -type f \\\n | grep -m1 x',
"seq 1 200000 | sed -n '/1/{p;q;}'",
'seq 1 200000 | grep -qiE "a|b" || exit 1',
):
with self.subTest(snippet=snippet):
self.assertTrue(hazards(snippet + "\n"), snippet)
def test_detector_flags_newly_covered_shapes(self):
for snippet, stage in (
("seq 1 200000 | head -1 | sed 's/^/x/'", "head -1"),
("x=\"$(seq 1 200000 | head -n 3 | tr -d ' ')\"", "head -n 3"),
("seq 1 200000 | awk '{print $1; exit}'", "awk '{print $1; exit}'"),
("seq 1 200000 | awk 'BEGIN { exit 1 }'", "awk 'BEGIN { exit 1 }'"),
("seq 1 200000 | awk '/7/{f=1; exit} END{print f}' | cat", "awk '/7/{f=1; exit} END{print f}'"),
("seq 1 200000 | read -r first", "read -r first"),
("seq 1 200000 | IFS= read -r first", "IFS= read -r first"),
("if seq 1 200000 | LC_ALL=C grep -q 1; then :; fi", "LC_ALL=C grep -q 1"),
("seq 1 200000 | env LC_ALL=C grep -m1 1 | wc -l", "env LC_ALL=C grep -m1 1"),
("seq 1 200000 | grep -q 1 | cat", "grep -q 1"),
):
with self.subTest(snippet=snippet):
self.assertEqual(hazards(snippet + "\n"), [(1, stage)], snippet)
def test_wrapped_and_prefixed_readers_flagged_and_harmful(self):
"""子壳/花括组、awk nextfile、带 break 的 while read、绝对路径与 exec 前缀:检测器命中,且 pipefail 下确实报 141。"""
grep = shutil.which("grep") or "/usr/bin/grep"
shapes = (
("seq 1 200000 | (head -1)", "(head -1)"),
("seq 1 200000 | ( head -1 )", "( head -1 )"),
("seq 1 200000 | { head -1; }", "{ head -1; }"),
("seq 1 200000 | awk '$1 == 7 { print; nextfile }'", "awk '$1 == 7 { print; nextfile }'"),
("seq 1 200000 | while read -r l; do break; done", "while read -r l; do break; done"),
(f"seq 1 200000 | {grep} -q 1", f"{grep} -q 1"),
("seq 1 200000 | exec grep -q 1", "exec grep -q 1"),
)
with tempfile.TemporaryDirectory(dir="/tmp") as tmp:
for snippet, stage in shapes:
with self.subTest(snippet=snippet):
self.assertEqual(hazards(snippet + "\n"), [(1, stage)], snippet)
script = Path(tmp) / "shape.sh"
script.write_text(f"set -euo pipefail\n{snippet}\necho survived\n", encoding="utf-8")
run = subprocess.run(["bash", str(script)], capture_output=True, text=True)
self.assertEqual(run.returncode, 141, run)
self.assertNotIn("survived", run.stdout)
def test_detector_flags_group_loop_and_prefix_variants(self):
for snippet, stage in (
("seq 1 200000 | {\n head -1\n} | cat", "{\n head -1\n}"),
("if seq 1 200000 | (grep -q 1); then :; fi", "(grep -q 1)"),
("seq 1 200000 | (cd /tmp && head -1)", "(cd /tmp && head -1)"),
("seq 1 200000 | while read -r l\ndo\n [ \"$l\" = 7 ] && break\ndone", "while read -r l\ndo\n [ \"$l\" = 7 ] && break\ndone"),
("seq 1 200000 | until ! read -r l; do break; done", "until ! read -r l; do break; done"),
("seq 1 200000 | while read -r l; do for x in a; do break 2; done; done", "while read -r l; do for x in a; do break 2; done; done"),
("seq 1 200000 | /usr/bin/env LC_ALL=C grep -q 1", "/usr/bin/env LC_ALL=C grep -q 1"),
("seq 1 200000 | exec /usr/bin/head -1", "exec /usr/bin/head -1"),
):
with self.subTest(snippet=snippet):
self.assertEqual(hazards(snippet + "\n"), [(1, stage)], snippet)
def test_counted_readers_flagged_and_harmful(self):
"""mapfile/readarray -n 与 read -n:检测器命中,且 pipefail 下脚本中止(读满 N 后退出报 141,长选项被拒报非零)。"""
shapes = (
("seq 1 200000 | mapfile -n 1 arr", "mapfile -n 1 arr", 141),
("seq 1 200000 | readarray --numlines 1 arr", "readarray --numlines 1 arr", None),
("seq 1 200000 | read -n 5 -r line", "read -n 5 -r line", 141),
)
with tempfile.TemporaryDirectory(dir="/tmp") as tmp:
for snippet, stage, rc in shapes:
with self.subTest(snippet=snippet):
self.assertEqual(hazards(snippet + "\n"), [(1, stage)], snippet)
script = Path(tmp) / "shape.sh"
script.write_text(f"set -euo pipefail\n{snippet}\necho survived\n", encoding="utf-8")
run = subprocess.run(["bash", str(script)], capture_output=True, text=True)
self.assertNotEqual(run.returncode, 0, run)
if rc is not None:
self.assertEqual(run.returncode, rc, run)
self.assertNotIn("survived", run.stdout)
script = Path(tmp) / "safe.sh"
script.write_text(
"set -euo pipefail\nseq 1 200000 > in.txt\nmapfile arr < in.txt\nread -r line < in.txt\n"
"seq 1 200000 | mapfile -t arr\necho \"survived ${#arr[@]} $line\"\n",
encoding="utf-8",
)
run = subprocess.run(["bash", str(script)], capture_output=True, text=True, cwd=tmp)
self.assertEqual((run.returncode, run.stdout), (0, "survived 200000 1\n"), run)
self.assertEqual(hazards(script.read_text(encoding="utf-8")), [])
def test_detector_flags_counted_reader_variants(self):
for snippet, stage in (
("seq 1 200000 | mapfile -n1 arr", "mapfile -n1 arr"),
("seq 1 200000 | mapfile -t -n 3 arr", "mapfile -t -n 3 arr"),
("seq 1 200000 | readarray -tn2 arr", "readarray -tn2 arr"),
("seq 1 200000 | mapfile -u 0 -n 1 arr", "mapfile -u 0 -n 1 arr"),
("seq 1 200000 | readarray --n 1 arr", "readarray --n 1 arr"),
("seq 1 200000 | builtin mapfile -n 1 arr", "builtin mapfile -n 1 arr"),
("seq 1 200000 | { mapfile -n 1 arr; }", "{ mapfile -n 1 arr; }"),
("seq 1 200000 | read --chars 5 line", "read --chars 5 line"),
):
with self.subTest(snippet=snippet):
self.assertEqual(hazards(snippet + "\n"), [(1, stage)], snippet)
def test_declared_reader_families_each_flagged(self):
"""READER_FAMILIES 是守护的契约:非空,且每个声明族至少有一个被检测器命中的合成用例。"""
cases = {
"grep": "seq 1 200000 | grep -q 1",
"head": "seq 1 200000 | head -1",
"sed": "seq 1 200000 | sed -n '/1/{p;q;}'",
"awk": "seq 1 200000 | awk '{print; exit}'",
"read": "seq 1 200000 | read -n 5 -r line",
"mapfile": "seq 1 200000 | mapfile -n 1 arr",
"while": "seq 1 200000 | while read -r l; do break; done",
}
self.assertTrue(READER_FAMILIES)
for family in READER_FAMILIES:
command = family[0]
with self.subTest(family=command):
self.assertEqual(len(family), 3)
self.assertTrue(all(family), family)
self.assertIn(command, cases, "declared family lacks a synthetic case")
self.assertTrue(hazards(cases[command] + "\n"), cases[command])
def test_detector_accepts_safe_forms(self):
src = "\n".join((
"set -euo pipefail",
"# seq 1 200000 | grep -q 1",
"grep -q 1 < <(seq 1 200000)",
'grep -q x <<<"$v"',
"x=\"$(sed -n '/^k=/{s///p;q;}' \"$f\")\"",
"x=\"$(sed -n '/^k=/{s///p;q;}' < <(seq 1 200000))\"",
"awk '{print $1; exit}' < <(seq 1 200000)",
"head -c 160 < <(seq 1 200000)",
"ps -eo comm | sed -n '1,12p'",
"seq 1 200000 | sed -n '1,12p' | tr -d ' '",
"seq 1 200000 | awk '{n++} END {exit !n}'",
"seq 1 200000 | awk '$1 == 7 { f = 1 } END { exit !f }' && echo hit",
"seq 1 200000 | while read -r l; do :; done",
"seq 1 200000 | while read -r l; do [ \"$l\" = 7 ] && continue; done",
"seq 1 200000 | while read -r l; do for x in a b; do break; done; done",
"while read -r l; do break; done < <(seq 1 200000)",
"seq 1 200000 | awk '{n++} END { nextfile }'",
"seq 1 200000 | (cat)",
"seq 1 200000 | { cat; }",
"seq 1 200000 | env FOO=1 command cat",
"seq 1 200000 | time builtin cat >/dev/null",
"seq 1 200000 | LC_ALL=C grep -c 1",
"mapfile arr < file",
"read -r line",
"read -n 5 -r line < file",
"seq 1 200000 | mapfile arr",
"seq 1 200000 | mapfile -t arr",
"seq 1 200000 | readarray -u 0 -t arr",
"seq 1 200000 | mapfile -d '' arr",
"seq 1 200000 | mapfile -t -- arr",
"case \"$w\" in --auth|--config) grep -q x <<<\"$w\" ;; esac",
"cat <<'EOF'",
"seq 1 200000 | grep -q 1",
"EOF",
"",
))
self.assertEqual(hazards(src), [])
def test_discovery_covers_managed_scripts(self):
found = managed_scripts()
for rel in (
"scripts/svc.sh",
"scripts/task.sh",
"scripts/e2e.sh",
"scripts/e2e-duties.sh",
"scripts/lint-boundaries.sh",
"scripts/hooks/pre-commit",
*EXTRA_SCRIPTS,
):
self.assertIn(rel, found)
self.assertFalse([p for p in found if p.startswith("runtime/")], found)
self.assertFalse(set(EXEMPT) - set(found), "stale EXEMPT entries")
def test_managed_scripts_have_no_early_exit_pipe_reader(self):
for rel in managed_scripts():
if rel in EXEMPT:
continue
with self.subTest(script=rel):
path = ROOT / rel
self.assertTrue(path.is_file(), f"{rel} missing")
found = hazards(path.read_text(encoding="utf-8"))
self.assertEqual(
found, [], "\n".join(f"{rel}:{line}: | {text}" for line, text in found)
)
if __name__ == "__main__":
unittest.main()