From 8d53fc813bfd25867a7714f08a939f7fa8ca3e04 Mon Sep 17 00:00:00 2001 From: Petr Date: Wed, 20 May 2026 22:16:50 +0200 Subject: [PATCH 1/2] fix(0.43.7): repair Windows wheel build hook (closes #320) Windows installation via 'uv tool install git+...' was completely broken by two independent bugs in hatch_build.py, with no working install path. Bug 1 (npm present): npm is npm.cmd on Windows; a bare subprocess.check_call(["npm", ...]) raises FileNotFoundError (an OSError subclass, not CalledProcessError), so the build crashed with WinError 2. Fix: pass the resolved shutil.which("npm") path (which is npm.cmd on Windows and runs via the system shell even with shell=False) and widen the except to (CalledProcessError, OSError) so a spawn failure degrades to a UI-less wheel instead of aborting the build. Bug 2 (npm absent): an early return left _ui_dist/ missing, but the unconditional force-include in pyproject.toml requires it to exist, so hatchling failed with 'Forced include not found'. Fix: every code path now guarantees _ui_dist/ exists via _ensure_target() (an empty dir is enough; the runtime UI detector keys on index.html). Build logic is extracted into a pure, hatchling-free _bundle_ui() so it is unit-testable in a plain dev venv. New KBAGENT_SKIP_UI_BUILD=1 env var ships a deliberate CLI-only wheel. Verified without a Windows machine: - tests/test_build_hook.py (20 tests) reproduces both bugs via mocks on any OS and asserts _ui_dist/ always exists; full suite 3428 passed. - New windows-latest CI job runs a real uv build on a free GitHub runner and asserts (scripts/check_wheel_ui.py) that a normal build bundles _ui_dist/index.html while a KBAGENT_SKIP_UI_BUILD=1 build does not. No CLI command surface changed (only a build-time env var). --- .claude-plugin/marketplace.json | 2 +- .github/workflows/ci.yml | 51 ++++ hatch_build.py | 175 +++++++++---- plugins/kbagent/.claude-plugin/plugin.json | 2 +- pyproject.toml | 2 +- scripts/check_wheel_ui.py | 81 ++++++ src/keboola_agent_cli/changelog.py | 4 + tests/test_build_hook.py | 281 +++++++++++++++++++++ uv.lock | 2 +- 9 files changed, 548 insertions(+), 52 deletions(-) create mode 100644 scripts/check_wheel_ui.py create mode 100644 tests/test_build_hook.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fe08cf8b..ce268548 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.43.6", + "version": "0.43.7", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da1a7ddb..f70acc8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,3 +57,54 @@ jobs: - name: Tests run: uv run pytest tests/ -v -m "not integration" + + # Real wheel build on Windows -- the only place the issue #320 fixes can be + # verified end-to-end without a Windows developer machine. GitHub provides + # windows-latest runners (with Node/npm preinstalled) for free, so the + # `npm.cmd` invocation (Bug 1) and the force-include path resolution (Bug 2) + # are exercised against a real `uv build` rather than mocks. + build-windows: + name: Windows wheel build (issue #320) + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + + - uses: astral-sh/setup-uv@v6 + with: + version: "latest" + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + # Bug 1: with npm present, the hook must resolve `npm.cmd` via + # shutil.which and run it (shell=False) instead of crashing with + # `FileNotFoundError [WinError 2]`, and the SPA must land in the wheel. + - name: Build wheel WITH bundled UI + run: uv build --wheel + + - name: Assert the SPA is bundled (Bug 1 fixed) + run: python scripts/check_wheel_ui.py --expect-ui + + - name: Install the built wheel and smoke-test the CLI on Windows + shell: pwsh + run: | + $whl = (Get-ChildItem dist/*.whl | Select-Object -First 1).FullName + uv run --no-project --with "$whl" kbagent --help + + # Bug 2: with the SPA bundle skipped, the force-include must still + # resolve (empty `_ui_dist/`) and the wheel must build successfully. + - name: Build CLI-only wheel WITHOUT UI + shell: pwsh + env: + KBAGENT_SKIP_UI_BUILD: "1" + run: | + Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue + uv build --wheel + + - name: Assert CLI-only wheel built without the SPA (Bug 2 fixed) + run: python scripts/check_wheel_ui.py --no-ui diff --git a/hatch_build.py b/hatch_build.py index 606db04a..01a4b812 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -24,68 +24,147 @@ still work; only ``kbagent serve --ui`` will fail with a "no UI bundled" error pointing the user at install instructions. +**Cross-platform note (issue #320).** Two Windows-specific traps are +handled here: + +- ``npm`` on Windows is a batch launcher (``npm.cmd``). A bare + ``subprocess.check_call(["npm", ...])`` cannot find it and raises + ``FileNotFoundError`` (a subclass of ``OSError``, *not* + ``CalledProcessError``). We pass the full path returned by + ``shutil.which("npm")`` -- which is ``...\\npm.cmd`` on Windows, and + ``CreateProcess`` happily runs a ``.cmd`` via the system shell even with + ``shell=False`` -- and we widen the ``except`` to ``OSError`` so a failed + invocation degrades to a UI-less wheel instead of killing the build. +- hatchling's ``force-include`` (see pyproject.toml) fails the whole build + if its source path is missing. Every code path here therefore guarantees + ``_ui_dist/`` exists on return (empty is fine -- hatchling includes zero + files from it and the runtime UI detector keys on ``index.html``). + +Set ``KBAGENT_SKIP_UI_BUILD=1`` to skip the on-the-fly npm build and ship a +CLI-only wheel deliberately (fast builds; CI exercising the no-UI path). + Wired in via ``[tool.hatch.build.hooks.custom]`` in pyproject.toml. """ from __future__ import annotations +import os import shutil import subprocess +from collections.abc import Callable from pathlib import Path -from typing import Any - -from hatchling.builders.hooks.plugin.interface import BuildHookInterface +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from hatchling.builders.hooks.plugin.interface import BuildHookInterface +else: + # hatchling is only present in the build environment (uv/pip provision it + # from ``[build-system].requires``). Fall back to ``object`` at runtime so + # the pure helpers below (``_bundle_ui`` / ``_ensure_target``) stay + # importable for unit tests in a plain dev venv that has no hatchling. + try: + from hatchling.builders.hooks.plugin.interface import BuildHookInterface + except ModuleNotFoundError: # pragma: no cover - exercised only without hatchling + BuildHookInterface = object + +# Set to "1" to ship a CLI-only wheel: skip bundling the SPA entirely, even if +# a prebuilt ``web/frontend/dist`` exists. The wheel still builds -- an empty +# ``_ui_dist/`` placeholder is created so hatchling's force-include resolves, +# and ``kbagent serve --ui`` surfaces the friendly "no UI bundled" error. +# Useful for fast CLI-only builds and for exercising the no-UI path in CI +# without uninstalling Node. +SKIP_UI_BUILD_ENV = "KBAGENT_SKIP_UI_BUILD" + + +def _ensure_target(target: Path) -> None: + """Guarantee the force-include source dir exists so the wheel build works. + + hatchling's ``force-include`` fails the *entire* wheel build if its source + path is missing (issue #320, Bug 2). An empty directory satisfies it -- + hatchling includes zero files from it, and the runtime UI detector keys on + ``index.html`` (absent here), so ``kbagent serve --ui`` degrades to a + friendly "no UI bundled" error rather than a build-time crash. + """ + target.mkdir(parents=True, exist_ok=True) + + +def _bundle_ui(repo_root: Path, log: Callable[[str], None] = print) -> None: + """Populate ``src/keboola_agent_cli/_ui_dist/`` for wheel inclusion. + + Extracted from :class:`CustomBuildHook` so it can be unit-tested without a + full hatchling build context. ``log`` is injected for the same reason. + + Postcondition: ``_ui_dist/`` always exists on return (see + :func:`_ensure_target`). + """ + dist = repo_root / "web" / "frontend" / "dist" + target = repo_root / "src" / "keboola_agent_cli" / "_ui_dist" + frontend_dir = repo_root / "web" / "frontend" + + # Always start from a clean slate so stale assets from a previous build + # don't leak into the new wheel. We rebuild target each time. + if target.exists(): + shutil.rmtree(target) + + # 1) Explicit opt-out: ship a CLI-only wheel. Checked first so it wins even + # over a prebuilt dist -- "skip UI build" means "no UI in this wheel". + if os.environ.get(SKIP_UI_BUILD_ENV) == "1": + log(f"{SKIP_UI_BUILD_ENV}=1 set; skipping SPA bundle (CLI-only wheel).") + _ensure_target(target) + return + + # 2) Prebuilt dist on disk -- the maintainer ran ``make web-build`` first. + if (dist / "index.html").exists(): + log(f"copying {dist} -> {target}") + shutil.copytree(dist, target) + return + + # 3) No prebuilt dist. Build it iff the source tree exists AND npm is on + # PATH. ``shutil.which`` returns the resolved path -- on Windows that is + # ``...\\npm.cmd``; passing the full path lets CreateProcess run the + # batch launcher even with shell=False (a bare "npm" raises WinError 2). + npm = shutil.which("npm") + if not (frontend_dir.exists() and npm): + why = "no `npm` on PATH" if frontend_dir.exists() else "no web/frontend/ dir" + log( + f"WARNING: no prebuilt SPA and {why}; wheel will not bundle the UI. " + "`kbagent serve --ui` will fail until the user rebuilds the SPA manually." + ) + _ensure_target(target) + return + + log("no prebuilt dist found; running npm build") + try: + subprocess.check_call( + [npm, "ci", "--prefer-offline", "--no-audit", "--no-fund"], + cwd=frontend_dir, + ) + subprocess.check_call([npm, "run", "build"], cwd=frontend_dir) + except (subprocess.CalledProcessError, OSError) as exc: + # OSError covers FileNotFoundError/PermissionError from the spawn + # itself (the Windows ``npm.cmd`` trap); CalledProcessError covers a + # non-zero npm exit. Either way: degrade to a UI-less wheel. + log( + f"WARNING: npm build failed ({exc}); wheel will not bundle the UI. " + "`kbagent serve --ui` will fail until the user rebuilds the SPA manually." + ) + _ensure_target(target) + return + + if not (dist / "index.html").exists(): + log("WARNING: build did not produce dist/index.html; skipping UI bundle.") + _ensure_target(target) + return + + log(f"copying {dist} -> {target}") + shutil.copytree(dist, target) class CustomBuildHook(BuildHookInterface): PLUGIN_NAME = "build-ui" def initialize(self, version: str, build_data: dict[str, Any]) -> None: - repo_root = Path(self.root).resolve() - dist = repo_root / "web" / "frontend" / "dist" - target = repo_root / "src" / "keboola_agent_cli" / "_ui_dist" - frontend_dir = repo_root / "web" / "frontend" - - # Always start from a clean slate so stale assets from a previous - # build don't leak into the new wheel. We rebuild target each time. - if target.exists(): - shutil.rmtree(target) - - if not (dist / "index.html").exists(): - # No prebuilt dist. Try to build it iff npm is present AND the - # source tree exists (true for `uv tool install git+...` and - # local development; false if someone uploaded an sdist that - # excluded `web/`). - if frontend_dir.exists() and shutil.which("npm"): - self._log("no prebuilt dist found; running npm build") - try: - subprocess.check_call( - ["npm", "ci", "--prefer-offline", "--no-audit", "--no-fund"], - cwd=frontend_dir, - ) - subprocess.check_call(["npm", "run", "build"], cwd=frontend_dir) - except subprocess.CalledProcessError as exc: - self._log( - f"WARNING: npm build failed ({exc}); wheel will not bundle " - "the UI. `kbagent serve --ui` will fail until the user " - "rebuilds the SPA manually.", - ) - return - else: - why = "no `npm` on PATH" if frontend_dir.exists() else "no web/frontend/ dir" - self._log( - f"WARNING: no prebuilt SPA and {why}; wheel will not bundle " - "the UI. `kbagent serve --ui` will fail until the user " - "rebuilds the SPA manually.", - ) - return - - if not (dist / "index.html").exists(): - self._log("WARNING: build did not produce dist/index.html; skipping UI bundle.") - return - - self._log(f"copying {dist} -> {target}") - shutil.copytree(dist, target) + _bundle_ui(Path(self.root).resolve(), self._log) def _log(self, message: str) -> None: # Hatchling's BuilderInterface exposes ``app`` for nicely-formatted diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 075b1e82..f213fc61 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.43.6", + "version": "0.43.7", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/pyproject.toml b/pyproject.toml index 104ba2fc..1fda77e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.43.6" +version = "0.43.7" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/check_wheel_ui.py b/scripts/check_wheel_ui.py new file mode 100644 index 00000000..b20b5a8d --- /dev/null +++ b/scripts/check_wheel_ui.py @@ -0,0 +1,81 @@ +"""CI helper: assert whether a built wheel bundles the React SPA. + +Used by the Windows wheel-build CI job to verify the issue #320 fixes +end-to-end on a real Windows runner (where no developer machine is needed): + +- a normal ``uv build`` must bundle ``_ui_dist/index.html`` (Bug 1: the + ``npm.cmd`` invocation actually succeeds), and +- a ``KBAGENT_SKIP_UI_BUILD=1`` build must still produce a valid wheel with + no SPA (Bug 2: the empty ``_ui_dist`` placeholder lets force-include + resolve instead of crashing the build). + +The check is OS-independent, so the same assertion guards local builds too. + +Usage: + python scripts/check_wheel_ui.py --expect-ui [--dist DIR] + python scripts/check_wheel_ui.py --no-ui [--dist DIR] +""" + +from __future__ import annotations + +import argparse +import glob +import sys +import zipfile + +# Path of the bundled SPA entry point inside the wheel (zip paths always use +# forward slashes, including on Windows). +UI_MARKER = "keboola_agent_cli/_ui_dist/index.html" + + +def wheel_bundles_ui(wheel_path: str) -> bool: + """Return True iff the wheel contains the bundled SPA entry point.""" + with zipfile.ZipFile(wheel_path) as zf: + return UI_MARKER in zf.namelist() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--expect-ui", + action="store_true", + help="fail unless the wheel bundles the SPA (normal build)", + ) + group.add_argument( + "--no-ui", + action="store_true", + help="fail if the wheel bundles the SPA (CLI-only build)", + ) + parser.add_argument("--dist", default="dist", help="directory containing the built wheel(s)") + args = parser.parse_args(argv) + + wheels = sorted(glob.glob(f"{args.dist}/*.whl")) + if not wheels: + print(f"ERROR: no wheel found in {args.dist}/", file=sys.stderr) + return 1 + + # Newest by name -- a single build produces one wheel anyway. + wheel = wheels[-1] + has_ui = wheel_bundles_ui(wheel) + print(f"wheel={wheel} bundles_ui={has_ui}") + + if args.expect_ui and not has_ui: + print( + f"FAIL: expected '{UI_MARKER}' in the wheel (issue #320 Bug 1 regression).", + file=sys.stderr, + ) + return 1 + if args.no_ui and has_ui: + print( + f"FAIL: did not expect '{UI_MARKER}' in a CLI-only wheel.", + file=sys.stderr, + ) + return 1 + + print("OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index b69ef3d9..b7d29ad1 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,10 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.43.7": [ + 'Fix: Windows installation was completely broken -- two independent bugs in the wheel build hook (`hatch_build.py`) meant `uv tool install git+https://github.com/padak/keboola_agent_cli` had no working path on Windows (closes #320). **Bug 1 (npm present):** `npm` on Windows is a batch launcher (`npm.cmd`), and a bare `subprocess.check_call(["npm", ...], shell=False)` cannot resolve it -- `CreateProcess` raises `FileNotFoundError [WinError 2]`, which is a subclass of `OSError`, NOT `subprocess.CalledProcessError`, so the existing `except subprocess.CalledProcessError` let it propagate and kill the build. Two-part fix: (a) the hook now hands subprocess the *resolved* path from `shutil.which("npm")` (already computed for detection, previously thrown away) -- on Windows that is `...\\npm.cmd`, and `CreateProcess` runs a `.cmd` via the system shell even with `shell=False`, so the SPA actually builds; (b) the `except` is widened to `(subprocess.CalledProcessError, OSError)` so any spawn failure degrades to a UI-less wheel instead of aborting the build. **Bug 2 (npm absent):** the hook returned early without creating `src/keboola_agent_cli/_ui_dist/`, but `pyproject.toml`\'s unconditional `[tool.hatch.build.targets.wheel.force-include]` requires that path to exist -- hatchling failed the whole build with `Forced include not found`. Fix: every code path now guarantees `_ui_dist/` exists on return via `_ensure_target()` (an empty dir is enough -- hatchling includes zero files from it, and the runtime UI detector keys on `index.html`, which is absent, so `kbagent serve --ui` still surfaces the friendly "no UI bundled" error). CLI-only callers and the normal (UI-present) build path are byte-for-byte unchanged. New `KBAGENT_SKIP_UI_BUILD=1` build-time env var ships a deliberate CLI-only wheel (fast builds; exercising the no-UI path) -- checked before the prebuilt-dist probe so it wins even when a `dist/` exists. The build logic is extracted into a pure, hatchling-free `_bundle_ui(repo_root, log)` function so it is unit-testable in a plain dev venv (the `hatchling` import is guarded behind `TYPE_CHECKING` + a runtime `try/except` fallback to `object`).', + 'Tests: new `tests/test_build_hook.py` (20 tests) drives `_bundle_ui` directly to reproduce both bugs WITHOUT a Windows machine -- Bug 1\'s `FileNotFoundError` and `CalledProcessError` are simulated with mocks (asserting no propagation + that the resolved `npm.cmd` path, not the bare `"npm"`, reaches subprocess), and every early-return path is asserted to leave `_ui_dist/` existing (Bug 2). A new `windows-latest` CI job (`.github/workflows/ci.yml`) runs a real `uv build` on a free GitHub Windows runner (Node/npm preinstalled) and asserts via `scripts/check_wheel_ui.py` that a normal build bundles `_ui_dist/index.html` (Bug 1 truly fixed, not just degraded) while a `KBAGENT_SKIP_UI_BUILD=1` build produces a valid wheel with no SPA (Bug 2). The Bug-2 force-include + empty-dir interaction is OS-independent, so it is additionally proven by the local `uv build` path. No CLI command surface changed -- `## All CLI Commands`, `AGENT_CONTEXT`, `SKILL.md`, and `keboola-expert.md` are intentionally untouched (the only new knob is a build-time env var, not a runtime flag).', + ], "0.43.6": [ 'New: `kbagent job run --mode run|debug` exposes the Queue API job `mode` body field, which was previously hard-coded to `"run"` inside `JobService.run_job` (the underlying `KeboolaClient.create_job` already accepted a `mode` kwarg but no service-layer or CLI path threaded it through). `--mode debug` flips the Queue worker into debug mode -- the component runs with the same configuration and inputs as a normal run, but its output stream is redirected into a Storage File tagged `debug-` instead of into the destination buckets, so the run is safe to repeat on a production configuration for diagnostics (reproducing a failure, capturing the worker\'s actual output, A/B-comparing a flag change) without touching downstream tables. Default behaviour is unchanged: omit `--mode` and the body still carries `"mode": "run"`, the same wire shape every prior release sent. Validation lives at the service boundary (`KeboolaApiError` with `INVALID_ARGUMENT`) and the CLI also gates the flag with `click.Choice(sorted(VALID_JOB_MODES))`, so a typo (`--mode dry-run`) exits 2 with a Click usage error before any network round-trip -- it cannot reach the wire and surface as an opaque Queue API 422. The human-mode \'Running ...\' banner appends a bold-yellow `mode=debug` chip when the flag is non-default so operators see at a glance that a run is diagnostic, not production. The hint surface (`kbagent --hint client job run` / `--hint service`) emits the new `mode="..."` kwarg on both the `create_job` and `JobService.run_job` calls so AI agents that copy the rendered Python see the parameter inline. New constants `VALID_JOB_MODES = frozenset({"run", "debug"})` and `DEFAULT_JOB_MODE = "run"` in `constants.py`. Tests: 3 new service-layer tests in `test_services.py::TestJobServiceRunJobMode` (default lands as `mode="run"`, opt-in `mode="debug"` forwarded, unknown mode rejected at the service boundary and never reaches the wire), 3 new CLI tests in `test_cli.py::TestJobRun` (default, `--mode debug` forwarded, `--mode dry-run` exits 2 via the Click choice gate), and 2 new client-layer tests in `test_client.py::TestCreateJob` (`mode="run"` is in the POST /jobs body by default, `mode="debug"` reaches the body verbatim). Existing four `create_job.assert_called_once_with` assertions in `test_services.py` updated to include `mode="run"` since the call signature now always passes it. Plugin sync surfaces (silent-drift risks per convention #17): `CLAUDE.md ## All CLI Commands`, `commands/context.py::AGENT_CONTEXT`, `plugins/kbagent/skills/kbagent/references/commands-reference.md`, and `plugins/kbagent/skills/kbagent/references/gotchas.md` (new `(since v0.43.6)` entry) all updated so AI agents on the new version recommend `--mode debug` correctly.', ], diff --git a/tests/test_build_hook.py b/tests/test_build_hook.py new file mode 100644 index 00000000..0c28ba94 --- /dev/null +++ b/tests/test_build_hook.py @@ -0,0 +1,281 @@ +"""Unit tests for the wheel build hook (``hatch_build.py``). + +Regression coverage for issue #320 ("Windows installation completely broken"). +The two bugs are reproduced *without a Windows machine*: + +- **Bug 1** -- a bare ``subprocess.check_call(["npm", ...])`` raises + ``FileNotFoundError`` on Windows because ``npm`` is ``npm.cmd`` (a batch + launcher ``CreateProcess`` can't resolve from the bare name). We assert the + hook (a) passes the *resolved* ``shutil.which`` path so the ``.cmd`` runs, + and (b) catches ``OSError`` so a spawn failure degrades to a UI-less wheel + instead of killing the build. Both are simulated with mocks, so they run on + any OS. +- **Bug 2** -- an early ``return`` left ``_ui_dist/`` missing, and hatchling's + ``force-include`` then failed the whole build. We assert every code path + leaves ``_ui_dist/`` existing on disk. (The force-include interaction itself + is OS-independent and is additionally exercised end-to-end by the CI wheel + build.) +""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +import zipfile +from pathlib import Path +from unittest import mock + +import pytest + +# ``hatch_build.py`` lives at the repo root (not under ``src/``), so it is not +# importable as a normal package. Load it by file path and register it in +# ``sys.modules`` so ``mock.patch("hatch_build.")`` resolves to *this* +# instance. The hatchling import inside is guarded, so this works in a plain +# dev venv that has no hatchling installed. +_HOOK_PATH = Path(__file__).resolve().parents[1] / "hatch_build.py" +_spec = importlib.util.spec_from_file_location("hatch_build", _HOOK_PATH) +assert _spec is not None and _spec.loader is not None +hatch_build = importlib.util.module_from_spec(_spec) +sys.modules["hatch_build"] = hatch_build +_spec.loader.exec_module(hatch_build) + +# The CI wheel-content assertion helper lives in ``scripts/`` -- load it the +# same way so its logic is regression-tested in normal (ubuntu) CI, not only by +# the Windows wheel-build job that calls it as a subprocess. +_HELPER_PATH = Path(__file__).resolve().parents[1] / "scripts" / "check_wheel_ui.py" +_helper_spec = importlib.util.spec_from_file_location("check_wheel_ui", _HELPER_PATH) +assert _helper_spec is not None and _helper_spec.loader is not None +check_wheel_ui = importlib.util.module_from_spec(_helper_spec) +sys.modules["check_wheel_ui"] = check_wheel_ui +_helper_spec.loader.exec_module(check_wheel_ui) + + +def _make_repo(root: Path, *, with_frontend: bool = True, with_dist: bool = False) -> Path: + """Lay out a minimal fake repo tree and return ``root``.""" + (root / "src" / "keboola_agent_cli").mkdir(parents=True) + if with_frontend: + (root / "web" / "frontend").mkdir(parents=True) + if with_dist: + dist = root / "web" / "frontend" / "dist" + dist.mkdir(parents=True, exist_ok=True) + (dist / "index.html").write_text("app", encoding="utf-8") + (dist / "assets").mkdir() + (dist / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8") + return root + + +def _target(root: Path) -> Path: + return root / "src" / "keboola_agent_cli" / "_ui_dist" + + +class TestBundleUiHappyPath: + def test_prebuilt_dist_is_copied(self, tmp_path: Path) -> None: + root = _make_repo(tmp_path, with_dist=True) + + hatch_build._bundle_ui(root, log=lambda _msg: None) + + target = _target(root) + assert (target / "index.html").read_text(encoding="utf-8") == "app" + assert (target / "assets" / "app.js").exists() + + def test_stale_target_is_cleared_before_copy(self, tmp_path: Path) -> None: + root = _make_repo(tmp_path, with_dist=True) + target = _target(root) + target.mkdir(parents=True) + (target / "STALE.txt").write_text("from a previous build", encoding="utf-8") + + hatch_build._bundle_ui(root, log=lambda _msg: None) + + assert not (target / "STALE.txt").exists() + assert (target / "index.html").exists() + + +class TestBundleUiBug2TargetAlwaysExists: + """Every early-return path must leave ``_ui_dist/`` existing (issue #320 Bug 2).""" + + def test_no_npm_creates_empty_target(self, tmp_path: Path) -> None: + root = _make_repo(tmp_path) # frontend present, no dist + with mock.patch.object(hatch_build.shutil, "which", return_value=None): + hatch_build._bundle_ui(root, log=lambda _msg: None) + + target = _target(root) + assert target.is_dir() + assert not (target / "index.html").exists() + + def test_no_frontend_dir_creates_empty_target(self, tmp_path: Path) -> None: + root = _make_repo(tmp_path, with_frontend=False) + with mock.patch.object(hatch_build.shutil, "which", return_value=None): + hatch_build._bundle_ui(root, log=lambda _msg: None) + + assert _target(root).is_dir() + + def test_skip_env_var_creates_empty_target( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + root = _make_repo(tmp_path) + monkeypatch.setenv(hatch_build.SKIP_UI_BUILD_ENV, "1") + # npm IS available -- the env var must short-circuit before invoking it. + with ( + mock.patch.object(hatch_build.shutil, "which", return_value="/usr/bin/npm"), + mock.patch.object(hatch_build.subprocess, "check_call") as check_call, + ): + hatch_build._bundle_ui(root, log=lambda _msg: None) + + check_call.assert_not_called() + assert _target(root).is_dir() + assert not (_target(root) / "index.html").exists() + + def test_skip_env_var_wins_over_prebuilt_dist( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + # "skip UI build" means a CLI-only wheel even when a dist is on disk. + root = _make_repo(tmp_path, with_dist=True) + monkeypatch.setenv(hatch_build.SKIP_UI_BUILD_ENV, "1") + + hatch_build._bundle_ui(root, log=lambda _msg: None) + + assert _target(root).is_dir() + assert not (_target(root) / "index.html").exists() + + def test_build_without_index_html_creates_empty_target(self, tmp_path: Path) -> None: + # npm "succeeds" but does not produce dist/index.html -> still no crash. + root = _make_repo(tmp_path) + with ( + mock.patch.object(hatch_build.shutil, "which", return_value="/usr/bin/npm"), + mock.patch.object(hatch_build.subprocess, "check_call"), + ): + hatch_build._bundle_ui(root, log=lambda _msg: None) + + assert _target(root).is_dir() + assert not (_target(root) / "index.html").exists() + + +class TestBundleUiBug1NpmInvocation: + """The Windows ``npm.cmd`` traps (issue #320 Bug 1).""" + + def test_filenotfounderror_is_caught_not_propagated(self, tmp_path: Path) -> None: + """A bare ``["npm", ...]`` on Windows raises FileNotFoundError -- must degrade.""" + root = _make_repo(tmp_path) + with ( + mock.patch.object( + hatch_build.shutil, "which", return_value="C:\\Program Files\\nodejs\\npm.cmd" + ), + mock.patch.object( + hatch_build.subprocess, + "check_call", + side_effect=FileNotFoundError(2, "The system cannot find the file specified"), + ), + ): + # Must NOT raise -- the old code only caught CalledProcessError. + hatch_build._bundle_ui(root, log=lambda _msg: None) + + assert _target(root).is_dir() + + def test_calledprocesserror_is_caught(self, tmp_path: Path) -> None: + root = _make_repo(tmp_path) + with ( + mock.patch.object(hatch_build.shutil, "which", return_value="/usr/bin/npm"), + mock.patch.object( + hatch_build.subprocess, + "check_call", + side_effect=subprocess.CalledProcessError(1, ["npm", "ci"]), + ), + ): + hatch_build._bundle_ui(root, log=lambda _msg: None) + + assert _target(root).is_dir() + + def test_passes_resolved_npm_path_not_bare_name(self, tmp_path: Path) -> None: + """Root-cause fix: the resolved ``shutil.which`` path (``npm.cmd`` on + Windows) is handed to subprocess, never the bare string ``"npm"``.""" + root = _make_repo(tmp_path) + resolved = "C:\\Program Files\\nodejs\\npm.cmd" + with ( + mock.patch.object(hatch_build.shutil, "which", return_value=resolved), + mock.patch.object(hatch_build.subprocess, "check_call") as check_call, + ): + hatch_build._bundle_ui(root, log=lambda _msg: None) + + assert check_call.call_count == 2 + ci_cmd = check_call.call_args_list[0].args[0] + build_cmd = check_call.call_args_list[1].args[0] + assert ci_cmd[0] == resolved + assert build_cmd[0] == resolved + assert ci_cmd[1] == "ci" + assert build_cmd[1:] == ["run", "build"] + + def test_successful_npm_build_is_bundled(self, tmp_path: Path) -> None: + """When npm produces dist/index.html, it gets copied into _ui_dist/.""" + root = _make_repo(tmp_path) + dist = root / "web" / "frontend" / "dist" + + def fake_check_call(cmd: list[str], **_kwargs: object) -> int: + # Simulate ``npm run build`` emitting the SPA on the second call. + if cmd[1:] == ["run", "build"]: + dist.mkdir(parents=True, exist_ok=True) + (dist / "index.html").write_text("built", encoding="utf-8") + return 0 + + with ( + mock.patch.object(hatch_build.shutil, "which", return_value="/usr/bin/npm"), + mock.patch.object(hatch_build.subprocess, "check_call", side_effect=fake_check_call), + ): + hatch_build._bundle_ui(root, log=lambda _msg: None) + + assert (_target(root) / "index.html").read_text(encoding="utf-8") == "built" + + +class TestEnsureTarget: + def test_creates_missing_dir(self, tmp_path: Path) -> None: + target = tmp_path / "deep" / "_ui_dist" + hatch_build._ensure_target(target) + assert target.is_dir() + + def test_idempotent_on_existing_dir(self, tmp_path: Path) -> None: + target = tmp_path / "_ui_dist" + target.mkdir() + hatch_build._ensure_target(target) # must not raise + assert target.is_dir() + + +def _fake_wheel(dist_dir: Path, *, with_ui: bool, name: str = "pkg-0.0.1-py3-none-any.whl") -> Path: + """Write a minimal wheel (zip) with or without the bundled SPA marker.""" + dist_dir.mkdir(parents=True, exist_ok=True) + wheel = dist_dir / name + with zipfile.ZipFile(wheel, "w") as zf: + zf.writestr("keboola_agent_cli/__init__.py", "") + if with_ui: + zf.writestr(check_wheel_ui.UI_MARKER, "app") + return wheel + + +class TestCheckWheelUiHelper: + """The ``scripts/check_wheel_ui.py`` CI assertion helper (issue #320).""" + + def test_wheel_bundles_ui_true(self, tmp_path: Path) -> None: + wheel = _fake_wheel(tmp_path, with_ui=True) + assert check_wheel_ui.wheel_bundles_ui(str(wheel)) is True + + def test_wheel_bundles_ui_false(self, tmp_path: Path) -> None: + wheel = _fake_wheel(tmp_path, with_ui=False) + assert check_wheel_ui.wheel_bundles_ui(str(wheel)) is False + + def test_expect_ui_passes_on_ui_wheel(self, tmp_path: Path) -> None: + _fake_wheel(tmp_path, with_ui=True) + assert check_wheel_ui.main(["--expect-ui", "--dist", str(tmp_path)]) == 0 + + def test_expect_ui_fails_on_cli_only_wheel(self, tmp_path: Path) -> None: + _fake_wheel(tmp_path, with_ui=False) + assert check_wheel_ui.main(["--expect-ui", "--dist", str(tmp_path)]) == 1 + + def test_no_ui_passes_on_cli_only_wheel(self, tmp_path: Path) -> None: + _fake_wheel(tmp_path, with_ui=False) + assert check_wheel_ui.main(["--no-ui", "--dist", str(tmp_path)]) == 0 + + def test_no_ui_fails_on_ui_wheel(self, tmp_path: Path) -> None: + _fake_wheel(tmp_path, with_ui=True) + assert check_wheel_ui.main(["--no-ui", "--dist", str(tmp_path)]) == 1 + + def test_missing_wheel_is_an_error(self, tmp_path: Path) -> None: + assert check_wheel_ui.main(["--expect-ui", "--dist", str(tmp_path)]) == 1 diff --git a/uv.lock b/uv.lock index b7a6b488..f5bda26a 100644 --- a/uv.lock +++ b/uv.lock @@ -496,7 +496,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.43.6" +version = "0.43.7" source = { editable = "." } dependencies = [ { name = "httpx" }, From ac33d79d96bb7b360516cc6c9841ab94bc0176a5 Mon Sep 17 00:00:00 2001 From: Petr Date: Wed, 20 May 2026 22:42:23 +0200 Subject: [PATCH 2/2] fix: changelog-check skips pre-release tags make changelog-check (scripts/generate_changelog.py --check) demanded a CHANGELOG entry for every GitHub release tag, including pre-releases. An in-flight beta tag (v0.44.0b1, from the unmerged 'kbagent agent' PR #310) therefore made local 'make check' red on every branch -- the beta's changelog entry lives on its own feature branch until that PR merges, per the CONTRIBUTING.md beta workflow. Skip pre-release tags in the coverage check (gh release list now also fetches isPrerelease), mirroring the auto-update path which only sees stable releases via /releases/latest. The filtering is extracted into a pure, I/O-free audit_changelog_coverage(tags, changelog) helper covered by tests/test_changelog_check.py (6 tests). Surfaced by the PR #322 review. changelog-check is local-only (not in CI) so this never turned CI red, but it blocked a clean local 'make check'. --- scripts/generate_changelog.py | 46 ++++++++++++++++----- src/keboola_agent_cli/changelog.py | 1 + tests/test_changelog_check.py | 65 ++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 tests/test_changelog_check.py diff --git a/scripts/generate_changelog.py b/scripts/generate_changelog.py index 41602d04..c62c6ebc 100644 --- a/scripts/generate_changelog.py +++ b/scripts/generate_changelog.py @@ -66,29 +66,57 @@ def _extract_summary(body: str) -> list[str]: return lines +def audit_changelog_coverage( + tags: list[dict], changelog: dict[str, object] +) -> tuple[list[str], int, int]: + """Split release tags into (missing, checked, skipped) against the changelog. + + Pure helper (no I/O) so it is unit-testable. ``tags`` are + ``gh release list --json tagName,isPrerelease`` entries. + + Pre-releases (PEP 440 betas / rcs, e.g. ``v0.44.0b1``) are skipped: per the + CONTRIBUTING.md beta workflow they are tagged on a feature branch and their + ``CHANGELOG`` entry rides along on that branch until the PR merges, so + ``main`` must not demand it. This mirrors the auto-update path, which only + sees stable releases via GitHub's ``/releases/latest`` and ignores + prereleases. + + Returns ``(missing_versions, stable_checked_count, prerelease_skipped_count)``. + """ + missing = [] + checked = 0 + skipped = 0 + for entry in tags: + if entry.get("isPrerelease"): + skipped += 1 + continue + checked += 1 + version = entry["tagName"].lstrip("v") + if version not in changelog: + missing.append(version) + return missing, checked, skipped + + def _check_mode() -> None: - """Verify all GitHub releases have entries in changelog.py.""" + """Verify all stable GitHub releases have entries in changelog.py.""" from keboola_agent_cli.changelog import CHANGELOG result = subprocess.run( - ["gh", "release", "list", "--limit", "50", "--json", "tagName"], + ["gh", "release", "list", "--limit", "50", "--json", "tagName,isPrerelease"], capture_output=True, text=True, check=True, ) tags = json.loads(result.stdout) - missing = [] - for entry in tags: - version = entry["tagName"].lstrip("v") - if version not in CHANGELOG: - missing.append(version) + missing, checked, skipped = audit_changelog_coverage(tags, CHANGELOG) + suffix = f" ({skipped} pre-release(s) skipped)" if skipped else "" if missing: - print(f"Missing changelog entries for: {', '.join(missing)}") + print(f"Missing changelog entries for: {', '.join(missing)}{suffix}") sys.exit(1) else: - print(f"All {len(tags)} releases have changelog entries.") + print(f"All {checked} stable releases have changelog entries.{suffix}") def main() -> None: diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index b7d29ad1..ed74a031 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -11,6 +11,7 @@ "0.43.7": [ 'Fix: Windows installation was completely broken -- two independent bugs in the wheel build hook (`hatch_build.py`) meant `uv tool install git+https://github.com/padak/keboola_agent_cli` had no working path on Windows (closes #320). **Bug 1 (npm present):** `npm` on Windows is a batch launcher (`npm.cmd`), and a bare `subprocess.check_call(["npm", ...], shell=False)` cannot resolve it -- `CreateProcess` raises `FileNotFoundError [WinError 2]`, which is a subclass of `OSError`, NOT `subprocess.CalledProcessError`, so the existing `except subprocess.CalledProcessError` let it propagate and kill the build. Two-part fix: (a) the hook now hands subprocess the *resolved* path from `shutil.which("npm")` (already computed for detection, previously thrown away) -- on Windows that is `...\\npm.cmd`, and `CreateProcess` runs a `.cmd` via the system shell even with `shell=False`, so the SPA actually builds; (b) the `except` is widened to `(subprocess.CalledProcessError, OSError)` so any spawn failure degrades to a UI-less wheel instead of aborting the build. **Bug 2 (npm absent):** the hook returned early without creating `src/keboola_agent_cli/_ui_dist/`, but `pyproject.toml`\'s unconditional `[tool.hatch.build.targets.wheel.force-include]` requires that path to exist -- hatchling failed the whole build with `Forced include not found`. Fix: every code path now guarantees `_ui_dist/` exists on return via `_ensure_target()` (an empty dir is enough -- hatchling includes zero files from it, and the runtime UI detector keys on `index.html`, which is absent, so `kbagent serve --ui` still surfaces the friendly "no UI bundled" error). CLI-only callers and the normal (UI-present) build path are byte-for-byte unchanged. New `KBAGENT_SKIP_UI_BUILD=1` build-time env var ships a deliberate CLI-only wheel (fast builds; exercising the no-UI path) -- checked before the prebuilt-dist probe so it wins even when a `dist/` exists. The build logic is extracted into a pure, hatchling-free `_bundle_ui(repo_root, log)` function so it is unit-testable in a plain dev venv (the `hatchling` import is guarded behind `TYPE_CHECKING` + a runtime `try/except` fallback to `object`).', 'Tests: new `tests/test_build_hook.py` (20 tests) drives `_bundle_ui` directly to reproduce both bugs WITHOUT a Windows machine -- Bug 1\'s `FileNotFoundError` and `CalledProcessError` are simulated with mocks (asserting no propagation + that the resolved `npm.cmd` path, not the bare `"npm"`, reaches subprocess), and every early-return path is asserted to leave `_ui_dist/` existing (Bug 2). A new `windows-latest` CI job (`.github/workflows/ci.yml`) runs a real `uv build` on a free GitHub Windows runner (Node/npm preinstalled) and asserts via `scripts/check_wheel_ui.py` that a normal build bundles `_ui_dist/index.html` (Bug 1 truly fixed, not just degraded) while a `KBAGENT_SKIP_UI_BUILD=1` build produces a valid wheel with no SPA (Bug 2). The Bug-2 force-include + empty-dir interaction is OS-independent, so it is additionally proven by the local `uv build` path. No CLI command surface changed -- `## All CLI Commands`, `AGENT_CONTEXT`, `SKILL.md`, and `keboola-expert.md` are intentionally untouched (the only new knob is a build-time env var, not a runtime flag).', + "Internal: `make changelog-check` (`scripts/generate_changelog.py --check`) now skips GitHub pre-release tags (PEP 440 betas/rcs, e.g. `v0.44.0b1`) instead of demanding a `CHANGELOG` entry for them. Per the CONTRIBUTING.md beta workflow a beta is tagged on its feature branch and its changelog entry rides along on that branch until the PR merges, so `main` must not require it -- mirroring the auto-update path, which only sees stable releases via GitHub's `/releases/latest` and ignores prereleases. Before this, an in-flight beta tag (`v0.44.0b1`, from the unmerged `kbagent agent` PR #310) made local `make check` red on every branch without ever turning CI red (`changelog-check` is local-only). The release-list filtering is extracted into a pure, I/O-free `audit_changelog_coverage(tags, changelog) -> (missing, checked, skipped)` helper, covered by 6 tests in `tests/test_changelog_check.py`; the surfaced count now reads e.g. `All 49 stable releases have changelog entries. (1 pre-release(s) skipped)`.", ], "0.43.6": [ 'New: `kbagent job run --mode run|debug` exposes the Queue API job `mode` body field, which was previously hard-coded to `"run"` inside `JobService.run_job` (the underlying `KeboolaClient.create_job` already accepted a `mode` kwarg but no service-layer or CLI path threaded it through). `--mode debug` flips the Queue worker into debug mode -- the component runs with the same configuration and inputs as a normal run, but its output stream is redirected into a Storage File tagged `debug-` instead of into the destination buckets, so the run is safe to repeat on a production configuration for diagnostics (reproducing a failure, capturing the worker\'s actual output, A/B-comparing a flag change) without touching downstream tables. Default behaviour is unchanged: omit `--mode` and the body still carries `"mode": "run"`, the same wire shape every prior release sent. Validation lives at the service boundary (`KeboolaApiError` with `INVALID_ARGUMENT`) and the CLI also gates the flag with `click.Choice(sorted(VALID_JOB_MODES))`, so a typo (`--mode dry-run`) exits 2 with a Click usage error before any network round-trip -- it cannot reach the wire and surface as an opaque Queue API 422. The human-mode \'Running ...\' banner appends a bold-yellow `mode=debug` chip when the flag is non-default so operators see at a glance that a run is diagnostic, not production. The hint surface (`kbagent --hint client job run` / `--hint service`) emits the new `mode="..."` kwarg on both the `create_job` and `JobService.run_job` calls so AI agents that copy the rendered Python see the parameter inline. New constants `VALID_JOB_MODES = frozenset({"run", "debug"})` and `DEFAULT_JOB_MODE = "run"` in `constants.py`. Tests: 3 new service-layer tests in `test_services.py::TestJobServiceRunJobMode` (default lands as `mode="run"`, opt-in `mode="debug"` forwarded, unknown mode rejected at the service boundary and never reaches the wire), 3 new CLI tests in `test_cli.py::TestJobRun` (default, `--mode debug` forwarded, `--mode dry-run` exits 2 via the Click choice gate), and 2 new client-layer tests in `test_client.py::TestCreateJob` (`mode="run"` is in the POST /jobs body by default, `mode="debug"` reaches the body verbatim). Existing four `create_job.assert_called_once_with` assertions in `test_services.py` updated to include `mode="run"` since the call signature now always passes it. Plugin sync surfaces (silent-drift risks per convention #17): `CLAUDE.md ## All CLI Commands`, `commands/context.py::AGENT_CONTEXT`, `plugins/kbagent/skills/kbagent/references/commands-reference.md`, and `plugins/kbagent/skills/kbagent/references/gotchas.md` (new `(since v0.43.6)` entry) all updated so AI agents on the new version recommend `--mode debug` correctly.', diff --git a/tests/test_changelog_check.py b/tests/test_changelog_check.py new file mode 100644 index 00000000..d103ab42 --- /dev/null +++ b/tests/test_changelog_check.py @@ -0,0 +1,65 @@ +"""Unit tests for the changelog-coverage audit (``scripts/generate_changelog.py``). + +Regression coverage for the `make changelog-check` pre-release handling: a +published pre-release tag (e.g. ``v0.44.0b1``) whose ``CHANGELOG`` entry lives +on an unmerged feature branch must NOT be reported as missing on ``main``. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +# ``scripts/`` is not an importable package; load the module by file path. +_SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "generate_changelog.py" +_spec = importlib.util.spec_from_file_location("generate_changelog", _SCRIPT_PATH) +assert _spec is not None and _spec.loader is not None +generate_changelog = importlib.util.module_from_spec(_spec) +sys.modules["generate_changelog"] = generate_changelog +_spec.loader.exec_module(generate_changelog) + +audit = generate_changelog.audit_changelog_coverage + + +class TestAuditChangelogCoverage: + def test_stable_release_with_entry_is_not_missing(self) -> None: + tags = [{"tagName": "v0.43.7", "isPrerelease": False}] + missing, checked, skipped = audit(tags, {"0.43.7": ["..."]}) + assert missing == [] + assert (checked, skipped) == (1, 0) + + def test_stable_release_without_entry_is_missing(self) -> None: + tags = [{"tagName": "v0.43.7", "isPrerelease": False}] + missing, checked, skipped = audit(tags, {}) + assert missing == ["0.43.7"] + assert (checked, skipped) == (1, 0) + + def test_prerelease_without_entry_is_skipped_not_missing(self) -> None: + # The v0.44.0b1 scenario: beta tag exists on GitHub, no entry on main. + tags = [{"tagName": "v0.44.0b1", "isPrerelease": True}] + missing, checked, skipped = audit(tags, {}) + assert missing == [] + assert (checked, skipped) == (0, 1) + + def test_mixed_stable_and_prerelease(self) -> None: + tags = [ + {"tagName": "v0.43.7", "isPrerelease": False}, + {"tagName": "v0.44.0b1", "isPrerelease": True}, + {"tagName": "v0.43.6", "isPrerelease": False}, + ] + missing, checked, skipped = audit(tags, {"0.43.7": ["x"]}) + assert missing == ["0.43.6"] # stable, no entry + assert (checked, skipped) == (2, 1) + + def test_v_prefix_is_stripped(self) -> None: + tags = [{"tagName": "v1.2.3", "isPrerelease": False}] + missing, _checked, _skipped = audit(tags, {"1.2.3": ["x"]}) + assert missing == [] + + def test_missing_isprerelease_key_treated_as_stable(self) -> None: + # Defensive: a tag dict without the flag is audited as a stable release. + tags = [{"tagName": "v0.9.9"}] + missing, checked, skipped = audit(tags, {}) + assert missing == ["0.9.9"] + assert (checked, skipped) == (1, 0)