#!/usr/bin/env python3
"""Classify a PR's changed files into CI work lanes.

Reads newline-separated changed paths on stdin and writes ``key=value``
booleans (one per lane) to ``$GITHUB_OUTPUT`` and stdout. The
``detect-changes`` composite action consumes them so steps gate on
``if: steps.changes.outputs.<lane> == 'true'``.

Lanes:

* ``python``      — pytest / ruff / ty / footguns.
* ``python_prod`` — Python changes OUTSIDE tests/. A tests-only PR keeps
  ``python`` (pytest must run) while this stays off.
* ``docker_meta`` — Dockerfiles etc.
* ``docker``      — the image build: docker meta, the dependency manifests, PM,
  and the image's own tests.
* ``nix``         — ``nix flake check``: the flake files and the dependency
  manifests.
* ``e2e``, ``e2e_upgrade``, ``e2e_desktop_core``, ``e2e_desktop_update`` —
  the end-to-end suites. The classifier still says which suites a diff
  touches (``_E2E_LANES``, the ``run-e2e`` label), but ci.yaml's ``detect``
  gate forces these lanes off on pull requests and pushes to main: the E2E
  suites run only on a release run or a manual dispatch.
* ``frontend``    — TS typecheck matrix + desktop build.
* ``site``        — Docusaurus + generated skill docs.
* ``scan``        — supply-chain scan (Python files, .pth, setup hooks).
* ``deps``        — pyproject.toml dependency bounds check.
* ``uv_lock``     — ``PM lock check``. Re-resolves the whole graph against
  PyPI, so a diff that touches neither ``pyproject.toml`` nor ``uv.lock``
  must not run it.
* ``npm_lock``    — semantic package-lock.json diff PR comment.
* ``bootstrap``   — the bootstrap installer lane: install.sh sandbox install,
  pin-fragment drift check, and shipped version-stamp verification.
* ``desktop_updater`` — the Windows desktop-update hand-off script and the
  tests that drive the REAL ``windows.ps1`` (``-SelfTestUi`` / pipe drain /
  retry policy). These are integration tests of a PowerShell process on a
  shared runner; running them on every Python PR made their timing noise
  everyone's problem. They still run on push (fail-open) and whenever the
  script, its siblings, or their tests change.
* ``rust``        — ``cargo test`` for the Tauri bootstrap installer. ``.rs``
  lives under ``apps/``, so without this lane a Rust change matched ``frontend``
  and only the TypeScript matrix ran.
* ``catalog``     — the plugin-catalog tests (catalog-tests.yml). A diff made
  only of catalog entry data (``plugin-catalog/*.yaml``, ``contributors/emails/``)
  runs this lane INSTEAD of the Python suite: those files are data that a known
  set of tests reads, so a one-entry PR need not run 23k tests on three OSes.
  ``plugin-catalog/README.md`` is not entry data (a docs page mirrors it), and
  any other file in the diff brings the full Python suite back.
* ``remote_backends`` — the SSH and Docker terminal backends against a real
  sshd container and the runner's Docker daemon (remote-backends.yml). Every
  other test of those backends is mocked, so a change to the backends, the
  terminal tool, or the real-backend tests runs it on the PR.

``docker``, ``nix`` and the E2E lanes take most of the larger-runner minutes.
An ordinary product change does not start them on a pull request. Every push
to main runs them all (a push has no diff, so every lane is on), so a
regression they catch shows up on main after the merge. A stable-release run
forces every lane on.

Contract — *fail open, never closed*. We may run a lane we didn't need, but
must never skip one a change could break:

* The slow lanes above are the one deliberate exception. On a pull request
  they skip changes that could still break them, and main is where those
  changes meet the suite. Their path lists decide which PRs run them early.
* An empty diff, or any ``.github/`` change, runs everything.
* ``python`` is a denylist: skipped only when *every* file is provably prose
  or a frontend-only package; an unrecognized path keeps it on.
* ``skills/`` (incl. ``SKILL.md``) is python-relevant — the skill-doc tests
  read that tree, so a doc-looking edit can still break Python.
* ``nix/``, ``flake.nix`` and ``flake.lock`` are the exception the other way:
  only the flake reads them, so they skip the Python lanes and run ``nix``
  alone. ``pyproject.toml`` and ``uv.lock`` are flake inputs too, but the
  packaging tests read them, so they keep every Python lane.
* ``website/static/oauth/`` is python-relevant too: it publishes the OAuth
  Client ID Metadata Document that ``tests/tools/test_mcp_cimd.py`` checks
  against the pinned callback ports in ``tools/mcp_oauth.py``.
* ``website/docs/`` and ``website/scripts/`` are python-relevant for the same
  reason: the docs tree generates ``llms.txt``, and
  ``tests/website/test_generate_llms_txt.py`` asserts every page reaches it.
* A cross-language fixture (``_SHARED_FIXTURE_CONSUMERS``) selects the test
  lanes of every consumer that reads it: the update-marker corpus runs pytest,
  cargo, vitest and the Windows hand-off tests, not only ``python``.
"""

from __future__ import annotations

import json
import os
import subprocess
import sys

_FRONTEND = ("ui-tui/", "web/", "apps/")  # TS typecheck-matrix packages
# Shipped page outside those packages, exercised by the desktop Electron suite.
_FRONTEND_FILES = {"scripts/desktop-update/ui.html"}
_ROOT_NPM = {"package.json", "package-lock.json"}  # shifts every package's tree
_DOCKER_META = ("docker/", ".hadolint.yaml", "Dockerfile", ".dockerignore") # docker setup
_NIX_PATHS = ("nix/",) # nix files
_NIX_FILES = {"flake.nix", "flake.lock"} # base nix files
_SITE = ("website/", "skills/", "optional-skills/")  # docs site + skill pages
# Prose/frontend trees that can't touch Python. skills/ is excluded on purpose.
_PY_SKIP = ("docs/", "website/") + _FRONTEND
# Published artifacts that live under website/ but that Python asserts about.
# The OAuth Client ID Metadata Document is cross-checked against the pinned
# callback ports in tools/mcp_oauth.py, so editing it alone must still run the
# Python lane — otherwise dropping a redirect URI goes green here and breaks
# every CIMD login on main.
# website/docs/ and website/scripts/ are asserted about the same way. The docs
# tree generates llms.txt — the index every LLM (Hermes included, via the
# hermes-agent skill) reads to learn what Hermes can do — and
# tests/website/test_generate_llms_txt.py holds every page to appearing in it.
# Skipping Python on a docs-only PR is how the index drifted to 53% coverage.
_PY_RELEVANT_SITE = (
    "website/static/oauth/",
    "website/docs/",
    "website/scripts/",
)
# Cross-language contract files: data committed under a frontend tree that a
# pytest pins against the Python side (emitter inventory, command registry).
# Editing only the JSON in an apps/-only PR would otherwise skip the one test
# that can catch the drift, so these force the Python lane too.
_PY_RELEVANT_CONTRACT_FILES = {
    # tests/tui_gateway/contracts/test_generated.py (rendered from tui_gateway/contracts)
    "apps/shared/src/gateway-contract.generated.ts",
    "apps/shared/src/gateway-contract.openrpc.json",
    # tests/hermes_cli/test_desktop_slash_registry.py
    "apps/desktop/src/lib/desktop-slash-registry.json",
    # tests/tui_gateway/test_show_reasoning_display_gate.py (card-tool names vs the gateway lifecycle set)
    "apps/desktop/src/lib/tool-render-class.ts",
    # tests/website/test_catalog_rules_mirror.py (docs page mirrors the canonical rules)
    "plugin-catalog/README.md",
}

# Catalog entry data: read by the catalog tests and the Plugin Catalog CI
# admission gate, executed by nothing. Same set that gate allows in an entry PR.
_CATALOG_DATA = ("plugin-catalog/", "contributors/emails/")

# Supply-chain scan: files that can execute code at install/import time.
_SCAN_EXTS = (".py", ".pth")
_SCAN_FILES = {"setup.cfg", "pyproject.toml"}

# Bootstrap installer: the POSIX shell installer, the dev-checkout wrapper
# that carries the same pin fragment, and the Tauri app's non-Rust sources
# (the .rs/Cargo files are the ``rust`` lane's job). Changes here get the
# bootstrap-installer.yml lane — a real sandboxed install + stamp check.
_BOOTSTRAP_PATHS = ("apps/bootstrap-installer/",)
_BOOTSTRAP_FILES = {"scripts/install.sh", "setup-hermes.sh"}
# Windows desktop-update hand-off (scripts/desktop-update/windows.ps1 + the
# Electron side that launches it) and the pytest files that spawn it.
# tests/_fixtures/ holds the conftest's platform gating, so it re-arms the lane too.
_DESKTOP_UPDATER_PATHS = ("scripts/desktop-update/", "tests/_fixtures/")
_DESKTOP_UPDATER_TEST_PREFIX = "tests/scripts/desktop_update/"
_DESKTOP_UPDATER_FILES = {
    "apps/desktop/electron/updater-process.ts",
    "apps/desktop/electron/managed-ssh-update.ts",
    # The other half of the marker / result contract the script implements.
    "apps/desktop/electron/update-marker.ts",
    "apps/desktop/electron/update-marker-gate.ts",  # the gate's live-marker probe
    "apps/desktop/electron/handoff-result.ts",
    # Stops a remote backend for the update the hand-off script then runs.
    "apps/desktop/electron/remote-lifecycle.ts",
    # The SSH remote's marker judge/gate programs: marker.sh/marker.ps1's contract, run remotely.
    "apps/desktop/electron/remote-update-marker-programs.ts",
    "apps/desktop/electron/remote-update-marker-programs.test.ts",
    # Python the script runs: the post-update verify and the staged app swap.
    "hermes_cli/desktop_update_verify.py",
    "hermes_cli/main_desktop.py",
    "tests/conftest.py",
    "pyproject.toml",
}

# Cross-language fixtures: one data file that tests in several languages read as
# their shared contract. Editing it is editing every consumer, so it selects each
# lane that runs one (a path-prefix rule would see only ``tests/`` -> python, and
# the cargo / vitest / PowerShell readers of the same cases would never run).
# tests/ci/test_update_ci_routing.py finds the consumers in the tree and fails
# until every one is listed here.
_SHARED_FIXTURE_CONSUMERS: dict[str, tuple[str, ...]] = {
    # A7 rule 7: the update-marker parse / judge / release corpus.
    "tests/fixtures/update_marker_corpus.json": (
        "tests/hermes_cli/test_update_marker_corpus.py",  # Python: update_lock
        "apps/bootstrap-installer/src-tauri/src/marker_tests.rs",  # Rust: cargo test
        "apps/desktop/electron/update-marker-corpus.test.ts",  # Electron: vitest
        "tests/scripts/desktop_update/test_desktop_update_posix_marker_corpus.py",  # marker.sh
        "tests/scripts/desktop_update/test_desktop_update_windows_marker_corpus.py",  # marker.ps1
        "apps/desktop/electron/remote-update-marker-programs.test.ts",  # SSH remote judge
        "apps/desktop/electron/remote-lifecycle-v2-marker.test.ts",  # SSH relaunch/spawn gate
    ),
}
# What a fixture inherits from its consumers: the lanes that run them as tests.
# Not the slow suites a consumer's path also matches (an Electron test file under
# electron/update-* starts the Desktop update E2E, which never reads the corpus).
_FIXTURE_CONSUMER_LANES = ("python", "rust", "frontend", "desktop_updater")

# Rust crates — currently just the Tauri bootstrap installer (Hermes-Setup).
# These live under ``apps/``, so before this lane existed a ``.rs`` edit matched
# ``frontend`` and nothing more: the TypeScript matrix built, cargo never ran,
# and the crate's unit tests had never executed in CI at all.
_RUST_PATHS = ("apps/bootstrap-installer/src-tauri/",)
_RUST_FILENAMES = {"Cargo.toml", "Cargo.lock"}

# The real SSH/Docker backend lane: the backends, the terminal tool that drives
# them, the tests it runs, the sshd it starts, and the runner's env allowlist
# that carries the sshd/image coordinates into each pytest process.
_REMOTE_BACKEND_PATHS = (
    "tools/environments/",
    "tools/terminal_tool",
    "tests/tools/test_ssh_environment.py",
    "tests/tools/test_docker_backend_real.py",
    "scripts/ci/start_test_sshd.sh",
    "scripts/ci/test_sshd_container_setup.sh",
    "scripts/run_tests.sh",
)

# The slow lanes and the paths that start each one on a pull request. A path
# belongs here when the lane is the only CI that exercises it for real: the
# suite itself, its harness, and the product code the suite exists to guard.
# Everything else reaches these lanes on the next push to main.
_DEP_MANIFESTS = ("pyproject.toml", "uv.lock", "setup.py")
_NPM_MANIFESTS = ("package.json", "package-lock.json")
# Shared pytest harness: a bad edit here can break every Python E2E suite.
_PY_TEST_HARNESS = (
    "tests/conftest.py",
    "tests/_fixtures/",
    "tests/fakes/",
    "tests/e2e/conftest.py",
    "tests/e2e/core/_",
    "scripts/run_tests.sh",
    "scripts/run_tests_parallel.py",
)
# Both Desktop suites package the app and share this harness (the update
# suite imports the core suite's harness and provider).
_DESKTOP_E2E_SHARED = (
    "apps/desktop/package.json",
    "apps/desktop/electron-builder.config.cjs",
    "apps/desktop/scripts/",
    "apps/desktop/e2e/core/harness",
    "apps/desktop/e2e/core/provider",
    "apps/desktop/e2e/electron-binary",
    "apps/desktop/e2e/fix-electron-tracing",
    "apps/desktop/e2e/run-tmp",
)
# The Tauri updater (update.rs, its marker claim marker.rs, the paths.rs both resolve) is
# compiled and tested by ``cargo test`` alone (the ``rust`` lane, via ``.rs``): no slow lane
# builds the bootstrap installer, so none of the three starts one.
# What `hermes update` runs outside the update_* module family: the steps of the
# pipeline (entry, lock, early recovery, completion tail, launchers, fleet
# restart/verify, Windows pause/resume) and the lock / marker / recovery state
# the update_* modules import. Editing any of these changes what a real update
# does, so the real-update suites (Linux e2e-upgrade and the Windows
# install + update journey, including its crash cells) must run on the PR.
_UPDATE_PIPELINE = (
    "hermes_cli/main.py",  # cmd_update: lock, pre-update backup, receipt boundary
    "hermes_cli/main_dashboard.py",  # hangup protection + update.log mirror
    # Prefix: main_desktop.py (staged Desktop swap / rebuild in the tail) and the
    # main_desktop_* siblings it imports (macOS signing identity).
    "hermes_cli/main_desktop",
    # Prefix: _early_recovery.py and its _early_recovery_* siblings (ZIP swap journal).
    "hermes_cli/_early_recovery",  # interrupted pull / shim restore at launch
    "hermes_cli/venv_sync.py",  # completion obligation + launch-time tail
    "hermes_cli/source_",  # source_completion/_build/_releases/_check/_stamp
    "hermes_cli/_launchers.py",
    "hermes_cli/release_channels.py",
    "hermes_cli/gitlock.py",  # git self-heal + partial-clone fetch
    "hermes_cli/process_identity.py",  # marker owner liveness
    "hermes_cli/runtime_state.py",
    "hermes_cli/relaunch.py",
    "hermes_cli/managed_uv.py",
    "hermes_cli/npm_engine.py",
    "hermes_cli/_scan_venv_blockers.py",
    "hermes_cli/dashboard_procs.py",
    "hermes_cli/desktop_update",  # desktop_update_verify
    "hermes_cli/gateway.py",  # fleet restart / verify
    "hermes_cli/gateway_windows",  # Windows pause / resume
    "hermes_cli/gateway_launchd.py",
    "hermes_cli/gateway_migrate",
    "hermes_cli/gateway_supervised_restart.py",
    "hermes_cli/git_pack_tidy.py",  # partial-clone pack tidy: every update's pre-fetch runs it
    "hermes_bootstrap.py",  # every launch's prepare_launch
    "hermes_constants.py",  # root home = update marker location
    "hermes_dir_acl.py",  # update restores the Windows user ACL on the home after elevation
    "gateway/status.py",  # code_sha stamp the fleet verify reads
    "gateway/status_inline_source.py",  # the Windows pause identifies inline-bootstrapped gateways
    "gateway/status_lock_dir.py",  # gateway/status.py imports it at module level
    "gateway/control_socket.py",  # pause-for-update verb
    "gateway/code_skew.py",
    "gateway/host_rendezvous.py",
    "gateway/shutdown_forensics.py",
    "gateway/restart.py",
    "scripts/desktop-update/",  # the hand-off scripts run `hermes update`
)
# Selectors are owned by the import graph, not remembered. The update
# transaction's own modules (what `hermes update` and the launch-time completion
# run between the lock and the receipt) are the entry points;
# tests/ci/test_update_ci_routing.py reads every repo module they import (AST,
# module level and lazy) and every build script they run, and fails until each
# one is routed to the suite below or is a _SHARED_HUBS entry.
_UPDATE_ENTRY_POINTS = (
    "hermes_cli/update_",
    "hermes_cli/_update_",
    "hermes_cli/source_",
    "hermes_cli/old_updater",
    "hermes_cli/_old_updater",
    "hermes_cli/post_update",
    "hermes_cli/venv_sync.py",
    "hermes_cli/_early_recovery",
    "hermes_cli/main_desktop.py",
    "hermes_cli/desktop_update_verify.py",
    "hermes_cli/desktop_build_lock.py",
    "hermes_cli/subcommands/update",
)
# The entry points that build or verify the Desktop app inside an update
# (`hermes desktop --build-only`, the source build/completion that feeds it).
_DESKTOP_BUILD_ENTRY_POINTS = (
    "hermes_cli/source_build.py",
    "hermes_cli/source_completion.py",
    "hermes_cli/main_desktop.py",
    "hermes_cli/desktop_update_verify.py",
    "hermes_cli/desktop_build_lock.py",
)
# What the entry points import, outside the update_* family and the pipeline above.
_UPDATE_DEPENDENCIES = (
    "hermes_cli/_subprocess_compat.py",  # update git env, process-tree kill, PM git exposure
    "hermes_cli/local_runtime/processes.py",  # bounded probes' spawn_server/job custody
    "agent/deadline.py",  # bounded probes' process-tree timeout cleanup
    # migrate_all_homes' second-hop provider/profile decisions, run on every update. Its
    # plugin-install branch (plugins_cmd, plugins_cmd_install) runs only for a home whose
    # configured memory provider left core; no update journey's home has one, so those
    # modules stay with the unit lane (tests/ci/test_update_transitive_routing.py).
    "agent/memory_provider.py",
    "pm/plugins_state.py",
    "pm/install.py",  # also sealed()/lazy_installs_allowed(): every update's default-tool install
    "hermes_cli/desktop_build_lock.py",
    "hermes_cli/memory_provider_migration.py",
    "hermes_cli/left_core_migration.py",  # source_build migrates plugins that left core
    "hermes_cli/web_build_limits.py",  # source_build caps the dashboard Node build
    "hermes_cli/desktop_console.py",
    "hermes_cli/bundled_app.py",
    "hermes_cli/gui_uninstall.py",
    "hermes_cli/linux_desktop_entry.py",
    "hermes_cli/github_api.py",  # source_check's release lookup
    "hermes_cli/build_info.py",
    "hermes_cli/image_provenance.py",
    "hermes_cli/backup.py",  # pre-update backup
    "hermes_cli/backup_cron_prompts.py",  # restore_cron_prompt_fields_if_degraded (update_cmd_config)
    "hermes_cli/backup_restore.py",
    "hermes_cli/relay_plugin_migrate.py",
    "hermes_cli/macos_tcc_anchor.py",
    "hermes_cli/model_catalog.py",
    "hermes_cli/sqlite_runtime.py",
    "hermes_cli/sqlite_safe_read.py",
    "hermes_cli/sizefmt.py",
    "hermes_cli/tools_config_cua.py",
    "hermes_cli/_startup_fast.py",
    "hermes_cli/_parser.py",
    "hermes_cli/gateway_multiplex_mode.py",
    "hermes_cli/plugin_catalog.py",
    "hermes_cli/steward.py",
    "hermes_cli/observability/shared_metrics_update.py",
    "hermes_cli/main_install_repair.py",
    "hermes_logging.py",
    "hermes_platform/host/__init__.py",
    "hermes_platform/host/facts.py",
    "hermes_platform/resolver/__init__.py",  # update_cmd_commit's interpreter lookup
    "hermes_platform/resolver/base.py",
    "hermes_platform/resolver/core.py",
    "agent/curator.py",
    "plugins/memory/__init__.py",
    "tools/checkpoint_maintenance.py",
    "tools/skills_sync.py",
    "tools/environments/local_env_policy.py",
    "pm/progress.py",
    # The compilers source_build / the Desktop build run (freshness, node-deps,
    # tui, web, desktop and their shared frontend-common).
    "scripts/build/",
)
# General-purpose modules an entry point imports but that half the product
# imports too (>= HUB_MIN_IMPORTERS product modules, checked by the test). The
# unit lanes cover them on every PR and the update suites on every push to main;
# routing each config.py / utils.py edit through the update suites would make
# them run on most PRs. Never an update-specific module: own those above.
HUB_MIN_IMPORTERS = 25
_SHARED_HUBS = frozenset({
    "hermes_cli/__init__.py",
    "hermes_cli/config.py",
    "hermes_cli/profiles.py",
    "hermes_cli/version_info.py",
    "utils.py",
    "hermes_state.py",
    "agent/__init__.py",
    "cron/jobs.py",
    "tools/environments/local.py",
    # Desktop lane only (the upgrade lane owns these outright):
    "hermes_constants.py",
    "gateway/status.py",
    "pm/__init__.py",
    "pm/paths.py",
    "pm/environments.py",
})
_E2E_LANES: dict[str, tuple[str, ...]] = {
    "e2e": (
        *_PY_TEST_HARNESS,
        "tests/e2e/",
        # The state.db torture chamber and the compaction/exactly-once
        # suites are the only tests that run real concurrent writers.
        "hermes_state",
        # Messaging-gated gateway cases: only the e2e job installs the real
        # Discord/Telegram SDKs (tests.yml "Run messaging-gated gateway cases").
        "tests/gateway/test_native_role_reauthorization.py",
        "tests/gateway/test_native_telegram_startup_recovery.py",
        "tests/gateway/test_native_pause_notice.py",
        "tests/gateway/test_native_reply_crash_recovery.py",
    ),
    "e2e_upgrade": (
        *_PY_TEST_HARNESS,
        *_DEP_MANIFESTS,
        "tests/e2e/core/upgrade/",
        "tests/e2e/core/windows_update/",
        "tests/compat/",
        "pm/",
        "scripts/install.",
        "setup-hermes.sh",
        "hermes_cli/update_",
        "hermes_cli/_update_",
        "hermes_cli/old_updater",
        # The downgrade refusal (hermes_cli/update_downgrade_guard.py) reads state.db through it.
        "hermes_state_holders.py",
        # The search-index notice (hermes_cli/update_cmd_maint.py) reads the stored FTS layout through it.
        "hermes_state_fts.py",
        "hermes_cli/_old_updater",
        "hermes_cli/post_update",
        "hermes_cli/config_migrations",
        "hermes_cli/subcommands/update",
        "hermes_cli/install_",
        "hermes_cli/_install_",
        "hermes_cli/main_install",
        *_UPDATE_PIPELINE,
        *_UPDATE_ENTRY_POINTS,
        *_UPDATE_DEPENDENCIES,
    ),
    "e2e_desktop_core": (
        *_DESKTOP_E2E_SHARED,
        "apps/desktop/e2e/",
        "apps/desktop/electron/backend-",
        "apps/shared/src/",
        "apps/desktop/src/store/session",
        "apps/desktop/src/store/transcript",
        # Every core spec drives open/resume/switch; #132017 changed resume
        # without running them and main sat red on remote-secondary.
        "apps/desktop/src/app/session/",
        "apps/desktop/src/app/open-session",
        # fleet-condensed-default.spec.ts: the profile rail's doors per gateway.
        "apps/desktop/src/app/chat/sidebar/profile-switcher",
        "apps/desktop/src/app/chat/sidebar/fleet-",
        # reconnect-turn-state.spec.ts: the lost-turn card and Discard live in the queue panel.
        "apps/desktop/src/app/chat/composer/queue-",
    ),
    "e2e_desktop_update": (
        *_DESKTOP_E2E_SHARED,
        "apps/desktop/e2e/update/",
        "apps/desktop/electron/updater",
        "apps/desktop/electron/update-",
        "apps/desktop/electron/app-updater",
        "apps/desktop/electron/gateway-stop-before-update",
        "apps/desktop/electron/pre-update-",
        "apps/desktop/electron/install-stamp",
        # The rest of the Electron update path (codemap desktop-update §1):
        # main.ts owns the gate / backend stop / hand-off launch hunks (the
        # classifier sees files, not hunks), the result reader, the install
        # kind, the attach-time version check and the in-place app swap.
        "apps/desktop/electron/main.ts",
        "apps/desktop/electron/handoff-result",
        "apps/desktop/electron/desktop-installation",
        "apps/desktop/electron/backend-discovery",
        "apps/desktop/electron/local-gateway",  # gateway ensure: code_sha vs checkout, restart on skew
        "apps/desktop/electron/bundle-swap",
        "apps/desktop/electron/app-installer-file",
        "scripts/desktop-update/",
        "scripts/install.sh",
        "hermes_cli/update_",
        "hermes_cli/main_desktop",  # the entry point and the main_desktop_* siblings it imports
        *_DESKTOP_BUILD_ENTRY_POINTS,
        *_UPDATE_DEPENDENCIES,
        # Pipeline modules the Desktop build entry points import directly.
        "hermes_cli/main.py",  # `hermes desktop --build-only`
        "hermes_cli/venv_sync.py",
        "hermes_cli/source_stamp.py",
        # Imported by the Desktop build's scripts (scripts/build/desktop.mjs closure).
        "apps/desktop/product-identity.cjs",
        "scripts/msix-shared.mjs",
        # The launchers the Desktop relaunches through reach the launch-time repair first.
        "hermes_cli/_launchers.py",
        # The backend's /api/health `commit`, which the Desktop compares to the checkout
        # before attaching to a running backend after an update.
        "hermes_cli/web_routers/status.py",
    ),
}


def _with_package_inits(paths: tuple[str, ...]) -> tuple[str, ...]:
    """Importing a/b/c.py runs a/__init__.py and a/b/__init__.py first, so a routed module
    routes the package inits above it. A shared hub's inits run whenever the hub is imported, so
    they are routed too; only an init that is itself a hub stays out."""
    root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    inits = [f"{'/'.join(parts[:i])}/__init__.py"
             for p in (*paths, *sorted(_SHARED_HUBS)) if p.endswith(".py")
             for parts in [p.split("/")[:-1]] for i in range(1, len(parts) + 1)]
    return tuple(dict.fromkeys([*paths, *(i for i in inits if i not in _SHARED_HUBS
                                          and os.path.isfile(os.path.join(root, i)))]))


for _lane in ("e2e_upgrade", "e2e_desktop_update"):
    _E2E_LANES[_lane] = _with_package_inits(_E2E_LANES[_lane])
# The upgrade journeys are their own lane; editing one does not start ``e2e``.
# The update suite shares apps/desktop/e2e/ with the core suite but not its specs.
_E2E_LANE_EXCLUDES = {
    "e2e": ("tests/e2e/core/upgrade/", "tests/e2e/core/windows_update/"),
    "e2e_desktop_core": ("apps/desktop/e2e/update/",),
}
_DOCKER_PATHS = (*_DOCKER_META, *_DEP_MANIFESTS, *_NPM_MANIFESTS, "pm/", "tests/docker/")
_NIX_LANE_PATHS = (*_NIX_PATHS, *_NIX_FILES, *_DEP_MANIFESTS, *_NPM_MANIFESTS)

# A pull request with this label runs every slow lane, whatever it touches.
RUN_E2E_LABEL = "run-e2e"

def _is_docs(p: str) -> bool:
    if p.startswith(("skills/", "optional-skills/")):
        return False
    return p.endswith((".md", ".mdx")) or p.startswith("docs/") or p.startswith("LICENSE")


def _is_nix(p: str) -> bool:
    return p.startswith(_NIX_PATHS) or p in _NIX_FILES


def _py_irrelevant(p: str) -> bool:
    if p.startswith(_PY_RELEVANT_SITE) or p in _PY_RELEVANT_CONTRACT_FILES:
        return False
    return (
        _is_docs(p)
        or p in _ROOT_NPM
        or p.startswith(_PY_SKIP)
        or p.startswith(_DOCKER_META)
        or _is_nix(p)
    )


def _is_catalog_data(p: str) -> bool:
    return p.startswith(_CATALOG_DATA) and p not in _PY_RELEVANT_CONTRACT_FILES


def _py_test_only(p: str) -> bool:
    """Is ``p`` inside the test suite (never shipped / imported by the product)?

    Product jobs (Desktop E2E's ``hermes serve`` backend, the Docker image)
    run installed code — nothing under ``tests/`` is packaged or importable
    there. scripts/run_tests.sh and scripts/run_tests_parallel.py are deliberately
    NOT test-only: they are runner infrastructure, and a bad edit there can
    mask real failures, so they stay conservative (python_prod=true).
    """
    return p.startswith("tests/")


def _is_scan(p: str) -> bool:
    return p.endswith(_SCAN_EXTS) or p in _SCAN_FILES


def _is_desktop_updater(p: str) -> bool:
    return (
        p.startswith(_DESKTOP_UPDATER_PATHS)
        or p.startswith(_DESKTOP_UPDATER_TEST_PREFIX)
        or p in _DESKTOP_UPDATER_FILES
    )


def _is_rust(p: str) -> bool:
    return (
        p.endswith(".rs")
        or p.startswith(_RUST_PATHS)
        or os.path.basename(p) in _RUST_FILENAMES
    )


def _slow_lanes(files: list[str]) -> dict[str, bool]:
    """The slow lanes a pull request with this diff runs; see ``_E2E_LANES``."""
    lanes = {
        lane: any(
            f.startswith(paths) and not f.startswith(_E2E_LANE_EXCLUDES.get(lane, ()))
            for f in files
        )
        for lane, paths in _E2E_LANES.items()
    }
    lanes["docker"] = any(f.startswith(_DOCKER_PATHS) for f in files)
    lanes["nix"] = any(f.startswith(_NIX_LANE_PATHS) for f in files)
    return lanes


def classify(files: list[str], run_e2e: bool = False) -> dict[str, bool]:
    """Map changed paths to ``{lane: should_run}``.

    ``run_e2e`` is the pull request's ``run-e2e`` label: it turns every slow
    lane on.
    """
    files = [f.strip() for f in files if f.strip()]
    catalog = any(_is_catalog_data(f) for f in files)
    code = [f for f in files if not _is_catalog_data(f)]
    python = any(not _py_irrelevant(f) for f in code)
    python_prod = any(not _py_irrelevant(f) and not _py_test_only(f) for f in code)
    frontend = any(
        f.startswith(_FRONTEND) or f in _ROOT_NPM or f in _FRONTEND_FILES
        or f.startswith("tests-js/")
        or (f.startswith("scripts/build/") and f.endswith((".mjs", ".js", ".ts")))
        for f in files
    )
    deps = any(f == "pyproject.toml" for f in files)
    npm_lock = any(f.split("/")[-1] == "package-lock.json" for f in files)
    docker_meta = any(f.startswith(_DOCKER_META) for f in files)
    
    ret = {
        "python": python,
        "python_prod": python_prod,
        "docker_meta": docker_meta,
        "frontend": frontend,
        "site": any(f.startswith(_SITE) for f in files),
        "scan": any(_is_scan(f) for f in files),
        "deps": deps,
        "uv_lock": any(f in ("pyproject.toml", "uv.lock") for f in files),
        "npm_lock": npm_lock,
        "bootstrap": any(
            f.startswith(_BOOTSTRAP_PATHS) or f in _BOOTSTRAP_FILES for f in files
        ),
        "desktop_updater": any(_is_desktop_updater(f) for f in files),
        "rust": any(_is_rust(f) for f in files),
        "remote_backends": any(f.startswith(_REMOTE_BACKEND_PATHS) for f in files),
        "catalog": catalog,
        **{lane: run_e2e or on for lane, on in _slow_lanes(files).items()},
    }
    consumers = [c for f in files for c in _SHARED_FIXTURE_CONSUMERS.get(f, ())]
    if consumers:
        inherited = classify(consumers)
        for lane in _FIXTURE_CONSUMER_LANES:
            ret[lane] = ret[lane] or inherited[lane]
    if not files or any(f.startswith(".github/") for f in files):
        ret["python"] = True
        ret["python_prod"] = True
        ret["docker_meta"] = True
        ret["frontend"] = True
        ret["site"] = True
        ret["scan"] = True
        ret["deps"] = True
        ret["uv_lock"] = True
        ret["npm_lock"] = True
        ret["bootstrap"] = True
        ret["desktop_updater"] = True
        ret["rust"] = True
        ret["remote_backends"] = True
        ret["catalog"] = True
        ret.update(dict.fromkeys(_slow_lanes([]), True))
    return ret


def _event_payload() -> dict:
    """The Actions event payload, or ``{}`` when it is absent or unreadable."""
    event_path = os.environ.get("GITHUB_EVENT_PATH")
    if not event_path:
        return {}
    try:
        with open(event_path, encoding="utf-8-sig") as fh:
            payload = json.load(fh)
    except (OSError, json.JSONDecodeError):
        return {}
    return payload if isinstance(payload, dict) else {}


def _pull_request_number() -> str | None:
    """Read the PR number from the Actions event payload, if present."""
    number = (_event_payload().get("pull_request") or {}).get("number")
    return str(number) if number else None


def _gh_lines(*args: str) -> list[str] | None:
    """Run ``gh`` and return its stdout lines, or ``None`` when it fails."""
    try:
        completed = subprocess.run(
            ["gh", *args],
            check=False,
            capture_output=True,
            text=True,
            timeout=30,
        )
    except (OSError, subprocess.TimeoutExpired):
        return None
    if completed.returncode != 0:
        return None
    return [line.strip() for line in completed.stdout.splitlines() if line.strip()]


def pull_request_changed_files() -> list[str]:
    """Recover the PR file list when the compare API returned nothing.

    ``detect-changes`` calls ``repos/.../compare/base...head`` with raw SHAs.
    A fork force-push can 404 for ~30s until GitHub attaches the new head SHA
    to the base repo, so the action fails open with an empty file list and
    runs every lane even when the change is narrow.

    The pull-request files endpoint already knows the PR's files (it is how
    this action used to classify), so use it as a fallback on pull_request
    events only. Push/dispatch keep the empty-diff fail-open.
    """
    if os.environ.get("EVENT_NAME") != "pull_request":
        return []
    repo = os.environ.get("REPO") or os.environ.get("GITHUB_REPOSITORY") or ""
    pr = _pull_request_number()
    if not repo or not pr:
        return []
    return _gh_lines("api", "--paginate", f"repos/{repo}/pulls/{pr}/files", "--jq", ".[].filename") or []


def pull_request_labels() -> list[str]:
    """The pull request's labels as they are now, not as the event saw them.

    A re-run replays the original event payload, so the payload alone would
    miss a ``run-e2e`` label added after the push. The API answer wins; the
    payload is the fallback when the API call fails.
    """
    if os.environ.get("EVENT_NAME") != "pull_request":
        return []
    repo = os.environ.get("REPO") or os.environ.get("GITHUB_REPOSITORY") or ""
    pr = _pull_request_number()
    if repo and pr:
        live = _gh_lines("api", f"repos/{repo}/pulls/{pr}", "--jq", ".labels[].name")
        if live is not None:
            return live
    labels = (_event_payload().get("pull_request") or {}).get("labels") or []
    return [label["name"] for label in labels if isinstance(label, dict) and label.get("name")]


def main() -> int:
    files = sys.stdin.read().splitlines()
    if not any(f.strip() for f in files):
        recovered = pull_request_changed_files()
        if recovered:
            print(
                f"compare API returned no files; recovered {len(recovered)} "
                "path(s) from the pull request files endpoint",
                file=sys.stderr,
            )
            files = recovered
    lanes = classify(files, run_e2e=RUN_E2E_LABEL in pull_request_labels())
    out = "\n".join(f"{key}={str(value).lower()}" for key, value in lanes.items())
    if dest := os.environ.get("GITHUB_OUTPUT"):
        with open(dest, "a", encoding="utf-8") as fh:
            fh.write(out + "\n")
    print(out)  # echo for local runs + CI step logs
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
