docs: the gate register, held to the effective config by a test

docs/gates.md lists every place a gate deliberately checks less than it
could, each with its reason and the count measured when the row was
written:
- an ESLint rule off, at warn, or not applied, by scope;
- a weak compiler flag, a ruff ignore, the basedpyright mode and baseline;
- a knip exclusion, a lane that does not run everywhere;
- an accepted CVE.
gates.contract.test.ts reads the EFFECTIVE configuration and fails when a
narrowing has no row:
- ESLint's computed config per scope;
- the three tsconfigs, ruff.toml, pyrightconfig, pytest.ini and knip.jsonc;
- .trivyignore.yaml, where an entry also needs a statement and an expiry;
- CI's pip-audit ignores.
It also pins `--max-warnings 0`, which is what makes a warn-level rule fail.
Seen red on a missing row, a new `off` rule and an allowlist entry without
an expiry.

CLAUDE.md gains the rule E32 exists for: a gate with any real hit is never
switched off or narrowed, and an "enforced by" comment is checked by running
the check. The gate descriptions there, in docs/development.md and in
backend/app/README.md name the lanes as they now are, and the "directly"
recipe points at the full list.
This commit is contained in:
2026-10-03 17:26:42 +02:00
parent 751e1ae5ed
commit 783f6408f9
5 changed files with 370 additions and 19 deletions
+24 -11
View File
@@ -39,36 +39,49 @@ decisions and the chosen patterns, rendered with the app's real CSS. It never re
## The gate
A change is done when all of this is green. CI runs the same checks server-side
(`.gitea/workflows/check.yml`), so "main is always green".
(`.gitea/workflows/check.yml`), so "main is always green". What each gate deliberately does NOT
check — and why — is the [gate register](gates.md); a test fails when a narrowing is missing from it.
**Backend**
```bash
# lint + format check
# lint + format check (ruff is pinned in backend/requirements-dev.txt)
python -m ruff check --config backend/ruff.toml backend scripts
python -m ruff format --check --config backend/ruff.toml backend scripts
# tests — pure-logic + the DB-backed integration lane, against an ephemeral Postgres.
# The backend tree is bind-mounted, so it always tests your working files.
# type check — in the test image (requirements.lock does not install on Windows); new errors only,
# the pre-existing ones are in backend/.basedpyright/baseline.json
docker compose -f backend/docker-compose.test.yml run --rm --no-deps --build test \
python -m basedpyright --project pyrightconfig.json
# tests — pure-logic + the DB-backed integration lane, against an ephemeral Postgres, ending with the
# coverage total. The backend tree is bind-mounted, so it always tests your working files.
docker compose -f backend/docker-compose.test.yml up --build --abort-on-container-exit --exit-code-from test
docker compose -f backend/docker-compose.test.yml down -v
```
The test compose sets `SIFTLODE_TEST_REQUIRE_DB=1`, which turns a would-be silent skip (no DB, or no
ffmpeg) into a hard failure — so the gate can't print green while the DB-backed tests were quietly not
run. Tests that need a database take the `db` fixture (`backend/tests/conftest.py`); the rest are
pure-logic and run without one.
A missing database or ffmpeg is a hard error, not a skip — so the gate can't print green while the
DB-backed tests were quietly not run (`SIFTLODE_TEST_REQUIRE_DB=0` is the explicit "pure-logic only"
run for a machine without Postgres). Tests that need a database take the `db` fixture
(`backend/tests/conftest.py`); the rest are pure-logic and run without one.
**Frontend** (from `frontend/`)
```bash
node ../scripts/check-node-modules.mjs # node_modules == package-lock.json (else: npm ci)
npm run typecheck # tsc x3 (app / node / e2e)
npm run lint # eslint
npm run lint # eslint (typed rules included; warnings fail)
npm run format:check # prettier
npm run knip # unused exports/deps
npm run test # vitest
npm run knip # unused files/exports/deps — and unused by what SHIPS (--production)
npm run test # vitest (incl. the compiler, size/complexity and gate-register contracts)
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code)
```
Coverage is reported, never enforced: `npm run test -- --coverage`, and the pytest lane's last line.
The Playwright suite (`npm run e2e`) runs against the `:8080` stack built from your tree; the release
requires it. Before an image is pushed, it is scanned with Trivy (HIGH/CRITICAL with a fix fails;
exceptions in `.trivyignore.yaml`, each with a reason and an expiry).
## Branching
Trunk-based: branch off `main`, keep the branch gate-green, and land the whole change back to `main`
+110
View File
@@ -0,0 +1,110 @@
# 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.
How the test matches: 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.
## ESLint (frontend)
`npm run lint` is `eslint . --max-warnings 0`, so a rule at **warn** fails the lane exactly like an
error (the test checks the flag is still there). 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 | `react-hooks/refs` — off | The one rule off while it has real hits. Its analysis IS the React Compiler's: every hit is a component the compiler skips (the popover engine's `getReferenceProps()` / `refs.setReference` passed during render, render-phase ref writes). Too many to fix in one sprint; until they are, `components/compilerCoverage.contract.test.ts` pins every skipped function (none may be added) and E32 S3+ burns them down to turn this on. | 245 hits; 93 skipped functions (E32 S1) |
| 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`). | 2 suppressions (both drop their whole function from the compiler — E32 S3) |
| 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 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 | `local/literal-a11y-text`, `local/button-primitive`, `local/input-primitive`, `local/surface-role`, `local/glyph-link` — off | A test renders throwaway markup (a raw button as a focus target, an untranslated label) that is not product UI. | — |
| FocusSafeButton.tsx | `local/focus-safe-disabled` — off | It IS the focus-safe primitive the rule points everyone else to. | — |
| AnchoredPopover.tsx | `local/focus-safe-disabled` — off | The popover's menu row: its native `disabled` is what floating-ui's roving focus reads to skip it. | — |
| 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. | — |
| 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 |
| --- | --- | --- | --- |
| 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.
## Ruff (backend + scripts)
| Scope | Narrowed | Why | Measured |
| --- | --- | --- | --- |
| backend + scripts | `E402` — ignored | A few modules (`main.py`, `scripts/e2e_seed.py`) run setup — logging, `sys.path` — before their imports on purpose. | — |
| 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 + scripts | `C901` — not in ruff; measured by `tests/test_source_size.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. The test holds each offender at its current value. | 32 functions over 10 |
## 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, so burn the hot files first. | 195 entries (E32 S1) |
| 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 and CI never set it. | — |
## knip (frontend)
| Scope | Narrowed | Why | Measured |
| --- | --- | --- | --- |
| --production | `!**/*.testkit.ts!`, `!e2e/**!` | Test kits and the e2e suite are test code: the production pass asks what the SHIPPED code uses. | — |
| --production | `!reactPlugin.ts!`, `!devOnlyGuard.ts!` | Build tooling the vite config imports: it shapes the bundle but is not in it. | — |
| --production | `@internal` exports | An export only its test imports says so with a JSDoc `@internal` tag naming the test; knip skips those in production mode. | 35 (E32 S1) |
## 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 |
| CI | no e2e, no Trivy | The lab runner has no browser stack, and Trivy's DB download is not reachable from it. Both run in `release` / `publish` on the dev machine, before anything is pushed. | — |
| coverage | no threshold | Owner decision E32 D4: reported on both sides, never enforced. "Every regression test seen red" is the bar; a percentage invites tests written to move it. | 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 (CI) | `--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 |
| React Compiler | 93 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) |
| size / complexity | today's offenders as ceilings | `src/lib/sourceSize.contract.test.ts`, `backend/tests/test_source_size.py`: listed files and functions may not grow, nothing new may cross the limit, an entry back under it must be deleted. | files >800 lines: 13 FE / 11 BE; functions over the limit: 64 FE / 32 BE |
## Accepted findings
Entries in `.trivyignore.yaml` (CVE gate) and `--ignore-vuln` flags on CI's `pip-audit` step. 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 | — | — |