Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell, the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate register, with its three imports. Placement: Plex's chip builder moves into lib beside the feed's, the folder names into features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed" itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic. The floating header re-measures when one page's pills replace another's in the same commit (the claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a page with a rail adds in lib.
155 lines
8.3 KiB
Markdown
155 lines
8.3 KiB
Markdown
# Development
|
|
|
|
How to run Siftlode locally and pass the gate. Conventions for the backend code itself live in
|
|
[`backend/app/README.md`](../backend/app/README.md).
|
|
|
|
## Node
|
|
|
|
One major, everywhere. `.nvmrc` pins it; the `Dockerfile`'s frontend build stage,
|
|
`frontend/package.json`'s `engines` and `@types/node` must all match it — and so must the machine you
|
|
run the gate on. `frontend/src/lib/nodeEngine.contract.test.ts` fails the gate the moment any of them
|
|
drift apart, including when yours is the odd one out. `nvm use` in the repo root picks it up.
|
|
|
|
This is a contract, not a preference: tsc and vitest read the engine's own `Intl` and web APIs, and
|
|
those change between majors — the same currency format renders `$160M` on one and `$160.0M` on the
|
|
next. A gate that is green on a major you do not ship is green about nothing.
|
|
|
|
## Run it locally
|
|
|
|
The whole stack (Postgres + API + worker) comes up from the base compose file plus the localdev override:
|
|
|
|
```bash
|
|
docker compose -f docker-compose.yml -f docker-compose.localdev.yml up --build
|
|
```
|
|
|
|
Browse at **http://localhost:8080** (Google login works — the OAuth redirect is
|
|
`http://localhost:8080/auth/callback`). The API runs `alembic upgrade head` on startup, so the schema
|
|
is always current.
|
|
|
|
For a fast frontend edit loop, run the Vite dev server with HMR against that API:
|
|
|
|
```bash
|
|
cd frontend && npm install && npm run dev # http://localhost:5173, proxying the API on :8080
|
|
```
|
|
|
|
The dev server also serves the **design lab** at `http://localhost:5173/design-lab`: the open design
|
|
decisions and the chosen patterns, rendered with the app's real CSS. It never reaches a production build
|
|
(see [`DESIGN.md`](DESIGN.md)).
|
|
|
|
## Adding a page
|
|
|
|
A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
|
|
[`CLAUDE.md`](../CLAUDE.md) § Architecture shape):
|
|
|
|
1. The page itself, default-exported, and anything only it uses. Header content (a search box, a toggle) the
|
|
page renders into the floating header itself through `PageHeader`; the shell knows no page.
|
|
2. `module.ts`: its `ModuleDef` (`lib/moduleDef.ts`): id, label keys, icon, an optional `gate`, the page as a
|
|
`lazyWithPreload(() => import(…))`, and optionally a rail and a `Store`. Of its own folder it imports statically
|
|
only its store (a `…Provider` / `…Store` file); the page and the rail load lazily.
|
|
3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's
|
|
hooks). Another folder reaches the module through this file or not at all.
|
|
4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in
|
|
nav-rail order. The rail, the palette, the `g` chords, the tab title and the root's stores all read from there.
|
|
5. Its strings in both locales (`src/i18n/locales`).
|
|
6. A page with a side rail also names its panel below the module, in `lib/` (rails share one layout
|
|
and collapse machinery, which lib may not learn from a module): the `PanelId` and its default group
|
|
order, storage and pref keys in `lib/panelLayout.ts`, its collapse flag in `lib/useRail.ts`
|
|
(`COLLAPSE`), and those keys in `lib/storage.ts`. The rail calls `useRail("<id>")` and hands what it
|
|
returns to `ui/FilterRail`; tsc names each table still missing the new id.
|
|
|
|
What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily),
|
|
`moduleStores.contract.test.tsx` (no store reads another module), the `local/layers` lint rule (imports only go
|
|
down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the
|
|
i18n parity test.
|
|
|
|
## The gate
|
|
|
|
A change is done when this is green, from the repo root:
|
|
|
|
```bash
|
|
bash scripts/check.sh # every lane
|
|
bash scripts/check.sh lint # the static lanes only (no tests, build or audits) — the quick loop
|
|
bash scripts/check.sh --brief # either mode: one line per lane, output only for a red one
|
|
bash scripts/check.sh --list # the lane names, one per line (runs nothing)
|
|
bash scripts/check.sh --only "vitest" # one lane, by its exact name — never "the gate"
|
|
```
|
|
|
|
It runs every lane even after one fails and names each red one at the end.
|
|
|
|
The codebase's health numbers — size, complexity, test ratio, gate weight, comment noise — come from
|
|
`python scripts/audit_metrics.py` (seconds; it reports, it never fails). `--speed` adds the speed
|
|
baseline (`frontend/e2e/speed.measure.ts`: the feed's first card and a shared `/watch` link's
|
|
time-to-play against the :8080 stack, with `E2E_PASSWORD` set). An epic that changes structure
|
|
compares its before and after. What it runs, and why
|
|
each lane runs where it does, is in the script's comments; `frontend/src/lib/gates.contract.test.ts`
|
|
holds its lines, so a lane dropped or narrowed there fails a test. What each gate deliberately does
|
|
NOT check — and why — is the [gate register](gates.md). There is no CI: the gate runs here, and a
|
|
release runs it (plus e2e and the CVE scan) before anything is merged.
|
|
|
|
The lanes, for running one by hand:
|
|
|
|
**Backend**
|
|
|
|
```bash
|
|
# 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
|
|
|
|
# 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. 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 lock against the advisory databases, at any severity
|
|
docker compose -f backend/docker-compose.test.yml run --rm --no-deps test \
|
|
pip-audit -r requirements.lock --no-deps --disable-pip
|
|
```
|
|
|
|
**Shell scripts**
|
|
|
|
```bash
|
|
# every tracked *.sh at shellcheck's default bar (info included); the version is pinned in scripts/check.sh
|
|
git ls-files -z -- '*.sh' | xargs -0 shellcheck
|
|
```
|
|
|
|
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 (typed rules included; warnings fail)
|
|
npm run format:check # prettier
|
|
npm run knip # unused files/exports/deps
|
|
npm run test # vitest (incl. the compiler and gate-register contracts)
|
|
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code, chunkGuard on a lazy page loaded at boot)
|
|
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
|
|
```
|
|
|
|
Coverage is on demand, never enforced and not in the gate (E35): `npm run test -- --coverage` in
|
|
`frontend/`, and for the backend
|
|
`docker compose -f backend/docker-compose.test.yml run --rm test python -m pytest --cov=app` (then
|
|
`… down -v`).
|
|
The DB lane runs on every core (`pytest -n auto`, pytest-xdist): `tests/db_per_worker.py` gives each worker its own
|
|
database (`siftlode_test_gw<N>`, created and migrated on first use), so the same tests run side by side. A serial
|
|
run (no `-n`) still works and uses `siftlode_test` itself.
|
|
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`
|
|
in one reviewed step. Migrations are Alembic revisions under `backend/alembic/versions`; the API
|
|
applies them on startup, and the DB-lane rebuilds the schema via `alembic upgrade head`, so a broken
|
|
migration chain fails the tests.
|