Skip to content

Development

Guide for setting up a development environment and contributing to Tender.

Prerequisites

  • mise — manages Node, pnpm, and Python versions
  • Git
  • A Steam Deck or Linux PC running Steam, with CEF remote debugging enabled — that is what the backend loads the panel through, and it creates the marker itself when one is missing (How the panel gets into Steam). Decky Loader is no longer needed, and the panel coexists with one that is installed.

On Windows, develop inside WSL2. Tender targets Linux — some adapters import Unix-only modules (e.g. fcntl), a few dev dependencies have no Windows wheel, and CI runs on Linux. Native Windows is not supported for running the test suite; in a WSL2 Linux distro the same mise install / mise run setup / mise run test work unchanged.

Setup

git clone https://github.com/danielcopper/romm-tender.git
cd romm-tender
mise install          # installs Node LTS, pnpm, Python
mise run setup        # installs JS + Python dependencies

This creates a Python virtual environment (auto-activated by mise via _.python.venv in mise.toml) and installs all npm packages.

mise run setup also points core.hooksPath at .githooks/, so the repo's pre-commit hook formats staged files on every commit. Git allows only one hooks path, so that setting replaces a global one rather than adding to it: the hook therefore runs whatever global pre-commit you have installed first and aborts the commit if it refuses. The repo's commit-msg, pre-merge-commit and pre-push hooks likewise hand over to a global hook of the same name and adopt its verdict, so a guard you rely on in your other repos keeps working here too. If you have no global hook, nothing changes.

Python dependencies are installed from requirements-dev.lock — fully-pinned versions compiled from requirements-dev.txt by uv. After changing a source (requirements-dev.txt / docs/requirements.txt) or bumping a pin, run mise run lock-update to regenerate the locks.

MkDocs and its extensions are pinned in docs/requirements.lock (the lock .github/workflows/docs.yml installs), not in requirements-dev.lock, and mise run setup does not install it, so run uv pip install -r docs/requirements.lock before previewing the site with mise run docs.

Automated dependency updates

Update PRs are managed by Renovate (renovate.json) across pip, npm, and GitHub Actions, and in-range minor/patch updates auto-merge once CI is green. For the full picture — where every version lives, what's coupled, what auto-merges, and how to bump things by hand — see Dependency management.

The toolchain versions — node, pnpm, python, uv, deno — are excluded from Renovate: they are pinned and cross-file-coupled (each appears in mise.toml and in frontend/package.json's packageManager and/or the workflow setup-* version inputs, and all copies must match; Dependency management's table says why for each). Renovate is disabled for these by dependency name so a bot bump can't desync one copy; bump them by hand, together. The setup-* action SHAs themselves stay auto-updated.

Building

pnpm -C frontend build   # Rollup -> the repository's dist/, not the package's

Rollup produces three files there, plus @decky/ui's licence text beside them: dist/globals.js installs Steam's React, and dist/index.js and dist/index-coexistence.js are the same panel differing in whether @decky/ui is bundled into it or taken from Decky Loader's own copy. The backend serves dist/ and loads one of the two panels into Steam itself — How the panel gets into Steam. What each file is and why there are two panels: How the panel is built and loaded.

The build does not type-check. It turns each TypeScript file into JavaScript on its own, so a syntax error fails it and a type error builds cleanly. What fails on a type error is pnpm -C frontend typecheck, which the gate (mise run gate) and CI's build job both run.

Testing

mise run test                        # run the backend test suite, one worker per logical CPU
python -m pytest tests/ -q -n auto   # same thing without mise
python -m pytest tests/ -q           # the whole suite in one process, for debugging

mise run test, and with it mise run gate, runs the suite across every logical CPU with pytest-xdist, and so do CI's test, test-3.12 and test-3.13 jobs. -n auto is passed there rather than set in pytest.ini, so a run of one file or one test stays in a single process. Leave -n off when you debug a failure — pdb, -s and print output only behave in a single process — and when a failure shows up only under -n auto, suspect a test that shares state with another one running beside it, or one that depends on timing: every CPU is busy, so a thread or a callback can land later than it does in a single process.

To run with coverage:

python -m pytest tests/ -q -n auto --cov=backend --cov-report=term --cov-branch

pytest-cov combines the workers' data into one report, the same one a single-process run writes.

Tests mirror the source layout (tests/services/, tests/adapters/, tests/domain/, tests/models/, tests/lib/), with each test file mapping 1:1 to a source module. No backend top-level name may be a standard-library module, importable without backend/, or the name of anything the suite registers from tests/: a module or package directly under it, or the topmost package of an __init__.py chain below it. tests/test_backend_package_names.py fails on each and says why. Shared fixtures live in tests/conftest.py: among them the fresh HOME every test runs under, and the per-test emit and logger a service is built with. _isolated_environment's docstring there says what the isolation covers and what it does not, and states the named exception, a test class that reads your real RetroDECK install and only reads. .claude/rules/testing-backend.md has the rules for writing a test against them.

Frontend component tests run with mise run test:frontend (pnpm -C frontend test); see .claude/rules/testing-frontend.md for the backend-event harness, and for what that suite cannot see — api/host is stubbed suite-wide, so no socket is ever opened in it.

Property-based tests

The pure decision kernels carry an extra tier of Hypothesis property tests alongside the hand-enumerated cases. They state the safety invariants directly and exercise them across a generated input space. The in-tree kernels (domain/save_path.py, domain/iso_time.py) have theirs in tests/domain/test_*_property.py; the save-sync decision runs in the compiled gavel core, so its properties drive GavelNativeAdapter from tests/adapters/test_gavel_native_property.py. Run them like any other test:

python -m pytest tests/adapters/test_gavel_native_property.py tests/domain/test_save_path_property.py -q

Hypothesis is a dev-only dependency (pinned in requirements-dev.txt, compiled into requirements-dev.lock via mise run lock-update — it never ships in Tender). A CI-safe profile in tests/conftest.py sets deadline=None (no timing flakes on shared runners) and a fixed example count. The example database is written to .hypothesis/, which is gitignored. See .claude/rules/testing-backend.md for the convention on pinning a property that encodes an open bug.

Contract tests

tests/contract/ is a tier that crosses the frontend↔backend wire. Where the unit tests check each side against its own mocked idea of the other, the contract tier builds the real Endpoints through the real bootstrap() + wire_services() (real settings dict, real SQLite + migrations, real file-store adapters, all under tmp_path) and drives the actual main.py endpoints exactly as the frontend does — positional, JSON-shaped arguments with the arg types declared in frontend/src/api/backend.ts (literal None where the TS type says null). The assertions pin the response shape (canonical failure shape, discriminated-status unions, partial-success flags), not delegation. Only the outermost edges are faked: the RomM + SteamGridDB network transports, the Clock/UuidGen/Sleeper seams, emit, and the retry backoff. Run them like any other test:

python -m pytest tests/contract/ -q

See .claude/rules/testing-backend.md for the full contract-tier rules.

Gavel conformance vectors

The save-sync decisions are also published as a standalone client contract, romm-gavel — and since both of them run in gavel's compiled core (vendored as backend/native/libgavel-x86_64-linux.so), the two vector families are what keep the shipped binary and the published spec from silently drifting apart:

  • ladder (the 409 resolution ladder) — tests/adapters/test_gavel_native.py.
  • decision-table (the full per-(rom, filename, slot) decision) — tests/adapters/test_gavel_native_table_vectors.py.

Both families read the core through GavelNativeAdapter, the seam production decides on. The vectors are vendored verbatim under tests/adapters/gavel_vectors/, one subdirectory per family mirroring upstream vectors/ (ladder/ — a curated named-case set plus the exhaustive equivalence classes; decision-table/ — curated named cases) — there is no submodule and no network in CI, so every contract change lands as a reviewable diff. Run them like any other test:

python -m pytest tests/adapters/test_gavel_native_table_vectors.py tests/adapters/test_gavel_native.py -q

Updating the vectors means deliberately re-copying the JSON from the matching upstream vectors/<family>/ directory and bumping the release tag in tests/adapters/gavel_vectors/README.md — in lockstep with the .so, which is pinned to the same release; never edit a vector to match the core.

Every backend feature or endpoint where testing makes sense should have unit tests covering:

  • Happy path — normal successful operation
  • Bad path — invalid input, missing data, API errors, network failures
  • Edge cases — empty strings, None values, boundary conditions

Running it

mise run dev

Builds the panel, restarts the running Steam, and runs the backend in the foreground. The backend binds a loopback port, serves dist/ from it, and loads the panel into Steam's renderer over the CEF debugger — nothing is copied into a plugin directory and no plugin loader is restarted. Ctrl-C stops it and lets it unload.

The restart closes whatever is open in Steam, and the task does it rather than leaving it to you: there is no hot reload, so a rebuilt bundle reaches Steam only in a fresh JS context, and a new backend process strands the panel the old one loaded. The new backend has Steam reload that context itself, but not dependably: it waits while an app is running or while it cannot tell whether one is, it tries once per stranded panel — terminating the web helper as the second attempt when Steam refused the reload or the earlier panel was still there after it — and it takes Steam's interface down no more than twice in ten minutes on the machine (a panel an earlier backend left behind). Steam comes back into the window and display a dev:bpm* / dev:desktop* task last chose — the desktop client, placed nowhere, if none has. A Steam that is not running is simply started.

Steam's remote-debugging marker has to exist, and the backend creates it when it does not. Steam reads it at start-up, so the task's own restart is what picks up a marker that has just been created (the marker).

The whole loop, the Big Picture window, the desktop surface's own loop (dev build only), and how to judge layout at the Deck's real metrics are in Frontend dev loop; what the injector does and how it protects the Steam UI from itself is in How the panel gets into Steam.

Two switches exist, both read from the environment at start-up: TENDER_INJECT=off serves the panel and loads it nowhere, and TENDER_INJECT=force loads it even where the crash watchdog has stopped.

TENDER_FOCUS_WATCHER=off, read at start-up too, switches the focus watcher off: it then opens no X connection and sends Steam nothing. It sends through the attachment the panel is loaded over, so TENDER_INJECT=off switches it off as well.

Running an installed one

mise run dev is the development loop. What a user gets instead is a systemd user unit, ~/.config/systemd/user/romm-tender.service, written by install.sh:

systemctl --user status romm-tender     # what it is doing
systemctl --user restart romm-tender    # after replacing the code by hand
systemctl --user cat romm-tender        # the roots this install resolved
journalctl --user -u romm-tender        # what it wrote to stderr

Its own log is ~/.local/state/romm-tender/backend.log, and the journal carries the same lines plus the one start-up address with the token UNREDACTED. The log file has that line too, with the token replaced — the redaction is the file handler's own formatter, so the two differ deliberately rather than by accident.

Tender runs as a service, and the Quick Access entry is something it PUTS there. The entry is not a file Steam reads at start-up — it is code this backend loads into Steam's renderer — so a unit that is not running when Steam starts means a Steam with no Tender entry in it, and starting the unit puts one there without restarting Steam. A backend that stops after it has loaded the panel leaves the entry where it is: the code is already in Steam, and what it loses is the backend to talk to.

To install a build of your own rather than a release:

mise run package                              # builds the frontend, writes build/romm-tender-<V>.tar.gz
bash install.sh --from build/romm-tender-<V>.tar.gz

That is the same path a release takes, with the download skipped — install.sh --uninstall takes it back out, and leaves the database, the settings and the launcher where they are.

Literally the same path: the release workflow's second job runs those same two steps on the tagged tree and attaches what comes out, so there is one producer for a local build and a published one. What it attaches is held to scripts/check_release_tarball.py, which opens the archive and asserts what the installer relies on — one top-level romm-tender/ directory, the files an install starts from together with the version file, the installer an installed tree rolls back with and the licence texts a distributed copy carries, nothing the packager prunes, and a sidecar sha256sum -c accepts. That check is not first run at the tag: CI's build job packs a tarball from every pull request's own build and runs it there too, for the reason ADR-0039 gives. What no run of it can say is whether the code inside works — the script's own docstring states the blind spot.

Beside the tarball the same job attaches decky-ui-<version>.tgz, named for the @decky/ui version rather than Tender's: the @decky/ui npm package frontend/pnpm-lock.yaml resolves, which scripts/decky_ui_source.py fetches from the npm registry and writes only once its SHA-512 matches the lock's integrity hash. It is the source of the copy the bundles carry, offered from the release itself (THIRD-PARTY-NOTICES.md says where a reader finds it). CI's build job runs the same script on every pull request, without attaching anything, so a release is not its first run either.

An install over an existing one is an update, and an update whose new version does not answer is rolled back. It does not ask whether you are coming from the Decky plugin, which a first install does unless given --yes or run after the question's end date (TENDER_ACK_UNTIL, set in install.sh): a machine with a tree at the code root already is not coming from it. Whenever install.sh installs over a tree already at ~/.local/lib/romm-tender/, the run goes in this order:

  1. The tarball is unpacked beside the install and checked, before anything running is touched.
  2. The new version gets its pre-install check: the installer runs $PYTHON -B <staged tree>/backend/check.py under timeout, with a two-minute limit. The check imports main.py with everything it imports, loads the native library and builds the application — the database and settings migrations included — on copies of romm-tender.db (taken with SQLite's backup API, since the running version may be writing it) and of settings.json. It builds the application and never starts it, so it takes no lock, binds no port, writes no port note, logs to its own output rather than to backend.log, and reaches neither Steam nor the network. Its code root is the staged tree; the other five TENDER_* roots and XDG_RUNTIME_DIR point into the run's temporary directory, and the check refuses to build where one of them already holds something. It ends one of four ways:

    • The application was built, and the update goes on.
    • It could not be built (exit status 1), or it crashed — ended by SIGSEGV, SIGABRT, SIGBUS, SIGILL or SIGFPE: the run ends with the new version does not start and nothing was changed, the staged tree is removed, and update-failure.json is written with "kind": "check" (below). The check's reason and the last lines it printed come just above those two lines — for a crash, after a line naming its signal (the pre-install check crashed (SIGSEGV)).
    • It did not finish — stopped at the limit (the pre-install check was stopped after 120s), killed, by timeout or by anything else (the pre-install check was killed), or ended with any other status the installer does not know, such as a SIGTERM from outside or timeout unable to run the interpreter (the pre-install check ended with status <N>): the check did not finish and nothing was changed, and no record, since nothing is known about the version.
    • The live data could not be copied: could not try the new version: your data could not be copied and nothing was changed, and no record, for the same reason.

    A first install gets the same check before its tree is put in place — coming from 0.33, where the database is still romm_sync.db, it copies that file under its own name, and the build renames the copy as a start would — and a refusal there writes no record and no unit. A release from before the check has no backend/check.py: it is installed without one, and the run says this version has no pre-install check. 3. The unit is stopped. 4. romm-tender.db with its -wal and -shm files, settings.json and the unit file are copied to ~/.local/share/romm-tender/update-backup/ — exactly the ones that exist, replacing the previous backup — beside a backed-up-at file holding when, as one line of ISO-8601 UTC, and a data-of-version file holding the version of the tree installed at the time, as one line. Plain copies are whole only because the unit is stopped. The copy is staged beside the backup, the previous backup is renamed to update-backup.prev, the staged one takes its name, and only then is the previous one removed: a removal that fails leaves update-backup.prev behind rather than a half-removed backup under the real name, and the update goes on. The next backup removes a leftover update-backup.prev first, or puts it back where the name is empty, and refuses, changing nothing, where it cannot. 5. The new tree is renamed into place and the old one is kept as ~/.local/lib/romm-tender.old — one kept tree, so the one an earlier update kept goes now, whether or not this update then answers. The covers' move runs and the unit is written as on a first install. 6. The unit is started, and the installer waits up to 60 seconds for the backend to answer as the version in the new tree's version.txt. It asks without the token: it reads the port note, requests http://127.0.0.1:<port>/, and takes the version off the Server: romm-tender/<version> field the refusal carries. A missing or stale note and a port nobody answers on mean "not yet". The answer costs one refused GET /: no token WARNING in backend.log.

On a terminal the run is four rows redrawn in place. Where nothing may be redrawn — output piped into a file or the journal, or NO_COLOR set — each row is a line of its own when it starts and again each time what it is doing changes, marked [..], before the line it ends on; so a log holds the whole run, the wait above included — the Service row's waiting for 1.3.0 to answer — rather than only how each row ended.

When the answer does not come, the installer stops the unit, puts the kept tree back and deletes the failed one, and restores the backup — deleting any of those database and settings files the backup does not hold. It then writes ~/.local/state/romm-tender/update-failure.json, through a temporary file and renamed, and only after that starts the previous version, so the record is there when that version logs the rollback at its start. It waits for it the same way, says update to <new> failed; back on <previous> and exits non-zero, and never tries again on its own. A restore that fails ends the run before either: no record, and nothing started. A record that cannot be written — a full disk, a state directory it may not write to — does not stop the start: the run says could not record the rolled-back update in <path>; Tender will not show it, starts the previous version anyway and ends as any rollback does, and Tender shows no notice of that rollback. The record has three keys:

{ "attempted_version": "1.3.0", "restored_version": "1.2.3", "rolled_back_at": "2026-09-25T10:15:00Z" }

A version the pre-install check in step 2 refused — one it could not build, or one that crashed it — leaves the same record with a fourth key, "kind": "check", where restored_version is the version still installed — nothing was replaced — and rolled_back_at is when it was refused. The keys keep their names so that a backend from before the check still reads the record, as a rollback. A record that cannot be written is said the same way (could not record the refused update in <path>; Tender will not show it) and the run still ends with nothing changed.

rolled_back_at is ISO-8601 UTC. The next update whose new version answers removes the file. The backend reads it and never writes it (UpdateOutcomeService notes).

~/.local/lib/romm-tender/install.sh --rollback does the same restore by hand — the release tarball ships the installer, so every tree whose tarball ships install.sh carries one — and writes no record, because going back by choice is not an update that failed. It refuses, changing nothing, when there is no kept tree. Otherwise it first puts a leftover update-backup.prev back where the name is empty, as the next backup would, and refuses, changing nothing else, when that move fails, when there is no backup, and when the kept tree's version.txt does not name the version the backup's data-of-version does: an update that ended after replacing the backup and before swapping the tree leaves the older kept tree beside the data of the version still installed, and rolling back would run that older code over newer data. Before it restores anything it stops the unit and copies the database files and settings.json it is about to replace into ~/.local/share/romm-tender/rollback-backup/, put in place over the copy the previous rollback by hand made the same way as the update's backup (a leftover is rollback-backup.prev); a copy that cannot be made refuses the rollback, changes nothing and starts the unit again where it was running. No update touches that directory. The run names the date the data goes back to — the backup's backed-up-at — and where the copy is. The automatic rollback above makes no such copy: what it discards was written by a version that was never seen to answer.

Going back, by itself or by hand, puts the kept tree in place of the current one, so there is none left afterwards and --rollback refuses until the next update keeps one. --uninstall removes the kept tree and leaves both backups with the rest of the data root. TENDER_UPDATE_WAIT sets the wait; it exists for the tests.

Where Steam's debugger is answering, an update or a rollback that ends on a backend which replaces a panel an earlier one left (A panel an earlier backend left behind) closes by saying the panel comes back by itself once no game is running, and to restart Steam only if it has not after a few minutes; its Steam row is marked done, with the line under it saying the backend now running replaces the earlier panel. Going back to a release from before that replacement asks for the restart instead, and leaves that row a warning that the earlier panel is still loaded.

Every step that runs once per machine — the covers' move above, a backend backfill behind a kv_config marker, a rung of either version ladder — stays safe to run again and stays in every later release, because an update jumps from whatever release a machine is on straight to the newest; one leaves only deliberately, with that release's notes naming the oldest version it can be updated from directly. The rule and its reasons are in the invariant register.

The install refuses while a backend of your own is up. mise run dev and mise run dev:backend start one outside the unit, and it holds the same exclusive lock the unit's backend takes — backend.lock, beside the database. A second one gives up on it after five seconds, so the unit would never come up, systemd would start it again five seconds after each failure for as long as your process lives, and the run would still report a started service. What the installer asks is the lock, not a process name, and the one holder it lets through is the unit's own backend, which is an update. The refusal names the holder where it can find one — its pid, command line and the directory it was started in: stop it where you started it with Ctrl-C, or kill <pid>, then install again. --uninstall and --disable do not ask, because neither starts a backend.

Trying an update against a fake GitHub

mise run dev:fake-github serves the release tarballs in build/ — what mise run package writes — as a GitHub of its own on 127.0.0.1:8765, bound to that address only, and prints the two variables that point Tender at it:

TENDER_RELEASE_API=http://127.0.0.1:8765/api/releases/latest
TENDER_DOWNLOAD_BASE=http://127.0.0.1:8765/download

The first answers the way GitHub's latest-release route does, with the tarball's digest and the .sha256 file among its assets; under the second, every tarball in the directory is served under its own tag with its .sha256 beside it, so install.sh --version reaches an older one too. With more than one tarball in the directory, name the one to call latest: mise run dev:fake-github -- --latest 1.3.0. --dir serves another directory and --port listens on another port. Four flags make the latest release one an update has to refuse: --no-digest (the tarball's asset states no digest), --no-tarball, --no-checksum-file, and --corrupt-tarball (the bytes served do not match the digest; the update check still offers such a release, and only the download's check finds it). --corrupt-tarball does so for every tag it serves, not only the latest, so install.sh --version fails on an older one too. --help lists them.

The installed service reads TENDER_RELEASE_API from its unit. Add it in a drop-in rather than in the unit itself, which the installer writes again on every update:

mkdir -p ~/.config/systemd/user/romm-tender.service.d
printf '[Service]\nEnvironment=TENDER_RELEASE_API=http://127.0.0.1:8765/api/releases/latest\n' \
    > ~/.config/systemd/user/romm-tender.service.d/fake-github.conf
systemctl --user daemon-reload
systemctl --user restart romm-tender

The check asks at most once a day, so press Check now in Settings › Updates to ask at once. To point the service back at GitHub, remove fake-github.conf, then daemon-reload and restart again.

Install update in Settings › Updates downloads from wherever the latest-release answer's asset addresses point — the fake server here, over plain HTTP on loopback — and starts the installer as the unit romm-tender-update with the service's own TENDER_RELEASE_API, TENDER_DOWNLOAD_BASE and TENDER_UPDATE_WAIT handed on, so a drop-in that sets them reaches the installer too. Follow it with journalctl --user -u romm-tender-update; --corrupt-tarball is refused before the installer starts, by the backend's own digest check.

install.sh reads TENDER_RELEASE_API and TENDER_DOWNLOAD_BASE from its own environment:

TENDER_RELEASE_API=http://127.0.0.1:8765/api/releases/latest \
    TENDER_DOWNLOAD_BASE=http://127.0.0.1:8765/download bash install.sh

Linting

PYTHONPATH=backend lint-imports   # check service/adapter layer rules
mise run lint                     # same via mise

The .importlinter config holds the layer boundary contracts, one section per rule with the rule stated on its name line; Boundary Enforcement walks through them.

mise run lint also runs scripts/check_cosmic_call_bans.sh, which complements the import rules at the call site: services may not call datetime.now() / asyncio.sleep() / time.time() / time.monotonic() / uuid.uuid4() / random.* directly — they inject the Clock / Sleeper / UuidGen Protocol instead.

mise run lint (and CI) also runs scripts/check_service_independence_contract.py, which derives the expected service list from backend/services/ and fails if .importlinter's service-independence contract drifts — omitting a service or carrying a stale entry — keeping the hand-maintained modules list self-healing.

mise run lint (and CI) also runs scripts/check_failure_shape.py --check, which holds the failure shape to main.py; what it reads and what fails it is docs/architecture/backend-architecture.md, §4.

mise run lint (and CI) also runs scripts/check_endpoint_parity.py, which pins the frontend↔backend endpoint surface to one source of truth: it derives the frontend names + arities from every endpoint<[Args], Return>("name") in frontend/src/**/*.ts and the backend surface from the endpoints on the Endpoints class in main.py (the public methods whose first decorator is @route), then fails if they diverge: an endpoint declared on only one side (either direction) or a matching name whose arity (positional param count) differs. A @route below another decorator or on an underscored name fails on its own. Arg types stay out of scope (Python signatures carry no hints), so arity is the only mechanically checkable shape. The same checks are surfaced inside the pytest run by tests/contract/test_endpoint_parity.py.

mise run lint (and CI) also runs scripts/check_event_parity.py, which fails if a backend emit("name", ...) event has no matching frontend addEventListener("name", ...) (or vice versa). The event names are bare string literals, so the gate matches the two surfaces by literal event name — the backend side parsed via AST (emit / _emit calls), the frontend side via a text scan of bare addEventListener calls. Static sibling of the endpoint parity gate, for the event channel. The same parity assertion is surfaced inside the pytest run by tests/contract/test_event_parity.py.

mise run lint (and CI) also runs scripts/check_reason_parity.py, which fails if a reason an endpoint's failure answer can carry — a refusal's, a partial failure's or one classify_error returns — is missing from the panel's WireReason union in frontend/src/types/reasons.ts, or the union holds one none of them answers with; if the union's RommErrorReason part differs from classify_error's reasons; and on a backend site whose reason it cannot read. Its tests are tests/scripts/test_check_reason_parity.py; what it reads and what it cannot see is its docstring, and why it exists is the invariant's entry.

mise run lint (and CI) also runs scripts/check_settings_owner.py, which fails if the settings.json filename literal appears anywhere except its owning adapter (adapters/persistence.py); confining the literal to one module keeps all settings writes in the single crash-safe owner.

mise run lint (and CI) also runs scripts/check_module_size.py, the decomposition-threshold gate: no module in services/, bootstrap/, adapters/, domain/, host/, lib/ or models/ may cross the ~1000-LOC threshold. The modules that were already over it when the gate landed were pinned at their size in the script's ALLOWLIST; none is left, and nothing is ever added to it — a module that crosses the threshold is split, never listed. What the gate does not walk is listed at SCOPE_DIRS with the reason for each: main.py grows with the endpoint surface by design, _vendor/ holds checksum-pinned upstream copies, a large file under tests/ is the one-file-per-source-module rule working, scripts/ never ships, and frontend/src/ needs a per-scope glob before it can be added. There is deliberately no --update flag — re-baselining should be a reviewable diff, never a command someone runs to get back to green.

mise run lint (and CI) also runs scripts/check_invariant_register.py, which holds the invariant register to its size: the register section of CLAUDE.md at most 6,000 characters, an entry's bold headline at most 300, one area file under docs/architecture/invariants/ at most 40,000, and no headline standing in two places. What it reads is its docstring, and what it misses is its entry in the invariant register (Cross-cutting); its tests are tests/scripts/test_check_invariant_register.py.

mise run lint (and CI) also runs scripts/check_generated_installer_logo.py, which holds install.sh's greeter to the mark. The installer draws the logo before it does anything, and it cannot render an image, so the art is text embedded in the script between two markers — drawn and rasterised by scripts/logo/terminal.py (so it needs librsvg, which CI installs for the check). There are two drawings: a half-block icon, where each cell's two colours ARE the picture, for a terminal in a UTF-8 locale that takes colour, and a class-drawn ASCII one for a terminal that is not. A run that may write no colour gets no icon at all, because half-blocks in one tone are a slab rather than a mark. A drawing kept by hand is the one that drifts away from the mark it is a picture of, so the check regenerates both, and the wordmark beside them, and fails on any difference; re-run python3 scripts/logo/build.py --install --terminal and commit the result. What the two drawings are and why they differ is scripts/logo/README.md.

mise run lint (and CI) also runs scripts/check_shell_answer_functions.py, which holds the repository's shell to one rule: a function whose value is taken with $(...) never reaches exit. exit inside a command substitution ends that subshell and nothing else, so a function that answers with a value and aborts on a bad input does neither — it prints its message, and the caller carries on with an empty answer, complaining a second time about the emptiness or building a request out of it. Both end up non-zero, which is why the shape survives a test that only reads the status. The answer is that such a function RETURNS non-zero and its caller aborts.

Which helper ends the run is derived rather than listed: a function "reaches exit" if it runs exit itself or calls one of the same file's functions that does, so a second abort helper is covered the day it is written. What the gate scans is install.sh, scripts/package.sh, bin/tender-rom-launcher — named because it carries no extension the glob would find — and every *.sh under scripts/ and bin/, through a hand-written lexer that knows quotes, comments, heredocs and nesting — not a bash parser. Its blind spots are listed in the script's docstring, which is their one home; the two worth knowing at this distance are that a function reached through a variable is invisible to it — install.sh's own step is the live example — and that ( f ) and f | cmd swallow an exit the same way without being checked. Because the lexer is hand-written rather than bash, a construct it misreads drops real code in silence, so the shapes it gets right are pinned one by one in its test file.

The frontend has no size gate — deliberately, because a threshold only works when something else forbids the cheap way of getting under it, and frontend/src/ has no equivalent of service-independence. What it has instead is direction rules, in frontend/eslint.config.js via eslint-plugin-import-x: frontend/src/utils/ and frontend/src/api/ may not import either surface (frontend/src/bigpicture/ or frontend/src/desktop/) or frontend/src/shared/, the UI that belongs to both surfaces; frontend/src/shared/ may not import either surface; the two surfaces may not import each other; only frontend/src/index.tsx and its test may import frontend/src/qam/ or frontend/src/boot/, and neither of those two the other; frontend/src/qam/ may not import either surface; frontend/src/types/ may import nothing else from frontend/src/; and no module in frontend/src/ may take part in an import cycle. frontend/src/boot/ sits beside index.tsx and may import a surface, because its start-up check has to see every Steam lookup the panel relies on, a surface's included. The cycle rule is the one that matters most, because a cycle is the signature of a split whose two halves still call each other — the wrong seam, detectable without judgment. What none of them catch is a helper imported by exactly one parent that takes a dozen parameters and does nothing on its own: it is neither a cycle nor a direction violation. These rules make the worst seam fail; they do not certify that a seam is right.

By default the plugin reads the imports of JavaScript files only, so until the config names .ts/.tsx for it, no-cycle finds no cycle among the frontend's modules; the comment at import-x/extensions in that file says which settings do that. Because the failure mode is silence rather than noise, frontend/src/eslintBoundaries.test.ts lints known-bad fixtures through the real config and fails if any of these rules stops reporting. A green pnpm -C frontend lint on its own does not distinguish a working rule from an inert one.

See Backend Architecture for details.

Full CI gate

mise run gate         # run the PR checks from .github/workflows/ci.yml locally (all but the jobs and steps named below)

mise run gate is the single local battery that mirrors CI. It runs the backend tests (mise run test), the architecture/lint gates (mise run lint) and the rest of what CI enforces: ruff check + ruff format --check, basedpyright, the frontend eslint / prettier --check / build / third-party package budgets / tsc typecheck / bundle-size budget, the frontend tests (pnpm -C frontend test), and deno fmt --check for Markdown. These run side by side, except that the frontend tests start only once the backend tests are done ([tasks."gate:frontend-test"] in mise.toml says why). The first step to fail stops the others: its output ends in ERROR task failed under the task's name, and the gate exits non-zero. It is slow — the two test suites one after the other, with a production frontend build beside them — so it is a pre-push check, not something to run on every save. The CI jobs it does not run are test-3.12 and test-3.13, which run the backend suite under Python 3.12 and 3.13 — interpreters the toolchain does not install (why CI runs them: Dependency management) — and the two that feed Sonar: pr-metadata, which needs a pull request, and sonarcloud, which needs SONAR_TOKEN and the CI coverage artifacts. The build job's fetch and check of the @decky/ui source package (scripts/decky_ui_source.py) stays out of it too, because it needs the network, and so does its pnpm audit step (pnpm audit under Code Quality).

Outside ci.yml, a pull request runs more checks, and the gate runs none of them, because each reads the pull request itself on GitHub: decisions (.github/workflows/decisions.yml), which checks that the pull request links an issue whose decisions are settled — the rules, the exemptions and the opt-outs are in the shared workflow's README; Docs updated in same PR (docs-check.yml), which fails a pull request that changes source without touching docs/ unless it carries the no-docs-change label or a docs: N/A line; commitlint (pr-title.yml), which holds the pull request's title to Conventional Commits; and label (labeler.yml), which only sets labels. A pull request that touches docs/** or mkdocs.yml also builds the documentation site (docs.yml, mkdocs build --strict), which mise run docs:build runs locally.

steam-internals.yml is no pull-request check, and the gate does not run it either: once a day and by hand it looks for every listed Steam internal in the client Valve publishes, and puts a finding on the issue labelled steam-internals instead of failing the run (What the daily watch looks for).

Code Quality

  • SonarCloud — CI-based analysis on every PR to main and every push to main. Quality Gate enforces 80% coverage on new code, 0 bugs, 0 vulnerabilities. The scanner waits for the gate (sonar.qualitygate.wait), so a failed gate fails the job that ran the scan. Which job scans depends on where the PR comes from, and one check blocks either way:
    • A branch of this repository — own branches, Renovate, release-please and Dependabot's security updates alike, and every push to main — is scanned by the sonarcloud job in ci.yml. A Dependabot PR's run reads SONAR_TOKEN from the repository's Dependabot secrets rather than its Actions secrets, so the token is stored in both.
    • A fork gets no secrets in its CI run (only a read-only GITHUB_TOKEN), so sonarcloud skips it. Once that CI run has succeeded, .github/workflows/sonarcloud-fork-pr.yml scans the fork's head in this repository's context, following SonarSource's pattern for pull requests from forks: it takes the coverage reports CI uploaded and main's sonar-project.properties, and executes nothing from the fork. Both the coverage and the CI success that starts the scan come from the fork's own copy of ci.yml, so the coverage can be forged: on a fork's PR the Sonar verdict informs the review; it does not replace it.
    • Which check blocks — on every PR, a branch's or a fork's, the SonarCloud app posts its own check, SonarCloud Code Analysis, and main's ruleset requires it, so a failed quality gate blocks the merge. The app posts it only once an analysis has run: a scan that fails before analysing — a scanner download refused with a 403, say — leaves the check "Expected", which blocks as well. On a branch PR the sonarcloud job is red beside it; on a fork's PR nothing on the PR turns red, and the failure is in the run of the "SonarCloud fork PRs" workflow (sonarcloud-fork-pr.yml) in the Actions tab.
  • Ruff — Python linting in CI. The enabled rules are the select list under [tool.ruff.lint] in pyproject.toml.
  • basedpyright — Type checking in CI. Checks all source files including the test suite (tests/ is not excluded).
  • import-linter — Layer boundary enforcement in CI (see Linting section above).
  • pnpm audit — CI's build job runs pnpm -C frontend audit --prod --audit-level=high, which reports high and critical advisories in what the panel ships: the dependencies of frontend/package.json and what they pull in, not the dev tooling. It is informational — the step has continue-on-error: true, so an advisory shows in the job log and never turns the build red — and mise run gate does not run it.
  • pytest-cov — Branch coverage reported to SonarCloud.
  • pytest-xdist — Runs the backend suite across every logical CPU in mise run test, the gate and CI's test, test-3.12 and test-3.13 jobs; see Testing.
  • pytest-timeout — Bounds a single test at 120 s (timeout in pytest.ini), so a test that blocks fails by name instead of running the CI job out of its timeout-minutes: 15 with nothing to say which test it was; on the main thread the default signal method raises inside the test, so the rest of the session still runs. Reading such a failure: the traceback is only as sharp as the block is synchronous — a test stuck on an await points into the event loop's own select() rather than at the awaiting line, so the test name is what identifies it. A test that legitimately waits longer raises its own with @pytest.mark.timeout(<seconds>).
  • Un-awaited coroutines fail the suite — filterwarnings in pytest.ini makes RuntimeWarning and PytestUnraisableExceptionWarning errors, and a coroutine that is created and never awaited needs both to go red: the first turns the warning Python issues when it finalizes the coroutine into an exception, and pytest reports that exception as the second. Reading such a failure: it lands on whichever test is running when the coroutine is finalized — it can surface as an error in a later test's setup — so the test it names need not be the one that dropped it; and when no test runs after that point, pytest reports it at session end, where every test shows as passed, the run exits 1, and the traceback names no test.

Where the coding conventions live

Three places, split by how often they apply:

  • CLAUDE.md (repo root) — the traps, the invariant register's map from area to file with the few rules it keeps, and the workflow. Everything here applies no matter which file you touch, so it is read up front.
  • docs/architecture/invariants/ — the invariant register's entries, one file per area; CLAUDE.md's map names each area's file and when to read it.
  • .claude/rules/*.md — the per-area conventions (services, adapters/domain, Python naming and docstrings, endpoint shapes, bootstrap wiring, vendored assets, backend and frontend testing). Each file carries a paths: frontmatter glob and is loaded when a matching file is opened, which keeps the always-on set small.

Most of what lives in .claude/rules/ has no mechanical check — Protocol suffixes, constructor shape, docstring intent, verb-named mutations — so those rules hold only if they are carried while writing. CLAUDE.md indexes them with the failure mode each one prevents.

Note that .gitignore ignores .claude/* (worktrees, local agents, settings.local.json) and re-includes .claude/rules/ explicitly. Adding a rule file works; adding anything else under .claude/ will silently not be tracked.

Project Structure

backend/
  main.py                            # Process entry (run(), build_backend()) + Endpoints
  bootstrap/                         # Composition root — re-exported through __init__.py
    adapters.py                      # bootstrap() builds every adapter and the typed bundles
    services.py                      # wire_services() builds every service from those bundles
    application.py                   # build_application() composes both into the Application
    startup.py                       # The wrapper each start-up repair runs through
  services/                          # Orchestration / business logic (Protocol-typed deps via *ServiceConfig);
                                     #   every module is in Backend Architecture → Services
  adapters/                          # I/O boundaries — implement Protocols
    romm/{http,romm_api}.py          # RomM HTTP transport + REST adapter
    steam_config.py / steamgriddb.py / sgdb_artwork_cache.py / cover_art_file_store.py
    persistence.py                   # settings.json read/write
    repositories/                    # SqliteUnitOfWork (unit_of_work.py) + one repo per aggregate root, plus kv_config
    sqlite_migrations.py / machine_id.py  # schema migration runner (PRAGMA user_version) + machine-id reader
    download_file.py / firmware_file.py / migration_file.py / rom_files.py / save_file.py
    emulator_sources.py / retrodeck_folders.py / atlas_catalogue.py / atlas_firmware.py / atlas_platforms.py
    atlas_launch.py / atlas_saves.py / atlas_zstd.py
                                     # the adapters over the vendored emu-atlas resolver
    system_clock.py / system_uuid_gen.py / asyncio_sleeper.py / hostname.py / path_probe.py / debug_logger.py
  db/
    migrations/001_initial.sql       # SQLite schema DDL
  domain/                            # Pure compute — no I/O, imports no other internal layer
                                     #   (Backend Architecture → Domain; the aggregate roots: Database Design)
    _aggregate.py                    # the @cosmic_aggregate decorator
  models/                            # Data shapes (TypedDicts/dataclasses) — independent of other layers
  lib/                               # Cross-cutting utilities (errors, list_result, iso_time, path_safety, late_binding, ...)
  _vendor/                           # Vendored third-party deps — not our code, only imported by adapters;
                                     #   each package's provenance is in _vendor/README.md
frontend/src/                        # Frontend TypeScript
  index.tsx                          # The panel's entry module: event listeners, QAM router, the Quick Access entry's install
  qam/                               # Tender's own Quick Access entry: the patch, the tab glyph, the panel's boundary;
                                     #   imported only by index.tsx and its test, imports no surface
  boot/                              # Start-up: Steam's React globals, the check of what the panel takes from Steam, the failure page;
                                     #   imported only by index.tsx and its test, may import a surface
  bigpicture/                        # The gamepad surface: React components (QAM pages, game detail UI)
    layout/                          # Wide-page frame primitives: WidePage, ScrollRegion, Columns, ListDetail, pane
    library/ settings/ sync/         # Component groups for the Library, Settings and Sync pages
    saves/                           # Slot and save-file components, shared across the game-detail panel's tabs
    patches/                         # The game-detail route patch
  desktop/                           # The desktop-client surface — peer of bigpicture/, see its README
  shared/                            # UI that belongs to both surfaces — launch prompts, disc glyphs; imports neither surface
  api/backend.ts                     # endpoint() wrappers (typed)
  types/                             # TypeScript interfaces and the declarations of Steam internals — types only; imports nothing else in src/
  utils/                             # Shortcut CRUD, sync, downloads, collections, session manager, store patches
bin/tender-rom-launcher              # Pure exec wrapper — installed to <bin root> at every start, and run from there
tests/                               # Backend unit tests, mirroring backend/ layout

See Backend Architecture for the service/adapter design, dependency diagram, and layer enforcement rules.