From f8febabfe70ad950f9bcce19a5fe21c62c1f8eac Mon Sep 17 00:00:00 2001 From: Shivam Saini <51438830+shivamsn97@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:50:42 +0530 Subject: [PATCH] fix(cli): first-run scaffold and CLI fixes from pre-launch QA - pyxle init renders the scaffolded requirements.txt from the running framework version (was a stale literal '>=0.4.1,<0.5' that silently DOWNGRADED fresh installs 0.6.1 -> 0.4.5 on 'pyxle install'). The pin is now '>=,' with a live-version regression test that cannot go stale. - Scaffold bumps vite to ^6.3 / @vitejs/plugin-react ^4.4: fresh scaffolds now pass npm audit with 0 vulnerabilities (was 1 high + 1 moderate via transitive esbuild <=0.24.2, GHSA-67mh-4wv8-2f99). Verified e2e: init -> npm install -> dev -> build -> serve. - pyxle typecheck fails fast with an actionable error when TypeScript is missing instead of relaying npx's placeholder banner and a contradictory 'failed with 0 error(s)' summary. - Scaffolded AGENTS.md no longer claims LoaderError must be imported; auto-injected runtime names documented consistently. Suite: 2195 passed, coverage 96.48% (threshold 95). Ruff clean. --- docs/changelog.md | 7 + docs/guides/typescript.md | 2 +- pyxle/cli/__init__.py | 62 ++++-- pyxle/cli/init.py | 22 ++ pyxle/templates/scaffold/AGENTS.md | 9 +- pyxle/templates/scaffold/package.json | 4 +- pyxle/templates/scaffold/requirements.txt | 2 +- tests/cli/test_commands.py | 242 +++++++++++++++++++--- 8 files changed, 300 insertions(+), 50 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index a8112bb..7df797e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,13 @@ Release notes for Pyxle. While we're in beta (`0.x`), minor versions may include breaking changes — those are called out explicitly here. To upgrade, run `pip install --upgrade pyxle-framework`. +## 0.7.0 — Unreleased + +- **`pyxle init` no longer scaffolds a stale framework pin.** The generated `requirements.txt` used to hard-code `pyxle-framework>=0.4.1,<0.5`, so `pyxle install` in a fresh project silently *downgraded* the framework that scaffolded it. The requirement is now rendered from the running version — running `0.7.0` emits `pyxle-framework>=0.7.0,<0.8` (current release up to the next minor). +- **Fresh scaffolds pass `npm audit` clean.** The scaffold now pins `vite ^6.3` and `@vitejs/plugin-react ^4.4` — the old `vite ^5.2` range resolved a transitive `esbuild <= 0.24.2` that `npm audit` flags (GHSA-67mh-4wv8-2f99) on every new project. Vite 6 keeps Node 18+ support; no config changes are needed in existing projects, but bumping your own pin clears the same warning. +- **`pyxle typecheck` now fails fast with a clear error when TypeScript isn't installed.** It previously fell back to `npx --yes tsc`, which resolves npm's placeholder `tsc` package — relaying its banner and ending with a self-contradictory "Type check failed with 0 error(s)". The command now checks for a real `tsc` (local `node_modules/.bin`, then global) *before* building and exits with `TypeScript is required for 'pyxle typecheck' — run: npm install --save-dev typescript` (or "run: npm install" when it's declared but not installed). A failed `tsc` run that produces no diagnostics now reports the exit code instead of "0 error(s)". See [TypeScript guide](guides/typescript.md). +- **Scaffolded `AGENTS.md` corrected on auto-injected names.** It claimed `LoaderError` had to be imported from `pyxle.runtime` — false since 0.6.1, and contradicted by the rest of the file. It now consistently documents that `server`, `action`, `LoaderError`, `ActionError`, `ValidationActionError`, and `invalidate_routes` are all auto-injected by the compiler. + ## 0.6.1 — 2026-07-01 A sharper `pyxle check` — the edit → check → fix loop now catches classes of mistake it used to wave through. diff --git a/docs/guides/typescript.md b/docs/guides/typescript.md index 9dde68e..fdddb76 100644 --- a/docs/guides/typescript.md +++ b/docs/guides/typescript.md @@ -94,7 +94,7 @@ Two honest notes on coverage: ## `pyxle typecheck` -`pyxle typecheck` compiles your `.pyxl` files to `.jsx` and runs `tsc --noEmit` against the generated `tsconfig.json`, surfacing any type errors against the framework declarations. It looks for `tsc` in your local `node_modules/.bin`, then globally, then via `npx`, so install `typescript` (locally is recommended) to use it. It's the recommended CI gate for catching framework-API misuse. See the [CLI reference](../reference/cli.md). +`pyxle typecheck` compiles your `.pyxl` files to `.jsx` and runs `tsc --noEmit` against the generated `tsconfig.json`, surfacing any type errors against the framework declarations. It looks for `tsc` in your local `node_modules/.bin`, then globally — TypeScript must actually be installed (locally is recommended: `npm install --save-dev typescript`); if it isn't, the command fails fast with that instruction rather than invoking anything. It's the recommended CI gate for catching framework-API misuse. See the [CLI reference](../reference/cli.md). ## Editor setup diff --git a/pyxle/cli/__init__.py b/pyxle/cli/__init__.py index 84e4992..88296af 100644 --- a/pyxle/cli/__init__.py +++ b/pyxle/cli/__init__.py @@ -1014,6 +1014,22 @@ def typecheck( logger.error(f"Failed to create settings: {exc}") raise typer.Exit(code=1) from exc + # Resolve the TypeScript compiler up front — if it isn't installed there + # is nothing to run, so fail fast with one actionable error instead of + # building the project first and failing afterwards. + tsc_command = _find_tsc(project_root) + if tsc_command is None: + if _typescript_declared(project_root): + logger.error( + "TypeScript is declared in package.json but not installed — run: npm install" + ) + else: + logger.error( + "TypeScript is required for 'pyxle typecheck' — " + "run: npm install --save-dev typescript" + ) + raise typer.Exit(code=1) + from pyxle.devserver.builder import build_once # noqa: PLC0415 logger.info("Building project before type-checking") @@ -1029,14 +1045,6 @@ def typecheck( logger.error(f"tsconfig.json not found at '{tsconfig_path}'") raise typer.Exit(code=1) - tsc_command = _find_tsc(project_root) - if tsc_command is None: - logger.error( - "TypeScript compiler (tsc) not found. " - "Install it with 'npm install --save-dev typescript' or 'npm install -g typescript'." - ) - raise typer.Exit(code=1) - full_command = [*tsc_command, "--noEmit", "--project", str(tsconfig_path)] logger.step("TypeScript", " ".join(full_command)) @@ -1067,7 +1075,12 @@ def typecheck( logger.success("Type check passed — no errors found") else: error_count = sum(1 for line in (result.stdout or "").splitlines() if ": error TS" in line) - logger.error(f"Type check failed with {error_count} error(s)") + if error_count > 0: + logger.error(f"Type check failed with {error_count} error(s)") + else: + # tsc failed without reporting diagnostics (crash, bad invocation, + # unexpected binary) — never claim "0 error(s)" for a failed run. + logger.error(f"Type check failed — tsc exited with code {result.returncode}") raise typer.Exit(code=1) @@ -1090,7 +1103,13 @@ def _emit_tsc_diagnostic(logger: ConsoleLogger, line: str) -> None: def _find_tsc(project_root: Path) -> list[str] | None: - """Locate the TypeScript compiler binary.""" + """Locate the TypeScript compiler binary. + + Only a real ``tsc`` is returned: the project-local ``node_modules/.bin`` + entry or a global install on ``PATH``. There is deliberately no + ``npx tsc`` fallback — when TypeScript isn't installed, npm's ``tsc`` + package is a placeholder that prints a banner instead of type-checking. + """ import shutil # noqa: PLC0415 @@ -1108,14 +1127,27 @@ def _find_tsc(project_root: Path) -> list[str] | None: if global_tsc is not None: return [global_tsc] - # npx fallback - npx = shutil.which("npx") - if npx is not None: - return [npx, "--yes", "tsc"] - return None +def _typescript_declared(project_root: Path) -> bool: + """Whether the project's ``package.json`` declares a ``typescript`` dependency.""" + + package_json = project_root / "package.json" + if not package_json.is_file(): + return False + try: + manifest = json.loads(package_json.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return False + if not isinstance(manifest, dict): + return False + return any( + isinstance(manifest.get(section), dict) and "typescript" in manifest[section] + for section in ("devDependencies", "dependencies") + ) + + @app.command(help="Display the project's file-based route table.") def routes( directory: Path = typer.Argument( diff --git a/pyxle/cli/init.py b/pyxle/cli/init.py index 07d21aa..0a72cb8 100644 --- a/pyxle/cli/init.py +++ b/pyxle/cli/init.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import secrets from pathlib import Path from typing import Mapping @@ -17,6 +18,26 @@ SUPPORTED_TEMPLATES = {"default"} +_MAJOR_MINOR_RE = re.compile(r"^(\d+)\.(\d+)") + + +def framework_requirement(version: str) -> str: + """Return the ``pyxle-framework`` requirement line for a scaffolded project. + + The specifier is derived from the *running* framework version so a scaffold + never pins an older release than the CLI that generated it: running + ``0.7.0`` emits ``pyxle-framework>=0.7.0,<0.8`` (current version up to, but + excluding, the next minor). When the version metadata is unavailable — + e.g. an uninstalled source checkout reports ``"unknown"`` — the requirement + is left unpinned rather than emitting an unsatisfiable specifier. + """ + + match = _MAJOR_MINOR_RE.match(version) + if match is None: + return "pyxle-framework" + major, minor = int(match.group(1)), int(match.group(2)) + return f"pyxle-framework>={version},<{major}.{minor + 1}" + def build_template_registry() -> TemplateRegistry: registry = TemplateRegistry() @@ -103,6 +124,7 @@ def run_init( "package_name": project_slug, "project_name": project_name, "pyxle_version": __version__, + "pyxle_framework_requirement": framework_requirement(__version__), } render_templates(writer, build_template_registry(), context, overwrite=force) writer.write("public/favicon.ico", default_favicon_bytes(), binary=True, overwrite=force) diff --git a/pyxle/templates/scaffold/AGENTS.md b/pyxle/templates/scaffold/AGENTS.md index bec3ddf..96c86ba 100644 --- a/pyxle/templates/scaffold/AGENTS.md +++ b/pyxle/templates/scaffold/AGENTS.md @@ -213,9 +213,12 @@ pyxle install # (re)install Python + Node deps - **DON'T** call your own actions with `fetch` or write a route for them — use `useAction`/`
`. - **DON'T** put emoji or other non-BMP characters in **server-rendered** JSX text — SSR can fail to encode them. Use them only in client-only paths, or stick to plain text. -- **DON'T** import `server`/`action` to use the decorators (they're injected). In a file with an - `@action`, `ActionError`/`ValidationActionError` are injected too — don't import them. **DO** - import `LoaderError` (`from pyxle.runtime import LoaderError`) before raising it — it is *not* injected. +- **DON'T** import `server`/`action` to use the decorators, or `LoaderError`/`ActionError`/ + `ValidationActionError`/`invalidate_routes` to raise/call them — the compiler auto-injects each + alongside the decorator that uses it (a `@server` loader injects `LoaderError`; an `@action` + injects `ActionError`/`ValidationActionError`/`invalidate_routes`). An explicit import (or your + own definition) of one of these names is harmless — it takes precedence over the injected one — + just unnecessary. - **DON'T** expose secrets: env vars reach the browser only with a `PYXLE_PUBLIC_` prefix, and loader/action return values are sent to the client. - **DON'T** add a `# --- JSX ---` / `# --- client ---` marker — the split is automatic. diff --git a/pyxle/templates/scaffold/package.json b/pyxle/templates/scaffold/package.json index 7346866..c062eac 100644 --- a/pyxle/templates/scaffold/package.json +++ b/pyxle/templates/scaffold/package.json @@ -13,10 +13,10 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@vitejs/plugin-react": "^4.3.0", + "@vitejs/plugin-react": "^4.4.1", "autoprefixer": "^10.4.20", "postcss": "^8.4.47", "tailwindcss": "^3.4.10", - "vite": "^5.2.0" + "vite": "^6.3.5" } } diff --git a/pyxle/templates/scaffold/requirements.txt b/pyxle/templates/scaffold/requirements.txt index fd6561b..665c62e 100644 --- a/pyxle/templates/scaffold/requirements.txt +++ b/pyxle/templates/scaffold/requirements.txt @@ -1,4 +1,4 @@ -pyxle-framework>=0.4.1,<0.5 +$pyxle_framework_requirement starlette>=0.37,<0.38 httpx>=0.27,<0.28 uvicorn[standard]>=0.29,<1.0 diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 5a011b3..20de9a0 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -147,17 +147,90 @@ def fake_run(command, *, cwd, check): assert calls[1][1] == tmp_path -def test_scaffold_requirements_declares_pyxle_framework() -> None: - """The scaffolded requirements.txt must list pyxle-framework itself, so a - fresh clone + `pip install -r requirements.txt` pulls the runtime.""" - from importlib import resources - - content = ( - resources.files("pyxle.templates.scaffold") - .joinpath("requirements.txt") - .read_text(encoding="utf-8") +def test_scaffold_requirements_pins_running_framework_version(tmp_path, monkeypatch) -> None: + """`pyxle init` must render the pyxle-framework requirement from the RUNNING + framework version — `>=,<` — so `pyxle install` can never + silently downgrade the framework a scaffold was generated with. + + The expectation is derived from the live ``pyxle.__version__`` so this test + can never go stale on a version bump. + """ + import re + + from pyxle.cli.init import run_init + + monkeypatch.chdir(tmp_path) + run_init("demo", force=False, template="default", logger=cli.ConsoleLogger(), log_steps=False) + + content = (tmp_path / "demo" / "requirements.txt").read_text(encoding="utf-8") + line = next( + ln for ln in content.splitlines() if ln.startswith("pyxle-framework") ) - assert "pyxle-framework" in content + + match = re.match(r"^(\d+)\.(\d+)", __version__) + assert match is not None, ( + "pyxle-framework distribution metadata is unavailable in this environment" + ) + major, minor = int(match.group(1)), int(match.group(2)) + assert line == f"pyxle-framework>={__version__},<{major}.{minor + 1}" + # No leftover template placeholder anywhere in the rendered file. + assert "$" not in content + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("0.7.0", "pyxle-framework>=0.7.0,<0.8"), + ("0.6.1", "pyxle-framework>=0.6.1,<0.7"), + ("1.9.3", "pyxle-framework>=1.9.3,<1.10"), + ("0.7.0rc1", "pyxle-framework>=0.7.0rc1,<0.8"), + ("2.0.0.dev4", "pyxle-framework>=2.0.0.dev4,<2.1"), + ], +) +def test_framework_requirement_spans_current_to_next_minor(version, expected) -> None: + from pyxle.cli.init import framework_requirement + + assert framework_requirement(version) == expected + + +def test_framework_requirement_unparseable_version_left_unpinned() -> None: + """An uninstalled source checkout reports version "unknown" — the scaffold + must emit a satisfiable (unpinned) requirement, not a broken specifier.""" + from pyxle.cli.init import framework_requirement + + assert framework_requirement("unknown") == "pyxle-framework" + + +def test_scaffold_package_json_pins_audit_clean_vite(tmp_path, monkeypatch) -> None: + """The scaffold must pin vite >= 6 — the vite 5 range resolves a transitive + esbuild <= 0.24.2 that `npm audit` flags (GHSA-67mh-4wv8-2f99) on every + fresh project, and plugin-react >= 4.4 to match.""" + from pyxle.cli.init import run_init + + monkeypatch.chdir(tmp_path) + run_init("demo", force=False, template="default", logger=cli.ConsoleLogger(), log_steps=False) + + manifest = read_json(tmp_path / "demo" / "package.json") + dev_deps = manifest["devDependencies"] + vite_major = int(dev_deps["vite"].lstrip("^~").split(".", 1)[0]) + assert vite_major >= 6 + plugin_spec = dev_deps["@vitejs/plugin-react"].lstrip("^~") + plugin_major, plugin_minor = (int(part) for part in plugin_spec.split(".")[:2]) + assert (plugin_major, plugin_minor) >= (4, 4) + + +def test_scaffold_agents_md_documents_auto_injected_runtime_names(tmp_path, monkeypatch) -> None: + """AGENTS.md must not claim `LoaderError` needs an import — the compiler + auto-injects it (alongside ActionError/ValidationActionError/invalidate_routes), + and the rest of the file says so.""" + from pyxle.cli.init import run_init + + monkeypatch.chdir(tmp_path) + run_init("demo", force=False, template="default", logger=cli.ConsoleLogger(), log_steps=False) + + agents = (tmp_path / "demo" / "AGENTS.md").read_text(encoding="utf-8") + assert "*not* injected" not in agents + assert "import `LoaderError` (`from pyxle.runtime import LoaderError`) before" not in agents def test_in_virtualenv_detects_environments(monkeypatch) -> None: @@ -1859,6 +1932,129 @@ def test_typecheck_command_fails_when_tsc_not_found(monkeypatch) -> None: assert "tsc" in result.stdout.lower() or "TypeScript" in result.stdout +def test_typecheck_missing_typescript_errors_without_invoking_tsc(monkeypatch) -> None: + """Without typescript (no node_modules/typescript, not in package.json), + typecheck must emit ONE actionable error and never spawn a compiler — + previously the `npx --yes tsc` fallback relayed npm's placeholder banner.""" + with runner.isolated_filesystem(): + project = Path("demo") + (project / "pages").mkdir(parents=True) + (project / "public").mkdir() + (project / "pages" / "index.pyxl").write_text( + "import React from 'react';\n" + "export default function Page() { return
; }\n", + encoding="utf-8", + ) + (project / "package.json").write_text( + json.dumps({"name": "demo", "devDependencies": {"vite": "^6.3.5"}}), + encoding="utf-8", + ) + + # No local node_modules and nothing on PATH → tsc genuinely absent. + monkeypatch.setattr("shutil.which", lambda cmd: None) + + def forbid_run(*args, **kwargs): + raise AssertionError("typecheck must not spawn a compiler when typescript is missing") + + monkeypatch.setattr("pyxle.cli.subprocess.run", forbid_run) + + result = runner.invoke(app, ["typecheck", "demo"], catch_exceptions=False) + assert result.exit_code == 1 + assert "TypeScript is required for 'pyxle typecheck'" in result.stdout + assert "npm install --save-dev typescript" in result.stdout + assert "0 error(s)" not in result.stdout + + +def test_typecheck_typescript_declared_but_not_installed(monkeypatch) -> None: + """typescript listed in package.json but node_modules missing → point at + `npm install`, not at adding the dependency again.""" + with runner.isolated_filesystem(): + project = Path("demo") + (project / "pages").mkdir(parents=True) + (project / "public").mkdir() + (project / "pages" / "index.pyxl").write_text( + "import React from 'react';\n" + "export default function Page() { return
; }\n", + encoding="utf-8", + ) + (project / "package.json").write_text( + json.dumps({"name": "demo", "devDependencies": {"typescript": "^5.5.0"}}), + encoding="utf-8", + ) + + monkeypatch.setattr("shutil.which", lambda cmd: None) + + result = runner.invoke(app, ["typecheck", "demo"], catch_exceptions=False) + assert result.exit_code == 1 + assert "declared in package.json but not installed" in result.stdout + assert "npm install" in result.stdout + assert "--save-dev" not in result.stdout + + +def test_typecheck_failed_run_never_reports_zero_errors(monkeypatch) -> None: + """A non-zero tsc exit with no `error TS` diagnostics (crash, wrong binary) + must not produce the self-contradictory 'failed with 0 error(s)' summary.""" + with runner.isolated_filesystem(): + project = Path("demo") + (project / "pages").mkdir(parents=True) + (project / "public").mkdir() + (project / "pages" / "index.pyxl").write_text( + "import React from 'react';\n" + "export default function Page() { return
; }\n", + encoding="utf-8", + ) + + monkeypatch.setattr("pyxle.cli._find_tsc", lambda root: ["tsc"]) + + fake_result = subprocess.CompletedProcess( + args=["tsc", "--noEmit"], + returncode=1, + stdout="This is not the tsc command you are looking for\n", + stderr="", + ) + monkeypatch.setattr("pyxle.cli.subprocess.run", lambda *a, **kw: fake_result) + + result = runner.invoke(app, ["typecheck", "demo"], catch_exceptions=False) + assert result.exit_code == 1 + assert "0 error(s)" not in result.stdout + assert "exited with code 1" in result.stdout + + +def test_find_tsc_has_no_npx_fallback(tmp_path: Path, monkeypatch) -> None: + """`npx --yes tsc` resolves npm's placeholder `tsc` package, never the real + compiler — _find_tsc must not fall back to it.""" + from pyxle.cli import _find_tsc + + monkeypatch.setattr( + "shutil.which", lambda cmd: "/usr/bin/npx" if cmd == "npx" else None + ) + + assert _find_tsc(tmp_path) is None + + +def test_typescript_declared_reads_package_json(tmp_path: Path) -> None: + from pyxle.cli import _typescript_declared + + # No package.json at all. + assert _typescript_declared(tmp_path) is False + + package_json = tmp_path / "package.json" + package_json.write_text(json.dumps({"devDependencies": {"vite": "^6.3.5"}})) + assert _typescript_declared(tmp_path) is False + + package_json.write_text(json.dumps({"devDependencies": {"typescript": "^5.5.0"}})) + assert _typescript_declared(tmp_path) is True + + package_json.write_text(json.dumps({"dependencies": {"typescript": "^5.5.0"}})) + assert _typescript_declared(tmp_path) is True + + package_json.write_text("{not json") + assert _typescript_declared(tmp_path) is False + + package_json.write_text(json.dumps(["not", "a", "dict"])) + assert _typescript_declared(tmp_path) is False + + def test_typecheck_command_succeeds_on_clean_output(monkeypatch) -> None: with runner.isolated_filesystem(): project = Path("demo") @@ -2053,16 +2249,6 @@ def capture(message: str, *, fg: str | None = None, bold: bool = False) -> None: assert "Some random output line" in captured[0] -def test_find_tsc_falls_back_to_npx(tmp_path: Path, monkeypatch) -> None: - from pyxle.cli import _find_tsc - - monkeypatch.setattr("shutil.which", lambda cmd: "/usr/local/bin/npx" if cmd == "npx" else None) - - result = _find_tsc(tmp_path) - assert result is not None - assert "npx" in result[0] - - # --------------------------------------------------------------------------- # version callback / --version (lines 52-53) # --------------------------------------------------------------------------- @@ -2582,8 +2768,10 @@ def test_typecheck_command_lazy_imports_devserver_settings(monkeypatch) -> None: ) monkeypatch.setattr("pyxle.cli.DevServerSettings", None) - # Stop before tsc: a missing tsconfig short-circuits after the - # lazy import + build, which is enough to exercise lines 868-870. + # Get past the up-front tsc resolution, then stop at the missing + # tsconfig after the lazy import + build — enough to exercise the + # lazy-import branch. + monkeypatch.setattr("pyxle.cli._find_tsc", lambda root: ["tsc"]) monkeypatch.setattr("pyxle.devserver.builder.build_once", lambda s: None) result = runner.invoke(app, ["typecheck", "demo"], catch_exceptions=False) @@ -2622,6 +2810,7 @@ def test_typecheck_command_build_failure_exits(monkeypatch) -> None: def boom(_settings): raise RuntimeError("build blew up") + monkeypatch.setattr("pyxle.cli._find_tsc", lambda root: ["tsc"]) monkeypatch.setattr("pyxle.devserver.builder.build_once", boom) result = runner.invoke(app, ["typecheck", "demo"], catch_exceptions=False) @@ -2638,13 +2827,10 @@ def test_typecheck_command_errors_when_tsconfig_missing(monkeypatch) -> None: (project / "pages").mkdir(parents=True) (project / "public").mkdir(parents=True) - # build_once is stubbed so no real tsconfig.json is produced. + # tsc resolves up front; build_once is stubbed so no real + # tsconfig.json is produced and the missing-tsconfig branch fires. + monkeypatch.setattr("pyxle.cli._find_tsc", lambda root: ["tsc"]) monkeypatch.setattr("pyxle.devserver.builder.build_once", lambda s: None) - # _find_tsc should never be consulted; the missing tsconfig wins first. - monkeypatch.setattr( - "pyxle.cli._find_tsc", - lambda root: (_ for _ in ()).throw(AssertionError("tsc lookup too early")), - ) result = runner.invoke(app, ["typecheck", "demo"], catch_exceptions=False)