Skip to content

Commit c90e9fc

Browse files
TMHSDigitalclaude
andcommitted
fix(testing): let the exit-code gate see fail() helpers and tuple returns
check_exit_code_readme.py resolved only `return N`, `sys.exit(N)` and named constants, but most showcase checks return `fail("...", N)` and many return tuples (`return 9, objs, objs`, `return (fail(...),) + none3`). Deleting README rows 15-18 from shipping-crate passed the gate. A module function whose every return hands back one of its own parameters is now a pass-through helper, so `fail(msg, 15)` resolves to 15 (positional or keyword), and a tuple return resolves through its first element. Distinct codes the gate guards across scripts: 559 -> 2031. No current README drift surfaced. tests/test_exit_code_readme.py pins each shape and runs in Validate. Closes #360 Signed-off-by: TMHSDigital <154358121+TMHSDigital@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 536f60f commit c90e9fc

3 files changed

Lines changed: 108 additions & 2 deletions

File tree

‎.github/workflows/validate.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,9 @@ jobs:
410410
- name: Plugin-dist --out refuses destructive targets
411411
run: python3 tests/test_build_plugin_dist.py -v
412412

413+
- name: Exit-code gate resolves fail() helpers and tuple returns
414+
run: python3 tests/test_exit_code_readme.py -v
415+
413416
- name: Hero-drift tool exit-code tests
414417
run: |
415418
pip install numpy==2.2.6 Pillow==11.2.1

‎tests/check_exit_code_readme.py‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
`return fcode`). Helper codes are read from examples/gallery_*.py, not
1616
hardcoded. A `return name` that resolves to none of these is a pass-through
1717
of another function's result; that function's own returns are scanned.
18+
- Local fail-style helpers resolve through their argument: a module
19+
function whose every `return` hands back one of its own parameters
20+
(`def fail(msg, code): ...; return code`) makes `return fail("...", 11)`
21+
code 11 (#360).
22+
- Tuple returns resolve through their first element when it is a code:
23+
`return 9, objs, objs` and `return (fail("...", 11),) + none3`.
1824
- `sys.exit(main())` and `sys.exit(name)` where `name` is assigned from a
1925
local function call (both headless templates: `exit_code = main()`) are
2026
harness pass-through, not unanalyzable.
@@ -89,6 +95,25 @@ def module_function_names(tree):
8995
return names
9096

9197

98+
def passthrough_helpers(tree):
99+
"""{func: (index, name)} for module functions whose every `return` hands
100+
back one of their own parameters unchanged, e.g. ``def fail(msg, code):
101+
print(...); return code``. A call to one is as static as the argument it
102+
passes through."""
103+
out = {}
104+
for node in tree.body:
105+
if not isinstance(node, ast.FunctionDef):
106+
continue
107+
params = [a.arg for a in node.args.args]
108+
rets = [r for r in ast.walk(node) if isinstance(r, ast.Return)]
109+
names = {r.value.id for r in rets if isinstance(r.value, ast.Name)}
110+
if rets and len(names) == 1 and all(isinstance(r.value, ast.Name) for r in rets):
111+
name = names.pop()
112+
if name in params:
113+
out[node.name] = (params.index(name), name)
114+
return out
115+
116+
92117
def module_int_constants(tree):
93118
"""Module-level `NAME = <int literal>` bindings."""
94119
consts = {}
@@ -144,9 +169,10 @@ def is_passthrough_call(node, func_names):
144169

145170

146171
class ExitVisitor(ast.NodeVisitor):
147-
def __init__(self, func_names, consts=None):
172+
def __init__(self, func_names, consts=None, helpers=None):
148173
self.func_names = func_names
149174
self.consts = consts or {}
175+
self.helpers = helpers or {}
150176
self.stack = []
151177
self.scope_binds = [{}]
152178
self.code_binds = [{}] # name -> code, per function scope
@@ -174,7 +200,19 @@ def resolve_code(self, node):
174200
dotted = _dotted(node)
175201
if dotted in HELPER_CONSTS:
176202
return HELPER_CONSTS[dotted]
203+
if isinstance(node, ast.Tuple) and node.elts:
204+
return self.resolve_code(node.elts[0])
205+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add) and isinstance(node.left, ast.Tuple):
206+
return self.resolve_code(node.left)
177207
if isinstance(node, ast.Call):
208+
if isinstance(node.func, ast.Name) and node.func.id in self.helpers:
209+
index, name = self.helpers[node.func.id]
210+
for kw in node.keywords:
211+
if kw.arg == name:
212+
return self.resolve_code(kw.value)
213+
if index < len(node.args):
214+
return self.resolve_code(node.args[index])
215+
return None
178216
return HELPER_FUNCS.get(_dotted(node.func))
179217
return None
180218

@@ -324,7 +362,8 @@ def check_file(path):
324362
errors.append(f"{rel}: cannot parse: {exc}")
325363
return errors
326364

327-
visitor = ExitVisitor(module_function_names(tree), module_int_constants(tree))
365+
visitor = ExitVisitor(module_function_names(tree), module_int_constants(tree),
366+
passthrough_helpers(tree))
328367
visitor.visit(tree)
329368

330369
for lineno, snippet in visitor.unanalyzable:

‎tests/test_exit_code_readme.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Resolution tests for tests/check_exit_code_readme.py (#360). No Blender.
2+
3+
The gate is only as good as the exit codes it can see. These pin the shapes
4+
showcase and example scripts actually use, so a refactor of the resolver
5+
cannot quietly stop seeing them again. Run:
6+
python tests/test_exit_code_readme.py -v
7+
"""
8+
from __future__ import annotations
9+
10+
import ast
11+
import sys
12+
import textwrap
13+
import unittest
14+
from pathlib import Path
15+
16+
sys.path.insert(0, str(Path(__file__).resolve().parent))
17+
18+
import check_exit_code_readme as c # noqa: E402
19+
20+
21+
def codes(src: str) -> set[int]:
22+
tree = ast.parse(textwrap.dedent(src))
23+
v = c.ExitVisitor(c.module_function_names(tree), c.module_int_constants(tree),
24+
c.passthrough_helpers(tree))
25+
v.visit(tree)
26+
return {code for code, _ in v.literals if code}
27+
28+
29+
class Resolution(unittest.TestCase):
30+
def test_literal_and_constant(self):
31+
self.assertEqual(codes("""
32+
EXIT_X = 7
33+
def main():
34+
if a: return 3
35+
return EXIT_X
36+
"""), {3, 7})
37+
38+
def test_fail_helper_positional_and_keyword(self):
39+
self.assertEqual(codes("""
40+
def fail(msg, code):
41+
print(msg)
42+
return code
43+
def check():
44+
if a: return fail("one", 15)
45+
return fail("two", code=16)
46+
"""), {15, 16})
47+
48+
def test_tuple_return_first_element(self):
49+
self.assertEqual(codes("""
50+
def fail(msg, code):
51+
return code
52+
def check():
53+
if a: return 9, objs, objs
54+
if b: return fail("x", 11), None, None
55+
return (fail("y", 12),) + none3
56+
"""), {9, 11, 12})
57+
58+
def test_helper_returning_a_non_parameter_is_not_passthrough(self):
59+
tree = ast.parse("def f(a, b):\n x = a\n return x\n")
60+
self.assertEqual(c.passthrough_helpers(tree), {})
61+
62+
63+
if __name__ == "__main__":
64+
unittest.main()

0 commit comments

Comments
 (0)