From 783f6408f9aa20062cef0b4e85911168e3a410dd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 17:26:42 +0200 Subject: [PATCH] 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. --- CLAUDE.md | 11 +- backend/app/README.md | 10 +- docs/development.md | 35 ++-- docs/gates.md | 110 ++++++++++++ frontend/src/lib/gates.contract.test.ts | 223 ++++++++++++++++++++++++ 5 files changed, 370 insertions(+), 19 deletions(-) create mode 100644 docs/gates.md create mode 100644 frontend/src/lib/gates.contract.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5a0f01ea..c9f95274 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. 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`. diff --git a/backend/app/README.md b/backend/app/README.md index ec7ba9ef..b7c885c5 100644 --- a/backend/app/README.md +++ b/backend/app/README.md @@ -72,7 +72,9 @@ mutation it records, so an audit entry can never exist without its change (or vi ## The gate Everything below must be green before a change is done (run via `siftlode check`): -`ruff` (backend) · `tsc` ×3 + `eslint` + `prettier` + `knip` (frontend) · `vitest` · `pytest`. The -pytest lane runs in `backend/docker-compose.test.yml` — an ephemeral Postgres + the backend tree -bind-mounted — so the DB-backed integration tests (the `db` fixture) actually run; `SIFTLODE_TEST_ -REQUIRE_DB=1` there turns a would-be silent skip into a hard failure. See `docs/development.md`. +`ruff` + `basedpyright` (backend) · the lock check + `tsc` ×3 + `eslint` + `prettier` + `knip` + +`vite build` (frontend) · `vitest` · `pytest`. The pytest lane runs in `backend/docker-compose.test.yml` +— an ephemeral Postgres + the backend tree bind-mounted — so the DB-backed integration tests (the `db` +fixture) actually run; a missing database is a hard failure, not a silent skip, unless a hand run asks +for pure-logic only with `SIFTLODE_TEST_REQUIRE_DB=0`. See `docs/development.md`, and `docs/gates.md` +for what each gate deliberately leaves out. diff --git a/docs/development.md b/docs/development.md index 4b4227a2..090f54ac 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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` diff --git a/docs/gates.md b/docs/gates.md new file mode 100644 index 00000000..7616e2ae --- /dev/null +++ b/docs/gates.md @@ -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 | — | — | diff --git a/frontend/src/lib/gates.contract.test.ts b/frontend/src/lib/gates.contract.test.ts new file mode 100644 index 00000000..32444e82 --- /dev/null +++ b/frontend/src/lib/gates.contract.test.ts @@ -0,0 +1,223 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { ESLint, type Linter } from "eslint"; +import prettierConfig from "eslint-config-prettier"; +import ts from "typescript"; +import tseslint from "typescript-eslint"; +import { describe, expect, it } from "vitest"; + +// The gate register (docs/gates.md) is held to the EFFECTIVE configuration (E32). A narrowing that +// is not on the register fails here — a rule switched off for a scope, a weak compiler flag, a ruff +// ignore, a knip exclusion, an accepted CVE. So "the gate checks X" is a claim this test verifies, +// not one a comment makes. (The comment that claimed the React Compiler's rules "are also linted" +// stood for months while the rule that would have said so was off.) +// +// Each narrowing is a (scope, id) pair; the register must hold a table row whose first cell is the +// scope and which names the id in backticks. + +const REPO = fileURLToPath(new URL("../../../", import.meta.url)); +const FRONTEND = `${REPO}frontend/`; +const read = (path: string) => readFileSync(REPO + path, "utf8"); + +/** register scope → every backticked id on its rows. */ +function register(): Map> { + const rows = new Map>(); + for (const line of read("docs/gates.md").split("\n")) { + if (!line.startsWith("|") || /^\|(\s*-+\s*\|)+\s*$/.test(line)) continue; // not the |---| rule + const scope = line.split("|")[1]!.trim(); + const ids = rows.get(scope) ?? new Set(); + for (const m of line.matchAll(/`([^`]+)`/g)) ids.add(m[1]!); + rows.set(scope, ids); + } + return rows; +} + +function missingFrom(found: [scope: string, id: string][]): string[] { + const reg = register(); + return found.filter(([scope, id]) => !reg.get(scope)?.has(id)).map(([s, id]) => `${s}: ${id}`); +} + +const jsonc = (path: string) => { + const { config, error } = ts.parseConfigFileTextToJson(path, read(path)); + if (error) + throw new Error(`${path}: ${ts.flattenDiagnosticMessageText(error.messageText, "\n")}`); + return config as Record; +}; + +// --- ESLint --------------------------------------------------------------------------------------- + +type Rules = Partial; +const severity = (v: Linter.RuleEntry | undefined): number => { + if (v === undefined) return -1; // not applied at all + const s = Array.isArray(v) ? v[0] : v; + return s === 2 || s === "error" ? 2 : s === 1 || s === "warn" ? 1 : 0; +}; +// Formatting is Prettier's (its own lane): the rules eslint-config-prettier switches off are not a +// narrowing of anything. +const PRETTIER_OFF = new Set( + Object.entries(prettierConfig.rules ?? {}) + .filter(([, v]) => severity(v as Linter.RuleEntry) === 0) + .map(([r]) => r), +); +// …and 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, …). +const TSC_OWNED = new Set( + Object.entries(tseslint.configs.eslintRecommended.rules ?? {}) + .filter(([, v]) => severity(v as Linter.RuleEntry) === 0) + .map(([r]) => r), +); +const family = (rule: string) => (rule.includes("/") ? `${rule.split("/")[0]}/*` : rule); + +const SCOPES: Record = { + src: "src/components/Feed.tsx", + "src tests": "src/components/Feed.ytsort.dom.test.tsx", + "FocusSafeButton.tsx": "src/components/ui/FocusSafeButton.tsx", + "AnchoredPopover.tsx": "src/components/ui/AnchoredPopover.tsx", + "design-lab": "src/design-lab/batchA.tsx", + e2e: "e2e/nav.spec.ts", + "ts configs": "vite.config.ts", + "js configs": "eslint.config.js", +}; + +async function eslintNarrowings(): Promise<[string, string][]> { + const eslint = new ESLint({ cwd: FRONTEND }); + const rules: Record = {}; + for (const [scope, file] of Object.entries(SCOPES)) + rules[scope] = ((await eslint.calculateConfigForFile(file)) as Linter.Config).rules ?? {}; + const src = rules.src!; + const ignorable = (rule: string, all: Rules) => + PRETTIER_OFF.has(rule) || + TSC_OWNED.has(rule) || + // a core rule off because its typescript-eslint twin replaces it is reported as the twin + (!rule.includes("/") && `@typescript-eslint/${rule}` in all); + const found: [string, string][] = []; + // src: everything at off or warn + for (const [rule, v] of Object.entries(src)) + if (severity(v) < 2 && !ignorable(rule, src)) found.push(["src", rule]); + // every other scope: what is WEAKER there than in src (a rule family not applied at all is one id) + for (const scope of Object.keys(SCOPES).filter((s) => s !== "src")) { + const ids = new Set(); + for (const [rule, v] of Object.entries(src)) { + if (ignorable(rule, src) || severity(v) < 2) continue; + const there = severity(rules[scope]![rule]); + if (there < 2 && !(there === 1 && severity(v) === 1)) + ids.add(there < 0 ? family(rule) : rule); + } + for (const id of ids) found.push([scope, id]); + } + for (const ignored of eslintIgnores()) found.push(["everywhere", ignored]); + return found; +} + +/** The global `ignores` of eslint.config.js (the first, file-less config object). */ +function eslintIgnores(): string[] { + const m = /\{\s*ignores:\s*\[([^\]]*)\]\s*\}/.exec(read("frontend/eslint.config.js")); + return m ? [...m[1]!.matchAll(/"([^"]+)"/g)].map((x) => x[1]!) : []; +} + +describe("the gate register (docs/gates.md)", () => { + it("lists every ESLint rule narrowed, by scope", async () => { + expect(missingFrom(await eslintNarrowings())).toEqual([]); + }, 60_000); + + it("keeps --max-warnings 0, which is what makes a warn-level rule fail", () => { + const pkg = JSON.parse(read("frontend/package.json")) as { scripts: Record }; + expect(pkg.scripts.lint).toMatch(/--max-warnings 0\b/); + }); + + // --- TypeScript --------------------------------------------------------------------------------- + const PROJECTS = [ + "frontend/tsconfig.json", + "frontend/tsconfig.node.json", + "frontend/e2e/tsconfig.json", + ]; + const REQUIRED_ON = [ + "strict", + "noUncheckedIndexedAccess", + "noUnusedLocals", + "noUnusedParameters", + "noFallthroughCasesInSwitch", + ]; + it.each(PROJECTS)("%s keeps the strict flags on", (path) => { + const opts = jsonc(path).compilerOptions as Record; + expect(REQUIRED_ON.filter((flag) => opts[flag] !== true)).toEqual([]); + }); + it("lists every weakening compiler flag", () => { + const found: [string, string][] = []; + for (const path of PROJECTS) { + const opts = jsonc(path).compilerOptions as Record; + if (opts.skipLibCheck === true) found.push(["all three projects", "skipLibCheck"]); + } + expect(missingFrom(found)).toEqual([]); + }); + + // --- Ruff ----------------------------------------------------------------------------------------- + it("lists every ruff ignore and immutable call", () => { + const toml = read("backend/ruff.toml"); + const list = (key: string) => { + const m = new RegExp(`^${key}\\s*=\\s*\\[([^\\]]*)\\]`, "m").exec(toml); + return m ? [...m[1]!.matchAll(/"([^"]+)"/g)].map((x) => x[1]!) : []; + }; + const found: [string, string][] = [ + ...list("ignore").map((code): [string, string] => ["backend + scripts", code]), + ...list("extend-immutable-calls").map((c): [string, string] => ["backend + scripts", c]), + ]; + expect(toml).not.toMatch(/per-file-ignores/); // none today — a new one needs a row and this line + expect(missingFrom(found)).toEqual([]); + }); + + // --- basedpyright --------------------------------------------------------------------------------- + it("lists the type-check mode, the baseline and every rule override", () => { + const cfg = jsonc("backend/pyrightconfig.json"); + const found: [string, string][] = []; + if (cfg.typeCheckingMode !== "all" && cfg.typeCheckingMode !== "strict") + found.push(["backend/app + alembic", "typeCheckingMode"]); + found.push(["backend/app + alembic", "baseline.json"]); + for (const [key, value] of Object.entries(cfg)) + if (key.startsWith("report") && (value === false || value === "none" || value === "warning")) + found.push(["backend/app + alembic", key]); + expect(cfg.include).toEqual(["app", "alembic"]); // tests/ out — its row says why + expect(missingFrom(found)).toEqual([]); + }); + + // --- pytest --------------------------------------------------------------------------------------- + it("keeps pytest strict: no deselection, no warning filters, --strict-markers", () => { + const ini = read("backend/pytest.ini"); + expect(ini).toMatch(/--strict-markers/); + expect(ini).not.toMatch(/--deselect|--ignore|-p no:|filterwarnings|\s-k\s/); + }); + + // --- knip ----------------------------------------------------------------------------------------- + it("lists every knip production exclusion and ignore", () => { + const cfg = jsonc("frontend/knip.jsonc") as { project?: string[] } & Record; + const found: [string, string][] = (cfg.project ?? []) + .filter((p) => p.startsWith("!")) + .map((p): [string, string] => ["--production", p]); + for (const key of Object.keys(cfg).filter((k) => k.startsWith("ignore"))) + found.push(["knip", key]); + expect(missingFrom(found)).toEqual([]); + }); + + // --- accepted findings ---------------------------------------------------------------------------- + it("accepts a CVE only with a statement, an expiry, and a row", () => { + const yaml = read(".trivyignore.yaml"); + const entries = yaml + .split(/\n\s*- id:\s*/) + .slice(1) + .map((chunk) => ({ + id: chunk.split("\n")[0]!.trim(), + statement: /\n\s*statement:\s*\S/.test(chunk), + expires: /\n\s*expired_at:\s*\d{4}-\d{2}-\d{2}/.test(chunk), + })); + expect(entries.filter((e) => !e.statement || !e.expires).map((e) => e.id)).toEqual([]); + const pipIgnores = read(".gitea/workflows/check.yml") + .split("\n") + .filter((l) => !l.trim().startsWith("#")) // the step's comment explains the flag; it is not one + .flatMap((l) => [...l.matchAll(/--ignore-vuln[ =](\S+)/g)].map((m) => m[1]!)); + const found = [...entries.map((e) => e.id), ...pipIgnores].map((id): [string, string] => [ + "accepted", + id, + ]); + expect(missingFrom(found)).toEqual([]); + }); +});