From ea7ac494731ba26d5f8712b271266798c408c77c Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 10 Oct 2026 17:41:23 +0800 Subject: [PATCH] emrg: the nonlocal check refuses an empty subject and names what it read --- scripts/check_nonlocal.py | 38 +++++++++++++++- tests/test_ci_nonlocal.py | 96 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/scripts/check_nonlocal.py b/scripts/check_nonlocal.py index eabe72ec..1355c236 100644 --- a/scripts/check_nonlocal.py +++ b/scripts/check_nonlocal.py @@ -10,6 +10,17 @@ a new state variable in interactive but forgets to declare it nonlocal in handle_key — exactly the class of bug that the SelectorState refactoring (#124) is designed to prevent. + +Exit codes: `0` every declaration checked is complete / `1` a name is read and written +without being declared / `2` nothing could be measured. `2` covers three absences, since +this check is a reading over the inner functions it names: no `app.py` at the resolved +tree, no `interactive` in it, and — the third, added 2026-10-10 (`cyc20261010-173239`) — +an `interactive` holding **none** of the four guarded inner functions +(`handle_key`, `read_server`, `_on_sigwinch`, `_run_elapsed_timer`). That last state +printed `OK: nonlocal integrity check passed` and exited 0 while checking nothing, which +is the family's "an empty tree is not a clean reading" (issue #1872). The `OK` line also +names which of the four were checked, so a rename that moves one of them out of +`interactive` shrinks this check visibly instead of silently. """ from __future__ import annotations @@ -172,6 +183,23 @@ def check_nonlocal(app_path: str) -> int: if _run_elapsed_timer: inner_fns.append(("_run_elapsed_timer", _run_elapsed_timer)) + if not inner_fns: + # None of the four functions this check guards is here, so there is nothing to + # verify and `OK` would say the declarations were checked when none was. The + # sibling case — `interactive` itself renamed away — already refuses above; this + # is the same absence one level in, and it was silent. Measured 2026-10-10 + # (`cyc20261010-173239`): an `interactive` holding only `x = 1` printed + # `OK: nonlocal integrity check passed` and exited 0. Same rule as the family's + # "an empty tree is not a clean reading" (issue #1872). + print( + "ERROR: found `interactive` in app.py but none of the inner functions this " + "check guards (handle_key, read_server, _on_sigwinch, _run_elapsed_timer), so " + "there was nothing for it to verify - `0` says the nonlocal declarations were " + "checked, and none was", + file=sys.stderr, + ) + return 2 + exit_code = 0 for fn_name, fn_node in inner_fns: nonlocal_names: set[str] = set() @@ -209,7 +237,15 @@ def check_nonlocal(app_path: str) -> int: exit_code = 1 if exit_code == 0: - print("OK: nonlocal integrity check passed") + # Name what was read, not just the verdict: the count of guarded inner functions + # is 4 today and the line was silent about it, so a rename that moved one of them + # out of `interactive` shrank this check's coverage while the output stayed + # byte-identical. Measured 2026-10-10 (`cyc20261010-173239`): all four are found in + # this checkout, and the OK line said none of them. + print( + "OK: nonlocal integrity check passed " + f"(inner functions checked: {', '.join(name for name, _ in inner_fns)})" + ) return exit_code diff --git a/tests/test_ci_nonlocal.py b/tests/test_ci_nonlocal.py index d4753d60..438c1fd1 100644 --- a/tests/test_ci_nonlocal.py +++ b/tests/test_ci_nonlocal.py @@ -241,6 +241,102 @@ async def handle_key(data): assert rc == 0 +GUARDED = ("handle_key", "read_server", "_on_sigwinch", "_run_elapsed_timer") + + +def _run_in_process(app_py: Path, capsys) -> tuple[int, str, str]: + """`check_nonlocal()` on a file, with its two streams captured - rc in, text out.""" + rc = check_nonlocal.check_nonlocal(str(app_py)) + captured = capsys.readouterr() + return rc, captured.out, captured.err + + +def _named_in_coverage(out: str) -> list[str]: + """The functions the `OK` line says were checked, in order. + + Compared as the **whole payload** rather than by membership: a line that names a + function it did not check is the same defect as one that omits the functions it did, + and a membership assertion passes on both (found by a mutation arm, cycle + `cyc20261010-173239`: prefixing the payload was green until this read it exactly). + """ + assert "inner functions checked:" in out, f"the OK line names no coverage at all:\n{out}" + payload = out.split("inner functions checked:", 1)[1].strip() + assert payload.endswith(")"), f"the coverage payload is not closed:\n{out}" + return [name.strip() for name in payload[:-1].split(",") if name.strip()] + + +class TestTheReadingNamesWhatItChecked: + """Issue #2047 (cycle `cyc20261010-173239`). + + This check is a reading **over the inner functions of `interactive`**, and it held two + silences about them: it refused when `interactive` itself was renamed away (the + 2026-09-11 worktree defect) but printed a clean `OK: nonlocal integrity check passed` + when *none* of the four functions it guards was there — a reading over an empty set, the + family's "an empty tree is not a clean reading" (issue #1872) — and its `OK` line named + none of them, so a rename that moved one out of `interactive` shrank the coverage while + the output stayed byte-identical. + """ + + def test_an_interactive_with_none_of_the_guarded_functions_is_not_a_pass( + self, tmp_path: Path, capsys + ) -> None: + """The empty subject: `interactive` present, all four guarded functions gone.""" + app_py = tmp_path / "app.py" + app_py.write_text( + "async def interactive():\n x = 1\n return x\n", encoding="utf-8" + ) + rc, out, err = _run_in_process(app_py, capsys) + assert rc == 2, f"rc={rc}\nstdout: {out}\nstderr: {err}" + assert "OK:" not in out, out + # the refusal names all four, so a reader knows what it looked for and did not find + for name in GUARDED: + assert name in err, f"{name} not named in the refusal:\n{err}" + assert "checked" in err and "none was" in err, err + + def test_adding_one_guarded_function_back_flips_the_reading(self, tmp_path: Path, capsys) -> None: + """The control: the refusal is about the subject, not an unconditional rc 2. + + The same file with `handle_key` restored must pass **and** name exactly that one — + the second half is what makes the coverage line a reading rather than a constant. + """ + app_py = tmp_path / "app.py" + app_py.write_text(textwrap.dedent("""\ + async def interactive(): + state_var = False + + async def handle_key(data): + nonlocal state_var + state_var = True + """), encoding="utf-8") + rc, out, err = _run_in_process(app_py, capsys) + assert rc == 0, f"rc={rc}\nstdout: {out}\nstderr: {err}" + assert _named_in_coverage(out) == ["handle_key"], out + + def test_the_ok_line_names_the_guarded_functions_this_tree_really_has(self, capsys) -> None: + """On the live tree: the named set is derived from the AST, not from the code path. + + Read back from `emrg/client/app.py` itself — the inner functions of `interactive` + that are in the guarded set — so the assertion fails on a constant, on an unguarded + helper being named, and on a name being added that the guard did not check. + """ + tree = ast.parse((check_nonlocal.REPO_ROOT / check_nonlocal.TARGET).read_text(encoding="utf-8")) + interactive = check_nonlocal._find_interactive_body(tree) + assert interactive is not None, "no `interactive` in the live app.py" + present = {n.name for n in interactive.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))} + expected = [name for name in GUARDED if name in present] + assert expected, ( + "the live app.py holds none of the guarded inner functions, so this reading has " + "no subject — and the guard's own new branch should be refusing it" + ) + + rc, out, err = _run_in_process(check_nonlocal.REPO_ROOT / check_nonlocal.TARGET, capsys) + assert rc == 0, f"rc={rc}\nstdout: {out}\nstderr: {err}" + assert _named_in_coverage(out) == expected, ( + f"the OK line's coverage is not this tree's reading " + f"(expected {expected}):\n{out}" + ) + + # --------------------------------------------------------------------------- # Which tree does the check inspect? #