diff --git a/src/app_controller.py b/src/app_controller.py index 5433dceb..2d233273 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -5,11 +5,9 @@ import inspect import json import os import re -import requests import sys import threading import time -import tomli_w import traceback import uuid @@ -3338,13 +3336,14 @@ class AppController: def do_post() -> None: try: + import requests requests.post( "http://127.0.0.1:8999/api/ask/respond", json={"request_id": request_id, "response": {"approved": True}}, timeout=2 ) except Exception as e: print(f"Error responding to ask: {e}") - threading.Thread(target=do_post, daemon=True).start() + self.submit_io(do_post) self._pending_ask_dialog = False self._ask_request_id = None self._ask_tool_data = None @@ -3361,13 +3360,14 @@ class AppController: def do_post() -> None: try: + import requests requests.post( "http://127.0.0.1:8999/api/ask/respond", json={"request_id": request_id, "response": {"approved": False}}, timeout=2 ) except Exception as e: print(f"Error responding to ask: {e}") - threading.Thread(target=do_post, daemon=True).start() + self.submit_io(do_post) self._pending_ask_dialog = False self._ask_request_id = None self._ask_tool_data = None diff --git a/tests/test_main_thread_purity.py b/tests/test_main_thread_purity.py new file mode 100644 index 00000000..dd801f00 --- /dev/null +++ b/tests/test_main_thread_purity.py @@ -0,0 +1,134 @@ +"""Tests that the Main Thread Purity Invariant holds for refactored modules. + +Per spec.md:2.2 Layer 1, the main thread (the one that enters immapp.run()) +must NEVER import a module heavier than imgui_bundle and the lean gui_2 +skeleton. Heavy imports are loaded by the AppController's _io_pool +warmup and accessed via _require_warmed() at use sites. + +This is the static enforcement test. The runtime audit hook test +(sys.addaudithook) is the empirical check; see tests/test_main_thread_purity_runtime.py +for the runtime version (Phase 8 T8.2). + +This test uses scripts/audit_main_thread_imports.py (built in T1.4) to +verify that the modules we refactored in Phases 3-7 contribute ZERO +new violations to the main-thread import graph. The test is RED before +each phase's refactor, GREEN after. + +For each refactored file, the test checks the top-level imports against +the heavy denylist. If any of these files still have top-level heavy +imports, the test FAILS - that means the refactor is incomplete or a +future commit re-introduced a heavy import. +""" + +import subprocess +import sys +import textwrap +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def _run_in_subprocess(snippet: str) -> subprocess.CompletedProcess: + script = textwrap.dedent(snippet) + return subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + cwd=str(ROOT), + timeout=30, + ) + + +# Heavy modules that must NOT be top-level imports in any main-thread- +# reachable file. Mirrors the spec's heavy denylist. +HEAVY_DENYLIST = { + "google.genai", "anthropic", "openai", "requests", + "google.genai.types", "fastapi", "fastapi.security.api_key", + "src.command_palette", "src.theme_nerv", "src.theme_nerv_fx", + "src.markdown_table", "numpy", "tkinter", "tomli_w", +} + +# Files that the refactor targets. Each must have ZERO top-level imports +# of any module in HEAVY_DENYLIST. +REFACTOR_TARGETS = [ + "src/ai_client.py", # Phase 3 + "src/app_controller.py", # Phase 4 + "src/commands.py", # Phase 5A + "src/theme_2.py", # Phase 5B + "src/markdown_helper.py", # Phase 5C + "src/gui_2.py", # Phase 5D +] + + +def _check_file_violations(file_path: str) -> str: + """Run a subprocess that AST-checks the file for heavy top-level imports. + Returns the stdout of the subprocess (which lists any violations found).""" + res = _run_in_subprocess(f""" + import ast + from pathlib import Path + root = Path('.').resolve() + path = root / {file_path!r} + if not path.exists(): + print('FILE_NOT_FOUND') + else: + tree = ast.parse(path.read_text(encoding='utf-8')) + heavy = {sorted(HEAVY_DENYLIST)!r} + violations = [] + for node in tree.body: + if isinstance(node, ast.Import): + for alias in node.names: + for h in heavy: + if alias.name == h or alias.name.startswith(h + '.'): + violations.append(f'{{alias.name}} @ line {{node.lineno}}') + elif isinstance(node, ast.ImportFrom): + if node.module: + for h in heavy: + if node.module == h or node.module.startswith(h + '.'): + violations.append(f'{{node.module}} @ line {{node.lineno}}') + for v in violations: + print('VIOLATION:', v) + if not violations: + print('CLEAN') + """) + return res.stdout + + +def test_ai_client_has_no_heavy_top_level_imports() -> None: + out = _check_file_violations("src/ai_client.py") + assert "VIOLATION" not in out, f"ai_client.py has heavy top-level imports: {out}" + + +def test_app_controller_has_no_heavy_top_level_imports() -> None: + out = _check_file_violations("src/app_controller.py") + assert "VIOLATION" not in out, f"app_controller.py has heavy top-level imports: {out}" + + +def test_commands_has_no_heavy_top_level_imports() -> None: + out = _check_file_violations("src/commands.py") + assert "VIOLATION" not in out, f"commands.py has heavy top-level imports: {out}" + + +def test_theme_2_has_no_heavy_top_level_imports() -> None: + out = _check_file_violations("src/theme_2.py") + assert "VIOLATION" not in out, f"theme_2.py has heavy top-level imports: {out}" + + +def test_markdown_helper_has_no_heavy_top_level_imports() -> None: + out = _check_file_violations("src/markdown_helper.py") + assert "VIOLATION" not in out, f"markdown_helper.py has heavy top-level imports: {out}" + + +def test_gui_2_has_no_heavy_top_level_imports() -> None: + out = _check_file_violations("src/gui_2.py") + assert "VIOLATION" not in out, f"gui_2.py has heavy top-level imports: {out}" + + +def test_refactor_targets_summary() -> None: + """Aggregate check: all 6 refactored files are clean.""" + summary = [] + for f in REFACTOR_TARGETS: + out = _check_file_violations(f) + clean = "CLEAN" in out + summary.append((f, clean)) + all_clean = all(c for _, c in summary) + assert all_clean, f"Not all refactored files are clean: {summary}"