diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a34c70c5..cffd69da 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -410,6 +410,9 @@ jobs: - name: Plugin-dist --out refuses destructive targets run: python3 tests/test_build_plugin_dist.py -v + - name: Exit-code gate resolves fail() helpers and tuple returns + run: python3 tests/test_exit_code_readme.py -v + - name: Hero-drift tool exit-code tests run: | pip install numpy==2.2.6 Pillow==11.2.1 diff --git a/tests/check_exit_code_readme.py b/tests/check_exit_code_readme.py index 6f2d4079..bc103b65 100644 --- a/tests/check_exit_code_readme.py +++ b/tests/check_exit_code_readme.py @@ -15,6 +15,12 @@ `return fcode`). Helper codes are read from examples/gallery_*.py, not hardcoded. A `return name` that resolves to none of these is a pass-through of another function's result; that function's own returns are scanned. +- Local fail-style helpers resolve through their argument: a module + function whose every `return` hands back one of its own parameters + (`def fail(msg, code): ...; return code`) makes `return fail("...", 11)` + code 11 (#360). +- Tuple returns resolve through their first element when it is a code: + `return 9, objs, objs` and `return (fail("...", 11),) + none3`. - `sys.exit(main())` and `sys.exit(name)` where `name` is assigned from a local function call (both headless templates: `exit_code = main()`) are harness pass-through, not unanalyzable. @@ -89,6 +95,25 @@ def module_function_names(tree): return names +def passthrough_helpers(tree): + """{func: (index, name)} for module functions whose every `return` hands + back one of their own parameters unchanged, e.g. ``def fail(msg, code): + print(...); return code``. A call to one is as static as the argument it + passes through.""" + out = {} + for node in tree.body: + if not isinstance(node, ast.FunctionDef): + continue + params = [a.arg for a in node.args.args] + rets = [r for r in ast.walk(node) if isinstance(r, ast.Return)] + names = {r.value.id for r in rets if isinstance(r.value, ast.Name)} + if rets and len(names) == 1 and all(isinstance(r.value, ast.Name) for r in rets): + name = names.pop() + if name in params: + out[node.name] = (params.index(name), name) + return out + + def module_int_constants(tree): """Module-level `NAME = ` bindings.""" consts = {} @@ -144,9 +169,10 @@ def is_passthrough_call(node, func_names): class ExitVisitor(ast.NodeVisitor): - def __init__(self, func_names, consts=None): + def __init__(self, func_names, consts=None, helpers=None): self.func_names = func_names self.consts = consts or {} + self.helpers = helpers or {} self.stack = [] self.scope_binds = [{}] self.code_binds = [{}] # name -> code, per function scope @@ -174,7 +200,19 @@ def resolve_code(self, node): dotted = _dotted(node) if dotted in HELPER_CONSTS: return HELPER_CONSTS[dotted] + if isinstance(node, ast.Tuple) and node.elts: + return self.resolve_code(node.elts[0]) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add) and isinstance(node.left, ast.Tuple): + return self.resolve_code(node.left) if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id in self.helpers: + index, name = self.helpers[node.func.id] + for kw in node.keywords: + if kw.arg == name: + return self.resolve_code(kw.value) + if index < len(node.args): + return self.resolve_code(node.args[index]) + return None return HELPER_FUNCS.get(_dotted(node.func)) return None @@ -324,7 +362,8 @@ def check_file(path): errors.append(f"{rel}: cannot parse: {exc}") return errors - visitor = ExitVisitor(module_function_names(tree), module_int_constants(tree)) + visitor = ExitVisitor(module_function_names(tree), module_int_constants(tree), + passthrough_helpers(tree)) visitor.visit(tree) for lineno, snippet in visitor.unanalyzable: diff --git a/tests/test_exit_code_readme.py b/tests/test_exit_code_readme.py new file mode 100644 index 00000000..96ae5b71 --- /dev/null +++ b/tests/test_exit_code_readme.py @@ -0,0 +1,64 @@ +"""Resolution tests for tests/check_exit_code_readme.py (#360). No Blender. + +The gate is only as good as the exit codes it can see. These pin the shapes +showcase and example scripts actually use, so a refactor of the resolver +cannot quietly stop seeing them again. Run: + python tests/test_exit_code_readme.py -v +""" +from __future__ import annotations + +import ast +import sys +import textwrap +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import check_exit_code_readme as c # noqa: E402 + + +def codes(src: str) -> set[int]: + tree = ast.parse(textwrap.dedent(src)) + v = c.ExitVisitor(c.module_function_names(tree), c.module_int_constants(tree), + c.passthrough_helpers(tree)) + v.visit(tree) + return {code for code, _ in v.literals if code} + + +class Resolution(unittest.TestCase): + def test_literal_and_constant(self): + self.assertEqual(codes(""" + EXIT_X = 7 + def main(): + if a: return 3 + return EXIT_X + """), {3, 7}) + + def test_fail_helper_positional_and_keyword(self): + self.assertEqual(codes(""" + def fail(msg, code): + print(msg) + return code + def check(): + if a: return fail("one", 15) + return fail("two", code=16) + """), {15, 16}) + + def test_tuple_return_first_element(self): + self.assertEqual(codes(""" + def fail(msg, code): + return code + def check(): + if a: return 9, objs, objs + if b: return fail("x", 11), None, None + return (fail("y", 12),) + none3 + """), {9, 11, 12}) + + def test_helper_returning_a_non_parameter_is_not_passthrough(self): + tree = ast.parse("def f(a, b):\n x = a\n return x\n") + self.assertEqual(c.passthrough_helpers(tree), {}) + + +if __name__ == "__main__": + unittest.main()