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 samemise install/mise run setup/mise run testwork 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¶
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:
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:
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¶
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:
- The tarball is unpacked beside the install and checked, before anything running is touched.
- The unit is stopped.
romm_sync.dbwith its-waland-shmfiles,settings.jsonand the unit file are copied to~/.local/share/romm-tender/update-backup/— exactly the ones that exist, replacing the previous backup — beside abacked-up-atfile holding when, as one line of ISO-8601 UTC, and adata-of-versionfile 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 toupdate-backup.prev, the staged one takes its name, and only then is the previous one removed: a removal that fails leavesupdate-backup.prevbehind rather than a half-removed backup under the real name, and the update goes on. The next backup removes a leftoverupdate-backup.prevfirst, or puts it back where the name is empty, and refuses, changing nothing, where it cannot.- 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. - 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, requestshttp://127.0.0.1:<port>/, and takes the version off theServer: romm-tender/<version>field the refusal carries. A missing or stale note and a port nobody answers on mean "not yet". The answer costs onerefused GET /: no tokenWARNING inbackend.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¶
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 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
mainand every push tomain. 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 thesonarcloudjob inci.yml. A Dependabot PR's run readsSONAR_TOKENfrom 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), sosonarcloudskips it. Once that CI run has succeeded,.github/workflows/sonarcloud-fork-pr.ymlscans 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 andmain'ssonar-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 ofci.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, andmain'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 thesonarcloudjob 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.
- A branch of this repository — own branches, Renovate, release-please and Dependabot's security updates alike,
and every push to
- Ruff — Python linting in CI. The enabled rules are the
selectlist under[tool.ruff.lint]inpyproject.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
buildjob runspnpm -C frontend audit --prod --audit-level=high, which reports high and critical advisories in what the panel ships: thedependenciesoffrontend/package.jsonand what they pull in, not the dev tooling. It is informational — the step hascontinue-on-error: true, so an advisory shows in the job log and never turns the build red — andmise run gatedoes 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'stestjob; see Testing. - pytest-timeout — Bounds a single test at 120 s (
timeoutinpytest.ini), so a test that blocks fails by name instead of running the CI job out of itstimeout-minutes: 15with nothing to say which test it was; on the main thread the defaultsignalmethod 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 anawaitpoints into the event loop's ownselect()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 —
filterwarningsinpytest.inimakesRuntimeWarningandPytestUnraisableExceptionWarningerrors, 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 apaths: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.