Files
siftlode/docs/gates.md
T
peter 531eb2eef1 refactor(plex): playlists, playback, Discover and admin through the unit; the plex package is fenced
The last Plex HTTP files do no database work of their own, so ALLOWED is empty: no route builds
SQL, touches the session or a unit's table. Discover's rails move whole into plex/discover.py; a
user's playlists, watch-state writes (the race-safe finish, an early checkpoint, a status, a
season or show at once) and the watch-sync link go through plex/queries.py and plex/service.py.

The plex package joins FENCED: it reads no other unit's tables, says whose a row is through
userscope (the rollup and the watch sync's 8 inline filters moved to owned_by), and commits only in
its named jobs — the library sync, TMDB enrichment, the watch sync and its background pushes.
A playlist's has-item probe keeps the plain key lookup, so a NUL in it is still the 422 a test pins.

Pins 178 -> 0; basedpyright baseline 118 -> 106.
2026-10-19 22:42:46 +02:00

128 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Gate register
Every place a gate deliberately checks **less** than it could: a rule switched off or narrowed for a
scope, a flag left off, a lane that does not run everywhere, an accepted finding. One row each, with
the reason and the number measured when the row was written.
**Why this file exists (E32).** The worst defect E32 found was not in any code a reviewer read: a lint
rule had been switched off with 64 real hits counted in its own comment, and the "false positives"
beside them were the React Compiler skipping 95 functions without a word. The rule since: **a gate
with any real hit is never switched off or narrowed** — fix the sites, or suppress each one inline and
name its invariant. Whatever is left narrowed is listed here, and
[`frontend/src/lib/gates.contract.test.ts`](../frontend/src/lib/gates.contract.test.ts) reads the
**effective** configuration and fails when a narrowing is not on this page. A row here is a decision,
not an oversight; a narrowing missing from here is a test failure.
**The snapshot.** ESLint's effective config for every scope `eslint .` reaches, and the lines that
run the lanes (`scripts/check.sh`, `backend/docker-compose.test.yml`,
`backend/Dockerfile.test`, the npm scripts, `vitest.config.ts`, `.prettierignore`), are committed in
[`frontend/src/lib/gates.snapshot.json`](../frontend/src/lib/gates.snapshot.json). The test fails on
ANY difference — a rule, an option, a new ignore, a gate flag. A scope is named by path (`scopeOf` in
the test: `src`, `src tests`, `ui`, …) and every file in it must get the same config, so a glob that
moves one file into another config fails too, while a new file in a known place changes nothing
(until E35 the snapshot listed every file, and 31% of commits regenerated it). So a change to a gate is always a diff of that file in review, never a shape a detector failed
to model (E32 review: three rounds of a cleverer detector each found a bypass of the last one's
fixes). Regenerate it with `GATE_SNAPSHOT=update npx vitest run src/lib/gates.contract.test.ts`
(from `frontend/`), and read its diff as a gate change. A new kind of file (a new config, a new place)
needs a line in `scopeOf`.
**How the register is matched:** each narrowing must appear in a table row below as its
`code-quoted` id, in a row whose **Scope** cell names the scope exactly as the test reports it (a cell
may list several, separated by ` · `). For ESLint the scopes are the snapshot's config names, and a
narrowing is read off each config against `src`: a rule at off or warn, or one src applies that a
scope does not (one id per plugin family), a `no-restricted-*` list that leaves out an entry src's
declares, an option that lets something through (`allow…`, `ignore…`, `except…`), an unused-disable
report that is not an error, and a file no config lints. Ruff's TOML is parsed and held to a
whitelist of key paths, pytest.ini to a whitelist of whole lines.
## ESLint (frontend)
`npm run lint` is `eslint . --max-warnings 0`, so a rule at **warn** fails the lane exactly like an
error (the test holds that exact script). The warn rows are listed so that claim stays checked.
A core rule switched off because its typescript-eslint twin replaces it (`no-unused-vars` →
`@typescript-eslint/no-unused-vars`) is not listed separately; neither are the core rules
typescript-eslint's compatibility layer switches off for TS files because the type checker reports
them itself (`no-undef`, `no-redeclare`, `no-dupe-keys`, …), nor the formatting rules
`eslint-config-prettier` switches off (Prettier owns formatting and has its own lane).
| Scope | Narrowed | Why | Measured |
| --- | --- | --- | --- |
| src | `@typescript-eslint/no-unused-vars` — off | tsc owns it (`noUnusedLocals` + `noUnusedParameters` in every project), so a finding is reported once, by one tool. | — |
| src | `react-hooks/exhaustive-deps` — warn | Preset level; `--max-warnings 0` makes it fail. Every suppression names its invariant (`eslint-comments/require-description`). | 0 suppressions (E32 S4: a suppression of this rule drops the whole function from the React Compiler, so the two that were left went) |
| src | `react-hooks/incompatible-library` — warn | Preset level; fails under `--max-warnings 0`. VirtualGrid (TanStack Virtual) is the one site, suppressed inline. | 1 |
| src | `react-hooks/unsupported-syntax` — warn | Preset level; fails under `--max-warnings 0`. | 0 |
| src | `react-refresh/only-export-components` — warn | Fails under `--max-warnings 0`; `allowConstantExport` lets a module export constants beside its component. | 0 |
| src | `@typescript-eslint/no-unused-expressions` — narrowed | `allowShortCircuit` + `allowTernary`: `cond && f()` / `a ? f() : g()` side-effect statements are an idiom here. | — |
| src | `@typescript-eslint/no-floating-promises` — narrowed | `allowForKnownSafeCalls`: React Query's `invalidateQueries`, `refetchQueries`, `cancelQueries`, `resetQueries`, `prefetchQuery`, `prefetchInfiniteQuery` never reject unless asked to (`throwOnError`), so a bare call is safe by name. | 6 names |
| e2e | `@typescript-eslint/no-floating-promises` — narrowed | The same `allowForKnownSafeCalls` list (the typed rules are one object for both scopes). | 6 names |
| lib/http.ts | `no-restricted-globals` — narrowed | A network owner (`NETWORK_OWNERS` in eslint.config.js): the one module that may call `fetch` (`send`, which `req` and the public pages go through). Every other network global stays restricted there. | 1 global |
| lib/api/core.ts | `no-restricted-globals` — narrowed | A network owner: `uploadReq` is the one `XMLHttpRequest` (upload progress). | 1 global |
| src tests · ui tests · lib tests | `no-restricted-globals`, `no-restricted-properties` — off | A test stands a double in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly. | — |
| src tests · ui tests · lib tests | `local/literal-a11y-text`, `local/input-primitive`, `local/surface-role`, `local/glyph-link` — off | A test renders throwaway markup (a raw input as a focus target, an untranslated label) that is not product UI. `local/button-primitive` stays on: in a test file (and in a control primitive) it only refuses a raw `<button disabled>`, which drops focus there as anywhere. | — |
| design-lab | `local/literal-a11y-text` — off | The dev-only design lab (never in the bundle — devOnlyGuard fails the build if it is) holds the owner's working copy, not product copy. | — |
| e2e | `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax` — not applied | Playwright specs drive a browser from Node: no React, no product UI, and they print to the console on purpose. They get the base + typed rules + the playwright rules. | — |
| ts configs | `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax` — not applied | The build configs (vite, vitest, playwright, reactPlugin, devOnlyGuard) run in Node at build time. | — |
| js configs | `@typescript-eslint/*`, `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax`, `no-var`, `prefer-const`, `prefer-rest-params`, `prefer-spread`, `no-array-constructor`, `no-unused-expressions` — not applied | `eslint.config.js` and `postcss.config.js`: plain-JS build config, linted with `js.configs.recommended` only. | — |
| repo scripts | `scripts/*.mjs` — not in the eslint or prettier lane; `js.configs.recommended` + Prettier via `src/lib/repoScripts.contract.test.ts` | They sit at the repo root, and an ESLint config cannot reach past its own directory. The contract test lints and format-checks them from the repo root, to the same bar as the `js configs` row; no typed rules, as they are in no tsc project. | 0 lint hits; 2 files reformatted (E32 review) |
| everywhere | ignored paths: `dist`, `node_modules`, `playwright-report`, `test-results`, `e2e/.auth` | Build output, installed packages, test artefacts, a live Playwright session. Generated, not written. | — |
**Not enabled** (decided, not forgotten): `strictTypeChecked` — owner decision E32 D3, ~2000 hits with
none real in the sample; the curated typed pair `no-floating-promises` + `no-deprecated` runs instead.
## TypeScript
| Scope | Narrowed | Why | Measured |
| --- | --- | --- | --- |
| config files | `eslint.config.js — not type-checked` | Linted (the `js configs` row) and executed by every lint run, but in no tsc project. Under the node project's `strict` + `checkJs` it reports 43 errors: 42 implicit-`any` parameters in the local rules' visitors (JSX nodes, which ESLint's own `Rule` typings do not model) and one inferred `string` severity. None is a defect; typing them means moving the local rules onto typescript-eslint's `TSESTree` types — a refactor of its own, not done in E32. | 43 (E32 review) |
| all three projects | `skipLibCheck` | The `.d.ts` of installed packages are not type-checked (ours are). Standard for an app: a package's own types are its maintainers' gate, and checking them costs minutes and reports errors we cannot fix. | — |
Every project has `strict`, `noUncheckedIndexedAccess`, `noUnusedLocals`, `noUnusedParameters` and
`noFallthroughCasesInSwitch` on; the test fails if one goes off, if a `strict` sub-flag
(`strictNullChecks`, `noImplicitAny`, …) is set false beside it, if a project gains an `extends`,
`exclude` or `files`, or if its `include` changes — and a config file at the frontend root that no
project checks needs a row.
## Ruff (backend + scripts)
| Scope | Narrowed | Why | Measured |
| --- | --- | --- | --- |
| backend + scripts | `B008` — narrowed: `fastapi.Depends`, `fastapi.Query` declared immutable | FastAPI's dependency injection IS a call in a default argument, on every route. Every other call in a default still fails. | 474 sites, all of them FastAPI |
| backend/app | `C901` — not in ruff; reported by `scripts/audit_metrics.py` | Ruff has no per-function baseline, so turning it on would need a `noqa` per offender, and a `noqa` lets a function grow without limit. Complexity is a number an epic reads before and after (E35), not a gate: the ratchet that held it caught no defect in E32–E34, went red 8 times on its own ceilings and drew 5 review findings about itself. | 32 functions over 10 (E34) |
## basedpyright (backend)
| Scope | Narrowed | Why | Measured |
| --- | --- | --- | --- |
| backend/app + alembic | `typeCheckingMode` — `standard` | Not `strict`/`all`: those report on every untyped third-party boundary (SQLAlchemy rows, yt-dlp dicts) — thousands of findings, near-zero real ones. `standard` was measured at 7 findings, all fixed (E32 S1). | — |
| backend/app + alembic | `baseline.json` — pre-existing errors absorbed | The lane fails on NEW errors only; the baseline only shrinks (the lane fails when a run tightens it and it is not committed). Matching is per (file, rule, column span) + count. No burn-down mandate (E35): the entries go when their code is touched, not as work of their own. None of them was ever triaged one by one (the 7 findings E34 read were the `standard` ones above, fixed and not in the baseline), so an entry is not known to be harmless — the `reportOptional*` ones (24 in E35) are the likeliest real bugs; read one before re-baselining code around it. | 195 entries (E32 S1); 176 (E35) |
| tests/ | not type-checked | A test's job is to fail loudly when wrong — the opposite of the silent runtime failure this lane catches — and annotating fixtures for a checker is churn. | — |
## pytest (backend)
| Scope | Narrowed | Why | Measured |
| --- | --- | --- | --- |
| a hand run | `SIFTLODE_TEST_REQUIRE_DB=0` — the DB-backed and ffmpeg tests skip | The explicit "pure-logic only" run for a machine without Postgres. Off by default since E32: without it a missing DB is a hard error; the gate never sets it. | — |
## Lanes and pipeline
| Scope | Narrowed | Why | Measured |
| --- | --- | --- | --- |
| check | e2e (Playwright) not in `check` | It needs the :8080 stack built from the tree (minutes). It runs in `check-full`, and `release` rebuilds the stack and requires it (E32 D2) — nothing ships without it. | 5 specs × 3 engines |
| pushes | no server-side gate (no CI) | Owner decision 2026-10-04. The gate (`scripts/check.sh`) runs on the dev machine before work is handed over, and `release` runs it, then e2e, before anything is merged and Trivy before the image is pushed — the CI only repeated that, after the merge. Its runner shared the git host's container, and one run there starved Gitea of memory until it stopped answering. What it added — a fresh `npm ci` on Linux — the image build does at publish. | — |
| coverage | not in the gate; no threshold | Owner decision E32 D4: never enforced; "every regression test seen red" is the bar, and a percentage invites tests written to move it. Out of the lanes since E35: read once, by hand, it found 5 bugs; as a lane it was a number nobody acted on, and the slowest vitest run (the one that flaked). On demand: docs/development.md. | frontend 80% / backend 78% of lines (E32 S1) |
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
| npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 |
| knip | `entry` — one file added | Not a narrowing: knip's Vite plugin reads entries from index.html only, so the shared link's page (`watch.html` → `src/watchMain.tsx`) is named here, or the lane reports that whole entry unused. It adds files to the lane, never takes any out. | 1 entry |
| unit fence | the database work the HTTP layer still does, pinned | `backend/tests/test_unit_fences.py`: a unit's route file may not build SQL, call the session's data methods, set a model's attributes or import a unit's tables; a core route may not import a unit's tables. Switched on unit by unit: `ALLOWED` pins each file's count exactly, so a rise fails and a fall fails until the pin is lowered — the list may only shrink. End state: empty, and the pin mechanism goes. A unit in `FENCED` is held below its HTTP layer too: its code reads another unit's tables only along `READS`, writes them never, filters owners through `userscope`, and commits only where `UNIT_COMMITS` names a job's own session. | 826 in 23 files (E42 S0) → 667 in 19 (S1: Capture 0, fenced) → 525 in 14 (S2: Reader 0, fenced) → 354 in 9 (S3: Catalog 0, fenced; core routes 0) → 178 in 4 (S4a: Plex browse, detail, people, collections 0) → 0 (S4b: Plex 0, fenced — every unit fenced) |
| React Compiler | the functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) |
## Accepted findings
Entries in `.trivyignore.yaml` (CVE gate) and `--ignore-vuln` flags on the gate's `pip-audit` lane (`scripts/check.sh`). Each
must have a row here (Scope `accepted`) with its reason and its expiry; the test fails on an entry
without one, and on a `.trivyignore.yaml` entry with no `statement` or no `expired_at`.
| Scope | Finding | Why | Expires |
| --- | --- | --- | --- |
| — | none | — | — |