diff --git a/.dockerignore b/.dockerignore index f9bc435f..5ae987fb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,7 @@ *.env.local **/__pycache__/ **/*.pyc +**/.coverage .venv/ venv/ # Docker patterns are anchored at the context root with NO implicit `**/` (unlike .gitignore, where diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml index 017d6393..7bd1487a 100644 --- a/.gitea/workflows/check.yml +++ b/.gitea/workflows/check.yml @@ -1,7 +1,9 @@ # Server-side mirror of the local `siftlode check` gate (E1 S3 / DX-1), so `main` is always green # independent of any one machine. Two jobs match the two local lanes: -# frontend — tsc (3 projects) + eslint + prettier --check + knip + vitest -# backend — ruff + the full pytest suite (pure-logic + the DB-backed lane) against a real Postgres +# frontend — tsc (3 projects) + eslint + prettier --check + knip (both modes) + vitest + vite build +# + npm audit of the production dependencies +# backend — ruff + basedpyright + the full pytest suite (pure-logic + the DB-backed lane) against +# a real Postgres + pip-audit of the lock # # They are declared side by side but they DO NOT run in parallel: the lab has a single act_runner # with one slot, so they queue. Measured across runs #86-#91 — the backend job starts at the exact @@ -56,6 +58,12 @@ jobs: - run: npm run format:check - run: npm run knip - run: npm run test + # The production bundle (E32): before this, `vite build` — and devOnlyGuard, which fails it if + # dev-only code reaches the bundle — first ran inside the image build, after main was pushed. + - run: npx vite build + # What ships to the browser: a known CVE at high or above in a production dependency fails. + # (The dev tooling's own advisories are burned down by hand; see docs/gates.md.) + - run: npm audit --omit=dev --audit-level=high backend: runs-on: ubuntu-latest @@ -112,8 +120,9 @@ jobs: - name: pytest run: python -m pytest working-directory: backend - # Dependency CVE audit over the pinned lock. Non-blocking for now (informational) — a real CVE - # shows up in the run without failing the gate; tighten to a hard fail once the noise is known. - - name: pip-audit (advisory) + # Dependency CVE audit over the pinned lock. BLOCKING since E32: it ran advisory-only, and when + # it was finally read it had three urllib3 advisories with a fix available (bumped in the same + # change). An advisory without a fix is answered with `--ignore-vuln ` here, a reason and a + # date in the comment, and a line in docs/gates.md. + - name: pip-audit run: pip install --no-cache-dir pip-audit && pip-audit -r backend/requirements.lock - continue-on-error: true diff --git a/.gitignore b/.gitignore index d5349a1d..d034f201 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ venv/ .mypy_cache/ .pytest_cache/ +.coverage .ruff_cache/ # Node / frontend diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 00000000..79dbc1c5 --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,15 @@ +# The CVE gate's allowlist (E32). The publish gate scans the built image and the committed tree with +# Trivy and fails on any HIGH or CRITICAL finding that has a fix available. A finding that cannot be +# fixed yet is let through ONLY by an entry here, and every entry carries both: +# statement — why it is acceptable for now (what is exposed, what is not) +# expired_at — the date it stops being accepted; after it, the gate fails again on its own +# A contract test (frontend/src/lib/gates.contract.test.ts) refuses an entry without either, and +# docs/gates.md lists them. +# +# Example: +# vulnerabilities: +# - id: CVE-2026-00000 +# statement: in libfoo's XML path, which nothing in the image calls; no fixed Debian build yet +# expired_at: 2026-12-31 +vulnerabilities: [] +secrets: [] diff --git a/CHANGELOG.md b/CHANGELOG.md index 216079a2..e1772831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ edit the release notes and re-run the generator. Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). +## 0.99.0 — 2026-10-04 + +### New + +- Nearly every screen is now compiled by the React Compiler, so lists and menus re-render less. +- The channel list's row menu opens and closes without redrawing the table. + +### Fixed + +- A failed or empty answer from YouTube or Plex no longer empties a playlist, a mirror or your Plex watch history: Siftlode keeps what it had until a complete answer arrives. +- Plex watch states un-watched on Plex now sync for each library on its own, even if another library is briefly unreachable. +- A channel with no uploads no longer fails its backfill on every run. +- “This playlist no longer exists” appears only for a list you picked yourself. +- A filter on a deleted tag matches nothing and its chip says so, and the tag cleanup keeps tags a saved view filters on. +- A channel filter names its channels from the first moment, and a playlist reopened from the cache keeps its rows. +- The bug report opens on its title, and a dialog whose form loads late gives the form the focus once. +- The Plex backdrop crossfade setting is respected even with reduced motion on. +- Smaller fixes to keyboard focus, undo and loading states. + ## 0.98.0 — 2026-10-03 ### New diff --git a/CLAUDE.md b/CLAUDE.md index 5a0f01ea..4eb2e8ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,12 +12,15 @@ router-free navigation provider. ## The gate — never merge or ship red -The local gate is **ruff (`check` + `format --check`) + basedpyright (backend) + typecheck (3 tsc projects) + eslint + prettier ---check + knip + vitest + pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no -`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review. +The local gate is **ruff (`check` + `format --check`) + basedpyright (backend) + the lock check + typecheck (3 tsc projects) + +eslint + prettier --check + knip (both modes) + vitest + vite build + pytest**, then a coverage report (no threshold). It runs +before every `release` merges and on every `publish`, which **refuses to build on failure — there is deliberately no `--skip` +flag**; a release also requires the Playwright suite, and the image must pass a Trivy scan before it is pushed. Run it and get it +green **before** any merge, release, or handing work to review. - Full gate (agents with the private ops dispatcher): `siftlode check`. -- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` + `ruff format --check backend` and `pytest` (the DB-lane needs Postgres). +- Directly, without the dispatcher: the commands in [`docs/development.md`](docs/development.md) § The gate (frontend lanes, `ruff` over `backend scripts`, basedpyright in the test image, and `pytest` — the DB-lane needs Postgres). +- **A gate with any real hit is never switched off or narrowed** — not a lint rule, a compiler flag, a lane or a release step. Fix the sites, or suppress each one inline naming its invariant (`-- why`, lint-enforced). What stays narrowed is in [`docs/gates.md`](docs/gates.md), and a contract test fails when a narrowing is missing from it. The effective ESLint config of every file and the CI/lane lines are pinned in `frontend/src/lib/gates.snapshot.json`: a new code file or any gate change fails until you regenerate it (`GATE_SNAPSHOT=update`, see gates.md) — then read that diff as a gate change. A comment saying "X is enforced by Y" is checked by running Y (E32: one such comment hid 95 functions the React Compiler skipped). - **The backend type lane runs in the test image, not on the host** — `requirements.lock` cannot be installed on Windows (uvloop), and a type checker without the real packages reports nonsense. It fails on NEW errors only; the pre-existing ones sit in `backend/.basedpyright/baseline.json` and burn down epic by epic. Fixed some? Regenerate with `--writebaseline`. - New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`. @@ -77,37 +80,39 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory ## Canonical patterns — when you have a choice, pick THIS -| Area | Use | Not | -| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` | -| Request body | a Pydantic model | `payload: dict` | -| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself | -| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` | -| Admin gate import | `app.auth` | the `app.routes.admin` re-export | -| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) | -| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI | -| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` | -| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) | -| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter | -| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals | -| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers | -| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks | -| FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) · `Chip` · `DragHandle` | a raw `