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. The plugin 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.

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 does CI's test job. -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. 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 the plugin). 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 Plugin 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 callables 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

A backend.ts manifest gate (Phase 2) that pins the frontend and backend to one parsed artifact is a forthcoming separate change. 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 callable 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, 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.

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.

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 unit is stopped.
  3. romm_sync.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.
  4. 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.
  5. 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.

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 starts the previous version, waits for it the same way, says update to <new> failed; back on <previous> and exits non-zero. It never tries again on its own. It leaves ~/.local/state/romm-tender/update-failure.json, written through a temporary file and renamed, with three keys:

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

rolled_back_at is ISO-8601 UTC. The next update whose new version answers removes the file.

~/.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.

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 fails if any success: False return in services/ is missing the canonical reason + message keys or carries the forbidden error / error_code key — collapsing the failure-shape dialects onto one vocabulary (the two documented carve-outs are pattern-exempt). Run it without --check for a report-mode inventory.

mise run lint (and CI) also runs scripts/check_callable_manifest.py, which pins the frontend↔backend callable surface to one source of truth: it derives the frontend names + arities from every callable<[Args], Return>("name") in frontend/src/**/*.ts and the backend surface from the endpoints on the Plugin 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_callable_manifest.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 callable-manifest 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_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 ratchet: no module in services/, bootstrap/, adapters/, domain/, lib/ or models/ may cross the ~1000-LOC threshold, and the modules that were already over it when the gate landed are pinned at their exact size, so they cannot grow. A pin moves up in exactly one case: a change that adds no code — a rename, or a reformat whose only effect is that the formatter re-wraps lines that were already there — may raise it to the newly measured size, with the reason recorded at the ALLOWLIST entry and argued in the PR. Nothing else qualifies, and deleting lines elsewhere to pay for the ones you add least of all — that is the growth the gate exists to stop. A raise taken silently has retired the gate, which costs more than any module's size. The pin list lives in the script and entries only ever come out — a module that drops back under the threshold has to leave it, and the gate fails until it does — while a module that banks 50+ lines of slack gets a non-fatal note asking for its ceiling to be lowered. What the gate does not walk is listed at SCOPE_DIRS with the reason for each: main.py grows with the callable 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_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/), the two surfaces may not import each other, and no module in frontend/src/ may take part in an import cycle. 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 the seven 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 every PR check from .github/workflows/ci.yml, locally

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 / 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 only CI jobs it can't reproduce are the two that feed Sonar — pr-metadata, which needs a pull request, and sonarcloud, which needs SONAR_TOKEN and the CI coverage artifacts.

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 job; 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

Two files, split by how often they apply:

  • CLAUDE.md (repo root) — the traps, the cross-cutting invariant register, and the workflow. Everything here applies no matter which file you touch, so it is read up front.
  • .claude/rules/*.md — the per-area conventions (services, adapters/domain, Python naming and docstrings, callable 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                            # Plugin entry — lifecycle + 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
  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 + one-time legacy save_sync_state fold
    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
    retrodeck_paths.py / es_find_rules.py
    atlas_catalogue.py / atlas_firmware.py / atlas_saves.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                          # Plugin entry, 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
  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
  api/backend.ts                     # callable() wrappers (typed)
  types/                             # TypeScript interfaces and Steam API declarations
  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
defaults/config.json                 # platform_map: 153 platform slug -> RetroDECK system mappings
tests/                               # Backend unit tests, mirroring backend/ layout

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