Skip to content

Commit d1487c0

Browse files
PhysShellclaude
andcommitted
fix(S2): enforce the no-import-time-exit contract as a real preflight
Two unsoundnesses in the harness guard, both fixed: (1) The guard ran too late. test_harness_contract.py was itself an auto-discovered module, so an earlier-sorting test_*.py that sys.exit()s at import ended the process before the scan ran. The check is now a PREFLIGHT (tests/_preflight.py) that run_tests.run() calls FIRST, over the source, before importing any test module — so an offender cannot sort ahead of it. A violation prints PREFLIGHT FAIL and the runner returns 1. (2) Pruning function/lambda/class bodies was unsound: a body is reached at import when a module-scope call or an immediately-invoked lambda runs it. The invariant is now strict and purely LOCATION-based (no call-graph analysis): sys.exit / raise SystemExit is allowed ONLY inside a standalone guard body — nowhere else, function bodies included. Regressions: MUST_CATCH now includes a helper called at module scope, an IIFE, an uncalled function body and a class method body; the MUST_PASS cases that permitted function-body exits are removed. A load-bearing regression builds a throwaway tests dir whose earliest-sorting file exits at import and confirms the preflight reports it (plus a helper-invoked offender and a clean guarded module). Verified end to end: injecting an earlier-sorting offender into the real tests dir makes run_tests return 1, not a silent 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G
1 parent f1b26cd commit d1487c0

3 files changed

Lines changed: 183 additions & 126 deletions

File tree

tests/_preflight.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Preflight for the aggregate test runner.
2+
3+
run_tests.py discovers every `test_*.py` and imports it with importlib. A single
4+
import-time `sys.exit(...)` / `raise SystemExit(...)` in ANY of them ends the whole process
5+
before the aggregate return code is collected — and the offender can sort BEFORE whatever
6+
module is meant to police it, so a policing test module cannot catch it. The check must
7+
therefore run as a PREFLIGHT, before the first test import (run_tests.run() calls
8+
check_test_files() first and aborts on any violation).
9+
10+
The invariant is deliberately strict and purely LOCATION-based — no call-graph analysis,
11+
which an immediately-invoked helper or lambda defeats:
12+
13+
a test_*.py may use sys.exit / raise SystemExit ONLY inside the body of a standalone
14+
top-level `if __name__ == "__main__":` guard — nowhere else (not module scope, not a
15+
function/lambda/class body, not a decorator or default).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import ast
21+
import os
22+
23+
24+
def _is_main_guard(test: ast.expr) -> bool:
25+
"""Structurally EXACTLY `__name__ == "__main__"`."""
26+
return (isinstance(test, ast.Compare)
27+
and isinstance(test.left, ast.Name) and test.left.id == "__name__"
28+
and len(test.ops) == 1 and isinstance(test.ops[0], ast.Eq)
29+
and len(test.comparators) == 1
30+
and isinstance(test.comparators[0], ast.Constant)
31+
and test.comparators[0].value == "__main__")
32+
33+
34+
def _is_systemexit(exc: ast.expr | None) -> bool:
35+
if isinstance(exc, ast.Call):
36+
exc = exc.func
37+
return isinstance(exc, ast.Name) and exc.id == "SystemExit"
38+
39+
40+
def _is_sys_exit(func: ast.expr) -> bool:
41+
return (isinstance(func, ast.Attribute) and func.attr == "exit"
42+
and isinstance(func.value, ast.Name) and func.value.id == "sys")
43+
44+
45+
def _guard_body_ids(module: ast.Module) -> set[int]:
46+
"""Every AST node lexically inside a standalone top-level `if __name__ == "__main__":`
47+
BODY (its `else`/`elif` are NOT included — they run on import)."""
48+
allowed: set[int] = set()
49+
for stmt in module.body:
50+
if isinstance(stmt, ast.If) and _is_main_guard(stmt.test):
51+
for s in stmt.body:
52+
for node in ast.walk(s):
53+
allowed.add(id(node))
54+
return allowed
55+
56+
57+
def exit_violations(tree: ast.Module) -> list[ast.AST]:
58+
"""Every sys.exit call / raise SystemExit that sits OUTSIDE a main-guard body — which,
59+
for a module imported by the runner, is any that could run at import (directly or via a
60+
helper called at module scope). No exemption for function/lambda/class bodies."""
61+
allowed = _guard_body_ids(tree)
62+
bad: list[ast.AST] = []
63+
for node in ast.walk(tree):
64+
if id(node) in allowed:
65+
continue
66+
if isinstance(node, ast.Raise) and _is_systemexit(node.exc):
67+
bad.append(node)
68+
elif isinstance(node, ast.Call) and _is_sys_exit(node.func):
69+
bad.append(node)
70+
return bad
71+
72+
73+
def check_test_files(tests_dir: str) -> list[str]:
74+
"""Return a list of human-readable violations across every test_*.py in `tests_dir`.
75+
Empty means every test module is safe for the runner to import."""
76+
problems: list[str] = []
77+
for fname in sorted(os.listdir(tests_dir)):
78+
if not (fname.startswith("test_") and fname.endswith(".py")):
79+
continue
80+
path = os.path.join(tests_dir, fname)
81+
try:
82+
with open(path, encoding="utf-8") as fh:
83+
tree = ast.parse(fh.read(), filename=fname)
84+
except (OSError, SyntaxError) as exc:
85+
problems.append(f"{fname}: cannot parse ({exc})")
86+
continue
87+
for node in exit_violations(tree):
88+
problems.append(
89+
f"{fname}:{node.lineno}: sys.exit / raise SystemExit outside the "
90+
"`if __name__ == \"__main__\"` guard body — it would end the aggregate "
91+
"runner at import time"
92+
)
93+
return problems

tests/run_tests.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,18 @@ def helper_and_report_smoke() -> list[str]:
994994

995995

996996
def run() -> int:
997+
# PREFLIGHT — BEFORE importing any test module. A test_*.py that ends the process at
998+
# import (sys.exit / raise SystemExit outside its `__main__` guard) would silently
999+
# truncate this run, and it can sort before whatever module is meant to police it, so
1000+
# the check must happen here, first, over the source rather than by importing.
1001+
from _preflight import check_test_files
1002+
_here = os.path.dirname(os.path.abspath(__file__))
1003+
preflight = check_test_files(_here)
1004+
for problem in preflight:
1005+
print(f"PREFLIGHT FAIL: {problem}")
1006+
if preflight:
1007+
return 1
1008+
9971009
passed = 0
9981010
failed = 0
9991011
for name, body, expected in CASES:

tests/test_harness_contract.py

Lines changed: 78 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,163 +1,115 @@
1-
"""Guard the aggregate test-runner contract itself.
2-
3-
The bug this pins down: a `test_*.py` that executes its checks at import time and ends with
4-
`sys.exit(...)` ends the WHOLE process when run_tests.py imports it, silently dropping every
5-
module discovered after it and the aggregate return code. This module statically proves that
6-
CANNOT happen — every sibling `test_*.py` exposes `run()` and never calls sys.exit /
7-
raise SystemExit at module scope (only under an `if __name__ == "__main__"` guard).
1+
"""Guard the aggregate test-runner contract — the ENFORCEABLE version.
2+
3+
The real enforcement is a PREFLIGHT in run_tests.py: before importing any test_*.py it
4+
calls _preflight.check_test_files(), which refuses any sys.exit / raise SystemExit that
5+
is not inside a standalone `if __name__ == "__main__":` guard body. That runs first, so an
6+
offender cannot sort ahead of the check and end the process during import.
7+
8+
This module proves the scanner behind that preflight: it exercises the location invariant
9+
(no exemption for function/lambda/class bodies — an immediately-invoked helper defeats any
10+
call-graph exemption), checks the live tests directory, and — the load-bearing regression —
11+
builds a throwaway tests directory whose EARLIEST-sorting file exits at import and confirms
12+
the preflight reports it (so the runner would return failure, not silent success).
813
"""
914

1015
from __future__ import annotations
1116

1217
import ast
1318
import os
19+
import tempfile
20+
21+
from _preflight import check_test_files, exit_violations
1422

1523
failures: list[str] = []
1624
checks = 0
1725

1826
_HERE = os.path.dirname(os.path.abspath(__file__))
1927

20-
21-
def _is_main_guard(test: ast.expr) -> bool:
22-
"""Structurally recognise EXACTLY `__name__ == "__main__"` — not any top-level `if`,
23-
so `if True: sys.exit(0)` is NOT waved through."""
24-
return (isinstance(test, ast.Compare)
25-
and isinstance(test.left, ast.Name) and test.left.id == "__name__"
26-
and len(test.ops) == 1 and isinstance(test.ops[0], ast.Eq)
27-
and len(test.comparators) == 1
28-
and isinstance(test.comparators[0], ast.Constant)
29-
and test.comparators[0].value == "__main__")
30-
31-
32-
def _future_annotations(module: ast.AST) -> bool:
33-
body = getattr(module, "body", [])
34-
for stmt in body:
35-
if isinstance(stmt, ast.ImportFrom) and stmt.module == "__future__":
36-
if any(alias.name == "annotations" for alias in stmt.names):
37-
return True
38-
return False
39-
40-
41-
def _annotation_exprs(func: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.expr]:
42-
a = func.args
43-
exprs = [arg.annotation for arg in
44-
(*a.posonlyargs, *a.args, *a.kwonlyargs) if arg.annotation]
45-
for extra in (a.vararg, a.kwarg):
46-
if extra is not None and extra.annotation is not None:
47-
exprs.append(extra.annotation)
48-
if func.returns is not None:
49-
exprs.append(func.returns)
50-
return exprs
51-
52-
53-
def _module_scope_exit(node: ast.AST, ann_eager: bool | None = None) -> bool:
54-
"""True if a sys.exit(...) / raise SystemExit(...) would fire at IMPORT time. Only the
55-
genuinely DEFERRED subtrees are pruned: a function / async-function / lambda BODY, and
56-
the `if __name__ == "__main__"` body. Everything else runs on import and is scanned —
57-
class bodies + bases + keywords, decorators, argument defaults, lambda defaults, and
58-
(unless `from __future__ import annotations` is in force) annotations. The node itself
59-
is checked BEFORE its children, so a decorator / default that IS `sys.exit(...)` is
60-
caught, not just its arguments."""
61-
if ann_eager is None: # decided once, at the module root, then threaded down
62-
ann_eager = not _future_annotations(node)
63-
64-
if isinstance(node, ast.Raise) and _is_systemexit(node.exc):
65-
return True
66-
if isinstance(node, ast.Call) and _is_sys_exit(node.func):
67-
return True
68-
if isinstance(node, ast.If) and _is_main_guard(node.test):
69-
# ONLY the guard's body is exempt — its `else:` (and `elif`) still run on import.
70-
return any(_module_scope_exit(s, ann_eager) for s in node.orelse)
71-
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
72-
eager = [*node.decorator_list, *node.args.defaults,
73-
*[d for d in node.args.kw_defaults if d is not None]]
74-
if ann_eager:
75-
eager += _annotation_exprs(node)
76-
return any(_module_scope_exit(e, ann_eager) for e in eager)
77-
if isinstance(node, ast.Lambda):
78-
eager = [*node.args.defaults, *[d for d in node.args.kw_defaults if d is not None]]
79-
return any(_module_scope_exit(e, ann_eager) for e in eager)
80-
# Everything else — module body, class body/bases/keywords/decorators, module-scope
81-
# control flow — executes on import; scan every child.
82-
return any(_module_scope_exit(c, ann_eager) for c in ast.iter_child_nodes(node))
83-
84-
85-
def _is_systemexit(exc: ast.expr | None) -> bool:
86-
if isinstance(exc, ast.Call):
87-
exc = exc.func
88-
return isinstance(exc, ast.Name) and exc.id == "SystemExit"
89-
90-
91-
def _is_sys_exit(func: ast.expr) -> bool:
92-
return (isinstance(func, ast.Attribute) and func.attr == "exit"
93-
and isinstance(func.value, ast.Name) and func.value.id == "sys")
94-
95-
96-
_SELFTEST_MUST_CATCH = (
97-
("bare sys.exit at module scope", "import sys\nsys.exit(0)\n"),
98-
("exit inside `if True:`", "import sys\nif True:\n sys.exit(0)\n"),
99-
("exit inside a top-level try",
28+
# MUST be reported as violations — including exits buried in a function / lambda / class
29+
# body, because a module-scope call (or an IIFE) runs them at import.
30+
_MUST_CATCH = (
31+
("bare module-scope exit", "import sys\nsys.exit(0)\n"),
32+
("exit in `if True:`", "import sys\nif True:\n sys.exit(0)\n"),
33+
("exit in a top-level try",
10034
"import sys\ntry:\n sys.exit(0)\nexcept Exception:\n pass\n"),
10135
("raise SystemExit at module scope", "raise SystemExit(1)\n"),
102-
("exit inside a for loop", "import sys\nfor _ in range(1):\n sys.exit(0)\n"),
103-
("sys.exit in a main-guard else",
36+
("exit in a for loop", "import sys\nfor _ in range(1):\n sys.exit(0)\n"),
37+
("exit in a main-guard else",
10438
"import sys\nif __name__ == '__main__':\n pass\nelse:\n sys.exit(0)\n"),
10539
("raise SystemExit in a main-guard else",
10640
"if __name__ == '__main__':\n pass\nelse:\n raise SystemExit(1)\n"),
10741
("exit in a main-guard elif",
10842
"import sys\nif __name__ == '__main__':\n pass\nelif True:\n sys.exit(0)\n"),
109-
# Declarations that run code AT IMPORT — the trapdoors that skipping a whole
110-
# FunctionDef / ClassDef / Lambda node would miss.
11143
("exit in a class body", "import sys\nclass C:\n sys.exit(7)\n"),
11244
("exit in a class base", "import sys\nclass C(sys.exit(7)):\n pass\n"),
11345
("exit in a class decorator", "import sys\n@sys.exit(7)\nclass C:\n pass\n"),
11446
("exit in a function default", "import sys\ndef f(value=sys.exit(7)):\n pass\n"),
11547
("exit in a function decorator", "import sys\n@sys.exit(7)\ndef f():\n pass\n"),
116-
("exit in a keyword-only default",
117-
"import sys\ndef f(*, value=sys.exit(7)):\n pass\n"),
11848
("exit in a lambda default", "import sys\nf = lambda value=sys.exit(7): None\n"),
119-
("exit in an eager annotation", "import sys\ndef f(x: sys.exit(7)):\n pass\n"),
49+
# The blocker-2 forms: a body that IS reached at import.
50+
("exit in a helper called at module scope",
51+
"import sys\ndef abort():\n sys.exit(7)\nabort()\n"),
52+
("exit in an immediately-invoked lambda", "import sys\nv = (lambda: sys.exit(7))()\n"),
53+
# And even an UNCALLED body — the strict location rule refuses it regardless.
54+
("exit in an uncalled function body", "import sys\ndef f():\n sys.exit(7)\n"),
55+
("exit in a class method body",
56+
"import sys\nclass C:\n def m(self):\n sys.exit(7)\n"),
12057
)
121-
_SELFTEST_MUST_PASS = (
58+
# MUST be accepted: only the `__main__` guard body, or no exit at all, or a string literal.
59+
_MUST_PASS = (
12260
("guarded entrypoint", "import sys\nif __name__ == '__main__':\n sys.exit(0)\n"),
123-
("exit inside a function", "import sys\ndef run():\n sys.exit(0)\n"),
124-
("exit inside a lambda", "f = lambda: __import__('sys').exit(0)\n"),
125-
("exit inside a class method body",
126-
"import sys\nclass C:\n def m(self):\n sys.exit(0)\n"),
127-
("stringified annotation under future-annotations",
128-
"from __future__ import annotations\nimport sys\ndef f(x: sys.exit(7)):\n pass\n"),
61+
("guarded raise SystemExit(run())",
62+
"def run():\n return 0\nif __name__ == '__main__':\n raise SystemExit(run())\n"),
63+
("guarded exit nested under an inner if",
64+
"import sys\nif __name__ == '__main__':\n if '--x' in sys.argv:\n"
65+
" raise SystemExit(0)\n"),
66+
("no exit at all", "def run():\n return 0\n"),
67+
("exit only as a string literal", "MSG = 'call sys.exit(0) to quit'\n"),
12968
)
13069

13170

71+
def _violates(src: str) -> bool:
72+
return bool(exit_violations(ast.parse(src)))
73+
74+
13275
def run() -> int:
13376
global checks
134-
# Self-test the guard first: it must catch the ways the original bug could recur, and
135-
# must NOT flag the sanctioned entrypoint or an exit that only lives inside a scope.
136-
for label, src in _SELFTEST_MUST_CATCH:
137-
checks += 1
138-
if not _module_scope_exit(ast.parse(src)):
139-
failures.append(f"guard self-test: failed to catch {label}")
140-
for label, src in _SELFTEST_MUST_PASS:
77+
for label, src in _MUST_CATCH:
14178
checks += 1
142-
if _module_scope_exit(ast.parse(src)):
143-
failures.append(f"guard self-test: wrongly flagged {label}")
144-
145-
for fname in sorted(os.listdir(_HERE)):
146-
if not (fname.startswith("test_") and fname.endswith(".py")):
147-
continue
148-
if fname == os.path.basename(__file__):
149-
continue
79+
if not _violates(src):
80+
failures.append(f"scanner self-test: failed to catch {label}")
81+
for label, src in _MUST_PASS:
15082
checks += 1
151-
src = open(os.path.join(_HERE, fname), encoding="utf-8").read()
152-
tree = ast.parse(src)
153-
has_run = any(isinstance(n, ast.FunctionDef) and n.name == "run"
154-
for n in ast.iter_child_nodes(tree))
155-
if not has_run:
156-
failures.append(f"{fname}: has no module-level run()")
157-
if _module_scope_exit(tree):
158-
failures.append(f"{fname}: calls sys.exit/raise SystemExit at import scope "
159-
"(would short-circuit the aggregate runner)")
160-
print(f"harness contract: {checks - len(failures)}/{checks} test modules honour run()")
83+
if _violates(src):
84+
failures.append(f"scanner self-test: wrongly flagged {label}")
85+
86+
# The live tests directory must itself be clean (this is what the runner enforces).
87+
checks += 1
88+
live = check_test_files(_HERE)
89+
if live:
90+
failures.append("live tests directory has import-time-exit violations: "
91+
+ "; ".join(live))
92+
93+
# The load-bearing regression: an offender that sorts BEFORE any policing module must be
94+
# caught by the preflight, so the runner returns failure instead of a silent green. Also
95+
# a helper invoked at module scope — the blocker-2 case call-graph pruning would miss.
96+
checks += 1
97+
with tempfile.TemporaryDirectory() as tmp:
98+
with open(os.path.join(tmp, "test_aaa_offender.py"), "w", encoding="utf-8") as fh:
99+
fh.write("import sys\nsys.exit(0)\n")
100+
with open(os.path.join(tmp, "test_zzz_helper.py"), "w", encoding="utf-8") as fh:
101+
fh.write("import sys\n\n\ndef _boom():\n sys.exit(0)\n\n\n_boom()\n")
102+
with open(os.path.join(tmp, "test_mmm_clean.py"), "w", encoding="utf-8") as fh:
103+
fh.write("def run():\n return 0\n\n\nif __name__ == '__main__':\n"
104+
" raise SystemExit(run())\n")
105+
found = check_test_files(tmp)
106+
offenders = {p.split(":", 1)[0] for p in found}
107+
if offenders != {"test_aaa_offender.py", "test_zzz_helper.py"}:
108+
failures.append(f"preflight regression: flagged {sorted(offenders)}, expected "
109+
"the bare-exit and helper-invoked offenders only "
110+
"(the guarded module must pass)")
111+
112+
print(f"harness contract: {checks - len(failures)}/{checks} checks pass")
161113
for f in failures:
162114
print(f" FAIL: {f}")
163115
return 1 if failures else 0

0 commit comments

Comments
 (0)