From 182ec3112050336988775bf9307e5bb99608a00e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 4 Oct 2026 04:47:10 +0200 Subject: [PATCH] test(gates): hold the lint and lane configs to a committed snapshot The register test detected narrowings by modelling config shapes, and each review round found one more shape it did not model (a basePath global ignore, a rule src never declares, a widened glob filed under a narrow row, a substring check on a CI line, knip.json beside knip.jsonc). It now compares instead: gates.snapshot.json holds ESLint's effective config for every code file and the lines that run each lane, and any difference fails until it is regenerated. The register is read off the snapshot's configs, which surfaced the ui/ and lib/ test scopes the sampling never reached. The complexity ratchet also names a wrapped function by its binding (const onSave = useCallback(() => ...)), not as "useCallback callback". --- CLAUDE.md | 2 +- docs/gates.md | 39 +- frontend/src/lib/gates.contract.test.ts | 428 ++-- frontend/src/lib/gates.snapshot.json | 2371 ++++++++++++++++++ frontend/src/lib/sourceSize.contract.test.ts | 13 + frontend/src/lib/sourceSize.testkit.ts | 36 +- 6 files changed, 2725 insertions(+), 164 deletions(-) create mode 100644 frontend/src/lib/gates.snapshot.json diff --git a/CLAUDE.md b/CLAUDE.md index 5f282707..4eb2e8ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ green **before** any merge, release, or handing work to review. - Full gate (agents with the private ops dispatcher): `siftlode check`. - 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). +- **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`. diff --git a/docs/gates.md b/docs/gates.md index c4a0a36e..b1d7b907 100644 --- a/docs/gates.md +++ b/docs/gates.md @@ -13,19 +13,30 @@ name its invariant. Whatever is left narrowed is listed here, and **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. For ESLint it reads each -scope's effective config through its sample files AND every config block on its own: a block glob -that no sample matches fails as an `unsampled block` (add a sample), a rule a block or src sets -weaker than src counts, so does a `no-restricted-*` list that leaves out an entry the src block -declares, so does an option that lets something through (`allow…`, `ignore…`, `except…`), and so -does any `linterOptions` but an erroring `reportUnusedDisableDirectives` (E32 review). Ruff's TOML -is parsed and held to a whitelist of key paths, pytest.ini to a whitelist of whole lines. +**The snapshot.** ESLint's effective config for every code file `eslint .` reaches, and the lines +that run the lanes (`.gitea/workflows/check.yml`, `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 glob that moves one file to another config, a new ignore, a CI +flag. 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 file only adds its path; anything else +needs a reason. A new code file is part of that diff by design. + +**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 checks the flag is still there). The warn rows are listed so that claim stays checked. +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 @@ -45,10 +56,10 @@ them itself (`no-undef`, `no-redeclare`, `no-dupe-keys`, …), nor the formattin | 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 | | lib/messagesSocket.ts | `no-restricted-globals` — narrowed | A network owner: the chat socket is the one `WebSocket`. | 1 global | -| 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. | — | +| 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/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 + AnchoredPopover.tsx | `local/focus-safe-disabled` — off | FocusSafeButton: it IS the focus-safe primitive the rule points everyone else to. | — | +| FocusSafeButton.tsx + AnchoredPopover.tsx | `local/focus-safe-disabled` — off | AnchoredPopover: 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. | — | @@ -112,7 +123,7 @@ project checks needs a row. | 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 | 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) | | 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. A function is held by its name, not its rank in the file. | files >800 lines: 13 FE / 9 BE; functions over the limit: 64 FE / 32 BE (E32 review) | -| size / complexity | shipped code only — not tests, test kits, e2e or `scripts/` | The ratchet guards what runs in production. A test file is long because it is a list of cases, and a test kit is test code; splitting either moves lines without making anything easier to change. `scripts/` is release and seed tooling, not in the image. | outside it: tests >800 lines 2 FE / 5 BE; functions over the limit 2 FE test code (`gates.contract.test.ts` 30, `compilerSkips.testkit.ts` 25), 1 BE test (11), 1 script (`e2e_seed.py` `main` 13) | +| size / complexity | shipped code only — not tests, test kits, e2e or `scripts/` | The ratchet guards what runs in production. A test file is long because it is a list of cases, and a test kit is test code; splitting either moves lines without making anything easier to change. `scripts/` is release and seed tooling, not in the image. | outside it: tests >800 lines 2 FE / 5 BE; functions over the limit 2 FE test code (`gates.contract.test.ts` `eslintNarrowings` 23, E32 review round 3; `compilerSkips.testkit.ts` 25), 1 BE test (11), 1 script (`e2e_seed.py` `main` 13) | ## Accepted findings diff --git a/frontend/src/lib/gates.contract.test.ts b/frontend/src/lib/gates.contract.test.ts index 5f10d77d..b826c17d 100644 --- a/frontend/src/lib/gates.contract.test.ts +++ b/frontend/src/lib/gates.contract.test.ts @@ -1,7 +1,10 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { relative } from "node:path"; +import { fileURLToPath } from "node:url"; import { ESLint, type Linter } from "eslint"; import prettierConfig from "eslint-config-prettier"; +import { format, resolveConfig } from "prettier"; import { parse as parseToml } from "smol-toml"; import ts from "typescript"; import tseslint from "typescript-eslint"; @@ -25,10 +28,15 @@ 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); + // a row may cover several scopes the same way: `src tests · ui tests · lib tests` + for (const scope of line + .split("|")[1]! + .split(" · ") + .map((s) => s.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; } @@ -47,7 +55,6 @@ const jsonc = (path: string) => { // --- 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; @@ -69,57 +76,6 @@ const TSC_OWNED = new Set( ); const family = (rule: string) => (rule.includes("/") ? `${rule.split("/")[0]}/*` : rule); -// Each scope is checked through its sample files' EFFECTIVE config (what a rule family not applied -// there means), and every config block that targets files is checked on its own (below): each of its -// globs must reach a sample, so a glob no sample reaches fails rather than passing unseen (E32 review: -// a narrowing scoped to any other file changed none of the sampled configs and needed no row; round -// 2: a block was sampled through ONE of its globs, and `reactPlugin.ts` rode along unseen). -const SCOPES: Record = { - src: ["src/components/Feed.tsx"], - "src/lib": ["src/lib/format.ts"], - "lib/http.ts": ["src/lib/http.ts"], - "lib/api/core.ts": ["src/lib/api/core.ts"], - "lib/messagesSocket.ts": ["src/lib/messagesSocket.ts"], - "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", "reactPlugin.ts", "devOnlyGuard.ts"], - "js configs": ["eslint.config.js"], -}; -const SAMPLES = Object.entries(SCOPES).flatMap(([scope, files]) => - files.map((f): [scope: string, file: string] => [scope, f]), -); -// The block every src file gets: the reference a scope's re-declared `no-restricted-*` list is held to. -const SRC_GLOB = "src/**/*.{ts,tsx}"; - -/** A flat-config `files` glob as a regex — the shapes this config uses: `**`, `*`, `{a,b}`. */ -function globRegex(glob: string): RegExp { - let out = ""; - for (let i = 0; i < glob.length; i++) { - const c = glob[i]!; - if (glob.startsWith("**/", i)) { - out += "(?:.*/)?"; - i += 2; - } else if (c === "*") out += "[^/]*"; - else if (c === "{") { - const end = glob.indexOf("}", i); - out += `(?:${glob - .slice(i + 1, end) - .split(",") - .join("|")})`; - i = end; - } else out += /[.+?^$()|[\]\\]/.test(c) ? `\\${c}` : c; - } - return new RegExp(`^${out}$`); -} - -type Block = Linter.Config & { files?: (string | string[])[]; ignores?: string[] }; -const blocks = async (): Promise => - ((await import(pathToFileURL(`${FRONTEND}eslint.config.js`).href)) as { default: Block[] }) - .default; - // A rule option whose key says it lets something through (`allowShortCircuit`, // `allowForKnownSafeCalls`, `ignoreRestSiblings`, `exceptAfterSingleLine`) narrows the rule, and a // `no-restricted-*` list re-declared for a scope narrows it by every entry it leaves out. @@ -143,101 +99,275 @@ function restrictedEntries(entry: Linter.RuleEntry | undefined): Set { return new Set(flat.map((e) => JSON.stringify(e))); } -async function eslintNarrowings(): Promise<[string, string][]> { +// --- the snapshot --------------------------------------------------------------------------------- +// The configs that can narrow a gate in more shapes than a check can enumerate are held to a +// COMMITTED SNAPSHOT (E32 review): ESLint's effective config for every file `eslint .` reaches, and +// the lines that run the lanes. Any change fails until the snapshot is regenerated, so a narrowing +// arrives as a diff of `gates.snapshot.json` in review instead of slipping past a detector. (Three +// review rounds kept finding shapes the detector this replaced did not model — a `basePath` global +// ignore, a widened glob filed under a narrow row, a substring check on a CI line. A comparison +// against everything has no shape to miss.) +// +// Regenerate: GATE_SNAPSHOT=update npx vitest run src/lib/gates.contract.test.ts +// then read the diff like any gate change. An ESLint config that changed keeps its scope name when +// most of its files had it; one no name fits is written as `new config N` — rename it to the +// register scope it is (the register test below then asks for its rows). + +const SNAPSHOT = "frontend/src/lib/gates.snapshot.json"; +const NOT_LINTED = "(not linted)"; +type EslintConfig = { + rules: Record; + linterOptions: Record; + languageOptions: Record; + plugins: string[]; + settings: unknown; +}; +type Snapshot = { + eslint: { configs: Record; files: Record }; + lanes: Record; +}; + +const sortKeys = (v: unknown): unknown => + Array.isArray(v) + ? v.map(sortKeys) + : v !== null && typeof v === "object" + ? Object.fromEntries( + Object.keys(v) + .sort() + .map((k) => [k, sortKeys((v as Record)[k])]), + ) + : v; +// through JSON, so a value compares as what the file can hold (a RegExp option is `{}` on both sides) +const canonical = (v: T): T => JSON.parse(JSON.stringify(sortKeys(v) ?? null)) as T; + +function normalize(cfg: Linter.Config): EslintConfig { + const lang = { ...(cfg.languageOptions ?? {}) } as Record; + const parser = lang.parser as { meta?: { name?: string } } | undefined; + const parserOptions = { ...(lang.parserOptions as Record | undefined) }; + if (typeof parserOptions.tsconfigRootDir === "string") + // absolute: differs per machine + parserOptions.tsconfigRootDir = relative(FRONTEND, parserOptions.tsconfigRootDir) || "."; + // ~1200 browser globals per config: held by count and digest, not listed + const globals = Object.entries((lang.globals ?? {}) as Record) + .map(([name, v]) => `${name}:${String(v)}`) + .sort(); + const digest = createHash("sha1").update(globals.join("\n")).digest("hex").slice(0, 12); + return canonical({ + languageOptions: { + ...lang, + parser: parser ? (parser.meta?.name ?? "(unnamed parser)") : null, + parserOptions, + globals: `${globals.length} names, sha1 ${digest}`, + }, + linterOptions: (cfg.linterOptions ?? {}) as Record, + plugins: Object.keys(cfg.plugins ?? {}).sort(), + settings: cfg.settings ?? null, + rules: Object.fromEntries( + Object.entries(cfg.rules ?? {}).map(([rule, v]) => [ + rule, + Array.isArray(v) ? [severity(v), ...v.slice(1)] : [severity(v)], + ]), + ), + }); +} + +// Every code file `eslint .` walks into. The skipped directories are the global ignores' generated +// output (the register's `everywhere` row) — untracked, and present on one machine but not another. +// Every other file is asked for its config, so a file a new ignore drops lands in `(not linted)`. +const WALK_SKIP = new Set([ + "node_modules", + "dist", + "playwright-report", + "test-results", + "e2e/.auth", +]); +const CODE = /[.](?:[mc]?[jt]s|[jt]sx)$/; +function codeFiles(dir = ""): string[] { + return readdirSync(FRONTEND + dir, { withFileTypes: true }).flatMap((d) => { + const path = dir + d.name; + if (d.isDirectory()) return WALK_SKIP.has(path) ? [] : codeFiles(`${path}/`); + return CODE.test(d.name) ? [path] : []; + }); +} + +// On disk every config but src is written as its difference from src — the rules it sets otherwise +// and the src rules it does not apply — so a scope reads like its register rows, and one rule set +// is not repeated fourteen times. In memory it is always whole. +type Stored = Omit & { + rules: Record; + notApplied?: string[]; +}; +function toStored(configs: Record): Record { + const src = configs.src?.rules; + if (!src) return configs; + return Object.fromEntries( + Object.entries(configs).map(([name, cfg]) => { + if (name === "src") return [name, cfg]; + const rules = Object.fromEntries( + Object.entries(cfg.rules).filter(([r, v]) => JSON.stringify(v) !== JSON.stringify(src[r])), + ); + return [ + name, + { ...cfg, rules, notApplied: Object.keys(src).filter((r) => !(r in cfg.rules)) }, + ]; + }), + ); +} +function fromStored(stored: Record): Record { + const src = stored.src?.rules ?? {}; + return Object.fromEntries( + Object.entries(stored).map(([name, { notApplied, ...cfg }]) => { + if (!notApplied) return [name, cfg]; + const rules = { ...src, ...cfg.rules }; + for (const r of notApplied) delete rules[r]; + return [name, canonical({ ...cfg, rules })]; + }), + ); +} +const committed = (): Snapshot => { + const s = JSON.parse(read(SNAPSHOT)) as Snapshot & { + eslint: { configs: Record }; + }; + return canonical({ ...s, eslint: { ...s.eslint, configs: fromStored(s.eslint.configs) } }); +}; + +async function eslintActual(names: Snapshot["eslint"]): Promise { const eslint = new ESLint({ cwd: FRONTEND }); - const found: [string, string][] = []; - const rules: [scope: string, rules: Rules][] = []; - for (const [scope, file] of SAMPLES) { - const cfg = (await eslint.calculateConfigForFile(file)) as Linter.Config | undefined; - if (!cfg) { - found.push([scope, `${file} is ignored`]); // ignored files get no config at all + const groups = new Map(); + const notLinted: string[] = []; + for (const file of codeFiles().sort()) { + const raw = (await eslint.calculateConfigForFile(file)) as Linter.Config | undefined; + if (!raw) { + notLinted.push(file); continue; } - rules.push([scope, cfg.rules ?? {}]); - // an unused `eslint-disable` is an error on every file, or a stale suppression stays armed - if (severity(cfg.linterOptions?.reportUnusedDisableDirectives as Linter.RuleEntry) !== 2) - found.push([scope, "reportUnusedDisableDirectives"]); + const config = normalize(raw); + const key = JSON.stringify(config); + const group = groups.get(key) ?? { config, files: [] }; + group.files.push(file); + groups.set(key, group); } - const src = rules.find(([scope]) => scope === "src")![1]; - 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); - // src: everything at off or warn, and every rule whose options let something through - for (const [rule, v] of Object.entries(src)) { - if (ignorable(rule, src)) continue; - if (severity(v) < 2 || (Array.isArray(v) && relaxes(v.slice(1)))) found.push(["src", rule]); + // a config is named by the snapshot entry it equals; a changed one by the old name most of its + // files had (if still free), so the diff shows the rule that moved, not a renamed scope + const byContent = new Map(Object.entries(names.configs).map(([n, c]) => [JSON.stringify(c), n])); + const oldName = new Map( + Object.entries(names.files).flatMap(([n, files]) => files.map((f) => [f, n] as const)), + ); + const configs: Record = {}; + const files: Record = { [NOT_LINTED]: notLinted }; + const unnamed: { config: EslintConfig; files: string[] }[] = []; + for (const [key, group] of groups) { + const name = byContent.get(key); + if (name) [configs[name], files[name]] = [group.config, group.files]; + else unnamed.push(group); } - // every other sample: what is WEAKER there than in src (a rule family not applied at all is one - // id). A src rule at warn counts too: switched off for a scope, it is narrowed there. - for (const [scope, theirs] of rules.filter(([s]) => s !== "src")) { - const ids = new Set(); - for (const [rule, v] of Object.entries(src)) { - if (ignorable(rule, src) || severity(v) === 0) continue; - const there = severity(theirs[rule]); - if (there < severity(v)) ids.add(there < 0 ? family(rule) : rule); + unnamed.forEach((group, i) => { + const votes = new Map(); + for (const f of group.files) { + const n = oldName.get(f); + if (n && n !== NOT_LINTED) votes.set(n, (votes.get(n) ?? 0) + 1); } + const best = [...votes].sort((a, b) => b[1] - a[1])[0]?.[0]; + const name = best && !(best in configs) ? best : `new config ${i + 1}`; + [configs[name], files[name]] = [group.config, group.files]; + }); + return canonical({ configs, files }); +} + +// the lines that run each lane, comments and blank lines aside +function meaningful(path: string): string[] { + return read(path) + .split("\n") + .map((l) => l.trimEnd()) + .filter((l) => { + const t = l.trim(); + return t !== "" && !/^(#|\/\/|\/\*|\*)/.test(t); + }); +} +const lanes = () => ({ + ".gitea/workflows/check.yml": meaningful(".gitea/workflows/check.yml"), + "backend/docker-compose.test.yml": meaningful("backend/docker-compose.test.yml"), + "backend/Dockerfile.test": meaningful("backend/Dockerfile.test"), + "frontend/package.json scripts": ( + JSON.parse(read("frontend/package.json")) as { scripts: Record } + ).scripts, + "frontend/vitest.config.ts": meaningful("frontend/vitest.config.ts"), + "frontend/.prettierignore": meaningful("frontend/.prettierignore"), +}); + +let actual: Promise | undefined; +const current = () => + (actual ??= (async () => + canonical({ eslint: await eslintActual(committed().eslint), lanes: lanes() }))()); + +// --- the register, read off the snapshot ------------------------------------------------------------ + +const ignorable = (rule: string, all: Record) => + 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 entry = (v: unknown[] | undefined) => v as Linter.RuleEntry | undefined; + +/** Every narrowing in the effective ESLint configs, as (scope, id). */ +function eslintNarrowings({ configs, files }: Snapshot["eslint"]): [string, string][] { + const found: [string, string][] = files[NOT_LINTED]!.map((f) => [ + "everywhere", + `${f} — not linted`, + ]); + const src = configs.src?.rules; + if (!src) return [...found, ["src", "(no config named src)"]]; + for (const [scope, { rules, linterOptions }] of Object.entries(configs)) { + // an unused `eslint-disable` is an error on every file, or a stale suppression stays armed + if (severity(linterOptions.reportUnusedDisableDirectives as Linter.RuleEntry) !== 2) + found.push([scope, "reportUnusedDisableDirectives"]); + const ids = new Set(); + for (const [rule, v] of Object.entries(rules)) { + if (ignorable(rule, rules)) continue; + const mine = entry(v)!; + const theirs = scope === "src" ? undefined : entry(src[rule]); + const relaxed = relaxes(v.slice(1)) && JSON.stringify(v) !== JSON.stringify(src[rule]); + if (theirs === undefined) { + // src, or a rule src does not have (the test and e2e plugins): at off or warn, or relaxed + if (severity(mine) < 2 || (scope === "src" ? relaxes(v.slice(1)) : relaxed)) ids.add(rule); + } else if (severity(mine) < severity(theirs)) ids.add(rule); + else if (rule.includes("no-restricted-")) { + const own = restrictedEntries(mine); + if ([...restrictedEntries(theirs)].some((e) => !own.has(e))) ids.add(rule); + } else if (relaxed) ids.add(rule); + } + // a src rule not applied at all here: one id for its whole family + if (scope !== "src") + for (const [rule, v] of Object.entries(src)) + if (!(rule in rules) && !ignorable(rule, src) && severity(entry(v)) > 0) + ids.add(family(rule)); for (const id of ids) found.push([scope, id]); } - // every config block: a global `ignores` is a narrowing everywhere; one that targets files must - // reach a sample through EACH glob, and what it declares is held to src on its own — a later block - // that switches a rule back on for the sample would otherwise hide where the first one is off - const all = await blocks(); - const isSrcBlock = (b: Block) => b.files?.flat().join() === SRC_GLOB; - const reference = new Map>(); // the FIRST src-block declaration of each list - for (const block of all.filter(isSrcBlock)) - for (const [rule, v] of Object.entries(block.rules ?? {})) - if (rule.includes("no-restricted-") && !reference.has(rule)) - reference.set(rule, restrictedEntries(v)); - let globalIgnores = 0; - for (const block of all) { - // ESLint reads a block as GLOBAL ignores when `ignores` is its only key besides `name` - if ( - !block.files && - Object.keys(block) - .filter((k) => k !== "name") - .join() === "ignores" - ) { - globalIgnores++; - for (const ignored of block.ignores ?? []) found.push(["everywhere", ignored]); - continue; - } - for (const [key, v] of Object.entries(block.linterOptions ?? {})) - if (key !== "reportUnusedDisableDirectives" || severity(v as Linter.RuleEntry) !== 2) - found.push(["linterOptions", key]); - if (!block.files) continue; - let scope: string | undefined; - for (const glob of block.files.flat()) { - const hit = SAMPLES.find(([, file]) => globRegex(glob).test(file)); - if (!hit) found.push(["unsampled block", glob]); - scope ??= hit?.[0]; - } - if (!scope) continue; - if (block.ignores?.length) found.push([scope, `ignores: ${block.ignores.join(", ")}`]); - for (const [rule, v] of Object.entries(block.rules ?? {})) { - if (ignorable(rule, src)) continue; - if (rule.includes("no-restricted-")) { - const ref = reference.get(rule); - const own = restrictedEntries(v); - if (ref && [...ref].some((e) => !own.has(e))) found.push([scope, rule]); - } else if (severity(v) < severity(src[rule]) || (Array.isArray(v) && relaxes(v.slice(1)))) - found.push([scope, rule]); - } - } - // the detection above must still see the block every config has, or `everywhere` is never checked - if (globalIgnores === 0) found.push(["everywhere", "(no global ignores block found)"]); return found; } +describe("the gate snapshot (gates.snapshot.json)", () => { + it("holds the effective ESLint config of every file and the lines that run each lane", async () => { + const now = await current(); + if (process.env.GATE_SNAPSHOT === "update") { + const path = REPO + SNAPSHOT; + const options = { ...(await resolveConfig(path)), filepath: path }; + const stored = { ...now, eslint: { ...now.eslint, configs: toStored(now.eslint.configs) } }; + writeFileSync(path, await format(JSON.stringify(sortKeys(stored)), options)); + return; + } + expect(now).toEqual(committed()); + }, 120_000); +}); + describe("the gate register (docs/gates.md)", () => { it("lists every ESLint rule narrowed, by scope", async () => { - expect(missingFrom(await eslintNarrowings())).toEqual([]); - }, 60_000); + expect(missingFrom(eslintNarrowings((await current()).eslint))).toEqual([]); + }, 120_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/); + expect(pkg.scripts.lint).toBe("eslint . --max-warnings 0"); }); // --- TypeScript --------------------------------------------------------------------------------- @@ -355,15 +485,20 @@ describe("the gate register (docs/gates.md)", () => { }); // Every ruff lane passes `--config backend/ruff.toml`, which makes ruff ignore any other config // file it would discover (measured: a nested `ruff.toml` selecting the `D` rules changed nothing). - // So that flag is what makes this file the whole configuration, and it is held here. - it("runs every CI ruff step on backend/ruff.toml", () => { + // So that flag is what makes this file the whole configuration. The snapshot holds every CI line; + // these two are spelled out here because the reason is (round 3: a substring check let an extra + // `--config "lint.extend-ignore=…"` override through). + it("runs every CI ruff step on backend/ruff.toml and nothing else", () => { const steps = read(".gitea/workflows/check.yml") .split("\n") - .filter((l) => /python -m ruff /.test(l)); - expect(steps.length).toBe(2); - expect(steps.filter((l) => !l.includes("--config backend/ruff.toml backend scripts"))).toEqual( - [], - ); + .filter((l) => /\bruff\b/.test(l) && !l.trim().startsWith("#")) + .map((l) => l.trim()); + expect(steps).toEqual([ + "- name: ruff", + "run: python -m ruff check --config backend/ruff.toml backend scripts", + "- name: ruff format --check", + "run: python -m ruff format --check --config backend/ruff.toml backend scripts", + ]); }); // --- basedpyright --------------------------------------------------------------------------------- @@ -423,6 +558,11 @@ describe("the gate register (docs/gates.md)", () => { expect(pkg.scripts.knip).toBe("knip && knip --production"); // the positive pattern is the project itself: narrowing it drops files from both modes expect((cfg.project ?? []).filter((p) => !p.startsWith("!"))).toEqual(["**/*.{ts,tsx,js}!"]); + // and knip.jsonc is the config knip reads: a knip.json (read first) or a package.json `knip` key + // (merged in) would narrow it unseen (E32 review round 3) + const KNIP_FILES = /^\.?knip(\.config)?\.(json|jsonc|js|ts|mjs|cjs|mts|cts|toml|ya?ml)$/; + expect(readdirSync(FRONTEND).filter((f) => KNIP_FILES.test(f))).toEqual(["knip.jsonc"]); + expect("knip" in pkg).toBe(false); }); // --- accepted findings ---------------------------------------------------------------------------- diff --git a/frontend/src/lib/gates.snapshot.json b/frontend/src/lib/gates.snapshot.json new file mode 100644 index 00000000..502789dc --- /dev/null +++ b/frontend/src/lib/gates.snapshot.json @@ -0,0 +1,2371 @@ +{ + "eslint": { + "configs": { + "FocusSafeButton.tsx + AnchoredPopover.tsx": { + "languageOptions": { + "ecmaVersion": 2022, + "globals": "1169 names, sha1 aee621727a03", + "parser": "typescript-eslint/parser", + "parserOptions": { "projectService": true, "tsconfigRootDir": "." }, + "sourceType": "module" + }, + "linterOptions": { "reportUnusedDisableDirectives": 2 }, + "notApplied": [], + "plugins": [ + "@", + "@eslint-community/eslint-comments", + "@typescript-eslint", + "local", + "react-hooks", + "react-refresh" + ], + "rules": { + "local/focus-safe-disabled": [0], + "no-restricted-imports": [ + 2, + { + "patterns": [ + { + "message": "components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.", + "regex": "^\\.\\./(?!\\.\\./)" + }, + { + "message": "components/ui/ is domain-free: a platform component may not import a feature component. Invert the dependency (take a prop/render-prop) or keep the component outside ui/.", + "regex": "^\\.\\./\\.\\./components/(?!ui/)" + }, + { + "message": "components/ui/ is domain-free: a platform component may not call a domain API slice. Take the data as props.", + "regex": "^\\.\\./\\.\\./lib/api(/|$)" + } + ] + } + ] + }, + "settings": null + }, + "design-lab": { + "languageOptions": { + "ecmaVersion": 2022, + "globals": "1169 names, sha1 aee621727a03", + "parser": "typescript-eslint/parser", + "parserOptions": { "projectService": true, "tsconfigRootDir": "." }, + "sourceType": "module" + }, + "linterOptions": { "reportUnusedDisableDirectives": 2 }, + "notApplied": [], + "plugins": [ + "@", + "@eslint-community/eslint-comments", + "@typescript-eslint", + "local", + "react-hooks", + "react-refresh" + ], + "rules": { "local/literal-a11y-text": [0] }, + "settings": null + }, + "e2e": { + "languageOptions": { + "ecmaVersion": 2022, + "globals": "82 names, sha1 29467d490e30", + "parser": "typescript-eslint/parser", + "parserOptions": { "projectService": true, "tsconfigRootDir": "." }, + "sourceType": "module" + }, + "linterOptions": { "reportUnusedDisableDirectives": 2 }, + "notApplied": [ + "local/button-primitive", + "local/focus-safe-disabled", + "local/glyph-link", + "local/input-primitive", + "local/jsx-comment-text", + "local/literal-a11y-text", + "local/locale-format", + "local/sortable-a11y", + "local/surface-role", + "no-console", + "no-restricted-globals", + "no-restricted-properties", + "no-restricted-syntax", + "react-hooks/capitalized-calls", + "react-hooks/component-hook-factories", + "react-hooks/config", + "react-hooks/error-boundaries", + "react-hooks/exhaustive-deps", + "react-hooks/exhaustive-effect-dependencies", + "react-hooks/gating", + "react-hooks/globals", + "react-hooks/hooks", + "react-hooks/immutability", + "react-hooks/incompatible-library", + "react-hooks/invariant", + "react-hooks/memo-dependencies", + "react-hooks/memoized-effect-dependencies", + "react-hooks/no-deriving-state-in-effects", + "react-hooks/preserve-manual-memoization", + "react-hooks/purity", + "react-hooks/refs", + "react-hooks/rule-suppression", + "react-hooks/rules-of-hooks", + "react-hooks/set-state-in-effect", + "react-hooks/set-state-in-render", + "react-hooks/static-components", + "react-hooks/syntax", + "react-hooks/todo", + "react-hooks/unsupported-syntax", + "react-hooks/use-memo", + "react-hooks/void-use-memo", + "react-refresh/only-export-components" + ], + "plugins": ["@", "@eslint-community/eslint-comments", "@typescript-eslint", "playwright"], + "rules": { + "@typescript-eslint/no-unused-expressions": [ + 2, + { "allowShortCircuit": false, "allowTaggedTemplates": false, "allowTernary": false } + ], + "playwright/no-focused-test": [2], + "playwright/no-skipped-test": [2] + }, + "settings": null + }, + "js configs": { + "languageOptions": { + "ecmaVersion": 2022, + "globals": "82 names, sha1 29467d490e30", + "parser": "(unnamed parser)", + "parserOptions": { "sourceType": "module" }, + "sourceType": "module" + }, + "linterOptions": { "reportUnusedDisableDirectives": 2 }, + "notApplied": [ + "@typescript-eslint/ban-ts-comment", + "@typescript-eslint/no-array-constructor", + "@typescript-eslint/no-deprecated", + "@typescript-eslint/no-duplicate-enum-values", + "@typescript-eslint/no-empty-object-type", + "@typescript-eslint/no-explicit-any", + "@typescript-eslint/no-extra-non-null-assertion", + "@typescript-eslint/no-floating-promises", + "@typescript-eslint/no-misused-new", + "@typescript-eslint/no-namespace", + "@typescript-eslint/no-non-null-asserted-optional-chain", + "@typescript-eslint/no-require-imports", + "@typescript-eslint/no-this-alias", + "@typescript-eslint/no-unnecessary-type-constraint", + "@typescript-eslint/no-unsafe-declaration-merging", + "@typescript-eslint/no-unsafe-function-type", + "@typescript-eslint/no-unused-expressions", + "@typescript-eslint/no-unused-vars", + "@typescript-eslint/no-wrapper-object-types", + "@typescript-eslint/prefer-as-const", + "@typescript-eslint/prefer-namespace-keyword", + "@typescript-eslint/triple-slash-reference", + "local/button-primitive", + "local/focus-safe-disabled", + "local/glyph-link", + "local/input-primitive", + "local/jsx-comment-text", + "local/literal-a11y-text", + "local/locale-format", + "local/sortable-a11y", + "local/surface-role", + "no-array-constructor", + "no-console", + "no-new-symbol", + "no-restricted-globals", + "no-restricted-properties", + "no-restricted-syntax", + "no-unused-expressions", + "no-var", + "prefer-const", + "prefer-rest-params", + "prefer-spread", + "react-hooks/capitalized-calls", + "react-hooks/component-hook-factories", + "react-hooks/config", + "react-hooks/error-boundaries", + "react-hooks/exhaustive-deps", + "react-hooks/exhaustive-effect-dependencies", + "react-hooks/gating", + "react-hooks/globals", + "react-hooks/hooks", + "react-hooks/immutability", + "react-hooks/incompatible-library", + "react-hooks/invariant", + "react-hooks/memo-dependencies", + "react-hooks/memoized-effect-dependencies", + "react-hooks/no-deriving-state-in-effects", + "react-hooks/preserve-manual-memoization", + "react-hooks/purity", + "react-hooks/refs", + "react-hooks/rule-suppression", + "react-hooks/rules-of-hooks", + "react-hooks/set-state-in-effect", + "react-hooks/set-state-in-render", + "react-hooks/static-components", + "react-hooks/syntax", + "react-hooks/todo", + "react-hooks/unsupported-syntax", + "react-hooks/use-memo", + "react-hooks/void-use-memo", + "react-refresh/only-export-components" + ], + "plugins": ["@", "@eslint-community/eslint-comments"], + "rules": { + "constructor-super": [2], + "getter-return": [2, { "allowImplicit": false }], + "no-class-assign": [2], + "no-const-assign": [2], + "no-dupe-args": [2], + "no-dupe-class-members": [2], + "no-dupe-keys": [2], + "no-func-assign": [2], + "no-import-assign": [2], + "no-new-native-nonconstructor": [2], + "no-obj-calls": [2], + "no-redeclare": [2, { "builtinGlobals": true }], + "no-setter-return": [2], + "no-this-before-super": [2], + "no-undef": [2, { "typeof": false }], + "no-unreachable": [2], + "no-unsafe-negation": [2, { "enforceForOrderingRelations": false }], + "no-unused-vars": [2], + "no-with": [2] + }, + "settings": null + }, + "lib tests": { + "languageOptions": { + "ecmaVersion": 2022, + "globals": "1169 names, sha1 aee621727a03", + "parser": "typescript-eslint/parser", + "parserOptions": { "projectService": true, "tsconfigRootDir": "." }, + "sourceType": "module" + }, + "linterOptions": { "reportUnusedDisableDirectives": 2 }, + "notApplied": [], + "plugins": [ + "@", + "@eslint-community/eslint-comments", + "@typescript-eslint", + "local", + "react-hooks", + "react-refresh", + "vitest" + ], + "rules": { + "local/button-primitive": [0], + "local/glyph-link": [0], + "local/input-primitive": [0], + "local/literal-a11y-text": [0], + "local/surface-role": [0], + "no-restricted-globals": [ + 0, + { + "message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only (E22 S4 — a request outside it never reaches the client log).", + "name": "fetch" + }, + { + "message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only (E22 S4 — a request outside it never reaches the client log).", + "name": "XMLHttpRequest" + }, + { + "message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).", + "name": "WebSocket" + }, + { + "message": "No `EventSource` without a logged owner module (E22 S4).", + "name": "EventSource" + } + ], + "no-restricted-imports": [ + 2, + { + "patterns": [ + { + "message": "lib/ sits below components/: it may not import from components/, not even a type. Move the shared piece down into lib/.", + "regex": "^(\\.\\./)+components(/|$)" + } + ] + } + ], + "no-restricted-properties": [ + 0, + { + "message": "Use lib/http.ts `send` (E22 S4).", + "object": "window", + "property": "fetch" + }, + { + "message": "Use lib/http.ts `send` (E22 S4).", + "object": "globalThis", + "property": "fetch" + }, + { + "message": "Use lib/api/core.ts `beacon` (E22 S4).", + "object": "navigator", + "property": "sendBeacon" + } + ], + "vitest/no-disabled-tests": [2], + "vitest/no-focused-tests": [2, { "fixable": true }] + }, + "settings": null + }, + "lib/api/core.ts": { + "languageOptions": { + "ecmaVersion": 2022, + "globals": "1169 names, sha1 aee621727a03", + "parser": "typescript-eslint/parser", + "parserOptions": { "projectService": true, "tsconfigRootDir": "." }, + "sourceType": "module" + }, + "linterOptions": { "reportUnusedDisableDirectives": 2 }, + "notApplied": [], + "plugins": [ + "@", + "@eslint-community/eslint-comments", + "@typescript-eslint", + "local", + "react-hooks", + "react-refresh" + ], + "rules": { + "no-restricted-globals": [ + 2, + { + "message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only (E22 S4 — a request outside it never reaches the client log).", + "name": "fetch" + }, + { + "message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).", + "name": "WebSocket" + }, + { + "message": "No `EventSource` without a logged owner module (E22 S4).", + "name": "EventSource" + } + ], + "no-restricted-imports": [ + 2, + { + "patterns": [ + { + "message": "lib/ sits below components/: it may not import from components/, not even a type. Move the shared piece down into lib/.", + "regex": "^(\\.\\./)+components(/|$)" + } + ] + } + ] + }, + "settings": null + }, + "lib/http.ts": { + "languageOptions": { + "ecmaVersion": 2022, + "globals": "1169 names, sha1 aee621727a03", + "parser": "typescript-eslint/parser", + "parserOptions": { "projectService": true, "tsconfigRootDir": "." }, + "sourceType": "module" + }, + "linterOptions": { "reportUnusedDisableDirectives": 2 }, + "notApplied": [], + "plugins": [ + "@", + "@eslint-community/eslint-comments", + "@typescript-eslint", + "local", + "react-hooks", + "react-refresh" + ], + "rules": { + "no-restricted-globals": [ + 2, + { + "message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only (E22 S4 — a request outside it never reaches the client log).", + "name": "XMLHttpRequest" + }, + { + "message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).", + "name": "WebSocket" + }, + { + "message": "No `EventSource` without a logged owner module (E22 S4).", + "name": "EventSource" + } + ], + "no-restricted-imports": [ + 2, + { + "patterns": [ + { + "message": "lib/ sits below components/: it may not import from components/, not even a type. Move the shared piece down into lib/.", + "regex": "^(\\.\\./)+components(/|$)" + } + ] + } + ] + }, + "settings": null + }, + "lib/messagesSocket.ts": { + "languageOptions": { + "ecmaVersion": 2022, + "globals": "1169 names, sha1 aee621727a03", + "parser": "typescript-eslint/parser", + "parserOptions": { "projectService": true, "tsconfigRootDir": "." }, + "sourceType": "module" + }, + "linterOptions": { "reportUnusedDisableDirectives": 2 }, + "notApplied": [], + "plugins": [ + "@", + "@eslint-community/eslint-comments", + "@typescript-eslint", + "local", + "react-hooks", + "react-refresh" + ], + "rules": { + "no-restricted-globals": [ + 2, + { + "message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only (E22 S4 — a request outside it never reaches the client log).", + "name": "fetch" + }, + { + "message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only (E22 S4 — a request outside it never reaches the client log).", + "name": "XMLHttpRequest" + }, + { + "message": "No `EventSource` without a logged owner module (E22 S4).", + "name": "EventSource" + } + ], + "no-restricted-imports": [ + 2, + { + "patterns": [ + { + "message": "lib/ sits below components/: it may not import from components/, not even a type. Move the shared piece down into lib/.", + "regex": "^(\\.\\./)+components(/|$)" + } + ] + } + ] + }, + "settings": null + }, + "src": { + "languageOptions": { + "ecmaVersion": 2022, + "globals": "1169 names, sha1 aee621727a03", + "parser": "typescript-eslint/parser", + "parserOptions": { "projectService": true, "tsconfigRootDir": "." }, + "sourceType": "module" + }, + "linterOptions": { "reportUnusedDisableDirectives": 2 }, + "plugins": [ + "@", + "@eslint-community/eslint-comments", + "@typescript-eslint", + "local", + "react-hooks", + "react-refresh" + ], + "rules": { + "@babel/object-curly-spacing": [0], + "@babel/semi": [0], + "@eslint-community/eslint-comments/no-unlimited-disable": [2], + "@eslint-community/eslint-comments/require-description": [2, { "ignore": [] }], + "@stylistic/array-bracket-newline": [0], + "@stylistic/array-bracket-spacing": [0], + "@stylistic/array-element-newline": [0], + "@stylistic/arrow-parens": [0], + "@stylistic/arrow-spacing": [0], + "@stylistic/block-spacing": [0], + "@stylistic/brace-style": [0], + "@stylistic/comma-dangle": [0], + "@stylistic/comma-spacing": [0], + "@stylistic/comma-style": [0], + "@stylistic/computed-property-spacing": [0], + "@stylistic/dot-location": [0], + "@stylistic/eol-last": [0], + "@stylistic/func-call-spacing": [0], + "@stylistic/function-call-argument-newline": [0], + "@stylistic/function-call-spacing": [0], + "@stylistic/function-paren-newline": [0], + "@stylistic/generator-star-spacing": [0], + "@stylistic/implicit-arrow-linebreak": [0], + "@stylistic/indent": [0], + "@stylistic/indent-binary-ops": [0], + "@stylistic/js/array-bracket-newline": [0], + "@stylistic/js/array-bracket-spacing": [0], + "@stylistic/js/array-element-newline": [0], + "@stylistic/js/arrow-parens": [0], + "@stylistic/js/arrow-spacing": [0], + "@stylistic/js/block-spacing": [0], + "@stylistic/js/brace-style": [0], + "@stylistic/js/comma-dangle": [0], + "@stylistic/js/comma-spacing": [0], + "@stylistic/js/comma-style": [0], + "@stylistic/js/computed-property-spacing": [0], + "@stylistic/js/dot-location": [0], + "@stylistic/js/eol-last": [0], + "@stylistic/js/func-call-spacing": [0], + "@stylistic/js/function-call-argument-newline": [0], + "@stylistic/js/function-call-spacing": [0], + "@stylistic/js/function-paren-newline": [0], + "@stylistic/js/generator-star-spacing": [0], + "@stylistic/js/implicit-arrow-linebreak": [0], + "@stylistic/js/indent": [0], + "@stylistic/js/jsx-quotes": [0], + "@stylistic/js/key-spacing": [0], + "@stylistic/js/keyword-spacing": [0], + "@stylistic/js/linebreak-style": [0], + "@stylistic/js/lines-around-comment": [0], + "@stylistic/js/max-len": [0], + "@stylistic/js/max-statements-per-line": [0], + "@stylistic/js/multiline-ternary": [0], + "@stylistic/js/new-parens": [0], + "@stylistic/js/newline-per-chained-call": [0], + "@stylistic/js/no-confusing-arrow": [0], + "@stylistic/js/no-extra-parens": [0], + "@stylistic/js/no-extra-semi": [0], + "@stylistic/js/no-floating-decimal": [0], + "@stylistic/js/no-mixed-operators": [0], + "@stylistic/js/no-mixed-spaces-and-tabs": [0], + "@stylistic/js/no-multi-spaces": [0], + "@stylistic/js/no-multiple-empty-lines": [0], + "@stylistic/js/no-tabs": [0], + "@stylistic/js/no-trailing-spaces": [0], + "@stylistic/js/no-whitespace-before-property": [0], + "@stylistic/js/nonblock-statement-body-position": [0], + "@stylistic/js/object-curly-newline": [0], + "@stylistic/js/object-curly-spacing": [0], + "@stylistic/js/object-property-newline": [0], + "@stylistic/js/one-var-declaration-per-line": [0], + "@stylistic/js/operator-linebreak": [0], + "@stylistic/js/padded-blocks": [0], + "@stylistic/js/quote-props": [0], + "@stylistic/js/quotes": [0], + "@stylistic/js/rest-spread-spacing": [0], + "@stylistic/js/semi": [0], + "@stylistic/js/semi-spacing": [0], + "@stylistic/js/semi-style": [0], + "@stylistic/js/space-before-blocks": [0], + "@stylistic/js/space-before-function-paren": [0], + "@stylistic/js/space-in-parens": [0], + "@stylistic/js/space-infix-ops": [0], + "@stylistic/js/space-unary-ops": [0], + "@stylistic/js/switch-colon-spacing": [0], + "@stylistic/js/template-curly-spacing": [0], + "@stylistic/js/template-tag-spacing": [0], + "@stylistic/js/wrap-iife": [0], + "@stylistic/js/wrap-regex": [0], + "@stylistic/js/yield-star-spacing": [0], + "@stylistic/jsx-child-element-spacing": [0], + "@stylistic/jsx-closing-bracket-location": [0], + "@stylistic/jsx-closing-tag-location": [0], + "@stylistic/jsx-curly-newline": [0], + "@stylistic/jsx-curly-spacing": [0], + "@stylistic/jsx-equals-spacing": [0], + "@stylistic/jsx-first-prop-new-line": [0], + "@stylistic/jsx-indent": [0], + "@stylistic/jsx-indent-props": [0], + "@stylistic/jsx-max-props-per-line": [0], + "@stylistic/jsx-newline": [0], + "@stylistic/jsx-one-expression-per-line": [0], + "@stylistic/jsx-props-no-multi-spaces": [0], + "@stylistic/jsx-quotes": [0], + "@stylistic/jsx-tag-spacing": [0], + "@stylistic/jsx-wrap-multilines": [0], + "@stylistic/jsx/jsx-child-element-spacing": [0], + "@stylistic/jsx/jsx-closing-bracket-location": [0], + "@stylistic/jsx/jsx-closing-tag-location": [0], + "@stylistic/jsx/jsx-curly-newline": [0], + "@stylistic/jsx/jsx-curly-spacing": [0], + "@stylistic/jsx/jsx-equals-spacing": [0], + "@stylistic/jsx/jsx-first-prop-new-line": [0], + "@stylistic/jsx/jsx-indent": [0], + "@stylistic/jsx/jsx-indent-props": [0], + "@stylistic/jsx/jsx-max-props-per-line": [0], + "@stylistic/key-spacing": [0], + "@stylistic/keyword-spacing": [0], + "@stylistic/linebreak-style": [0], + "@stylistic/lines-around-comment": [0], + "@stylistic/max-len": [0], + "@stylistic/max-statements-per-line": [0], + "@stylistic/member-delimiter-style": [0], + "@stylistic/multiline-ternary": [0], + "@stylistic/new-parens": [0], + "@stylistic/newline-per-chained-call": [0], + "@stylistic/no-confusing-arrow": [0], + "@stylistic/no-extra-parens": [0], + "@stylistic/no-extra-semi": [0], + "@stylistic/no-floating-decimal": [0], + "@stylistic/no-mixed-operators": [0], + "@stylistic/no-mixed-spaces-and-tabs": [0], + "@stylistic/no-multi-spaces": [0], + "@stylistic/no-multiple-empty-lines": [0], + "@stylistic/no-tabs": [0], + "@stylistic/no-trailing-spaces": [0], + "@stylistic/no-whitespace-before-property": [0], + "@stylistic/nonblock-statement-body-position": [0], + "@stylistic/object-curly-newline": [0], + "@stylistic/object-curly-spacing": [0], + "@stylistic/object-property-newline": [0], + "@stylistic/one-var-declaration-per-line": [0], + "@stylistic/operator-linebreak": [0], + "@stylistic/padded-blocks": [0], + "@stylistic/quote-props": [0], + "@stylistic/quotes": [0], + "@stylistic/rest-spread-spacing": [0], + "@stylistic/semi": [0], + "@stylistic/semi-spacing": [0], + "@stylistic/semi-style": [0], + "@stylistic/space-before-blocks": [0], + "@stylistic/space-before-function-paren": [0], + "@stylistic/space-in-parens": [0], + "@stylistic/space-infix-ops": [0], + "@stylistic/space-unary-ops": [0], + "@stylistic/switch-colon-spacing": [0], + "@stylistic/template-curly-spacing": [0], + "@stylistic/template-tag-spacing": [0], + "@stylistic/ts/block-spacing": [0], + "@stylistic/ts/brace-style": [0], + "@stylistic/ts/comma-dangle": [0], + "@stylistic/ts/comma-spacing": [0], + "@stylistic/ts/func-call-spacing": [0], + "@stylistic/ts/function-call-spacing": [0], + "@stylistic/ts/indent": [0], + "@stylistic/ts/key-spacing": [0], + "@stylistic/ts/keyword-spacing": [0], + "@stylistic/ts/lines-around-comment": [0], + "@stylistic/ts/member-delimiter-style": [0], + "@stylistic/ts/no-extra-parens": [0], + "@stylistic/ts/no-extra-semi": [0], + "@stylistic/ts/object-curly-spacing": [0], + "@stylistic/ts/quotes": [0], + "@stylistic/ts/semi": [0], + "@stylistic/ts/space-before-blocks": [0], + "@stylistic/ts/space-before-function-paren": [0], + "@stylistic/ts/space-infix-ops": [0], + "@stylistic/ts/type-annotation-spacing": [0], + "@stylistic/type-annotation-spacing": [0], + "@stylistic/type-generic-spacing": [0], + "@stylistic/type-named-tuple-spacing": [0], + "@stylistic/wrap-iife": [0], + "@stylistic/wrap-regex": [0], + "@stylistic/yield-star-spacing": [0], + "@typescript-eslint/ban-ts-comment": [2], + "@typescript-eslint/block-spacing": [0], + "@typescript-eslint/brace-style": [0], + "@typescript-eslint/comma-dangle": [0], + "@typescript-eslint/comma-spacing": [0], + "@typescript-eslint/func-call-spacing": [0], + "@typescript-eslint/indent": [0], + "@typescript-eslint/key-spacing": [0], + "@typescript-eslint/keyword-spacing": [0], + "@typescript-eslint/lines-around-comment": [0], + "@typescript-eslint/member-delimiter-style": [0], + "@typescript-eslint/no-array-constructor": [2], + "@typescript-eslint/no-deprecated": [2], + "@typescript-eslint/no-duplicate-enum-values": [2], + "@typescript-eslint/no-empty-object-type": [2], + "@typescript-eslint/no-explicit-any": [2], + "@typescript-eslint/no-extra-non-null-assertion": [2], + "@typescript-eslint/no-extra-parens": [0], + "@typescript-eslint/no-extra-semi": [0], + "@typescript-eslint/no-floating-promises": [ + 2, + { + "allowForKnownSafeCalls": [ + { + "from": "package", + "name": [ + "invalidateQueries", + "refetchQueries", + "cancelQueries", + "resetQueries", + "prefetchQuery", + "prefetchInfiniteQuery" + ], + "package": "@tanstack/query-core" + } + ] + } + ], + "@typescript-eslint/no-misused-new": [2], + "@typescript-eslint/no-namespace": [2], + "@typescript-eslint/no-non-null-asserted-optional-chain": [2], + "@typescript-eslint/no-require-imports": [2], + "@typescript-eslint/no-this-alias": [2], + "@typescript-eslint/no-unnecessary-type-constraint": [2], + "@typescript-eslint/no-unsafe-declaration-merging": [2], + "@typescript-eslint/no-unsafe-function-type": [2], + "@typescript-eslint/no-unused-expressions": [ + 2, + { "allowShortCircuit": true, "allowTaggedTemplates": false, "allowTernary": true } + ], + "@typescript-eslint/no-unused-vars": [0], + "@typescript-eslint/no-wrapper-object-types": [2], + "@typescript-eslint/object-curly-spacing": [0], + "@typescript-eslint/prefer-as-const": [2], + "@typescript-eslint/prefer-namespace-keyword": [2], + "@typescript-eslint/quotes": [0], + "@typescript-eslint/semi": [0], + "@typescript-eslint/space-before-blocks": [0], + "@typescript-eslint/space-before-function-paren": [0], + "@typescript-eslint/space-infix-ops": [0], + "@typescript-eslint/triple-slash-reference": [2], + "@typescript-eslint/type-annotation-spacing": [0], + "array-bracket-newline": [0], + "array-bracket-spacing": [0], + "array-element-newline": [0], + "arrow-parens": [0], + "arrow-spacing": [0], + "babel/object-curly-spacing": [0], + "babel/quotes": [0], + "babel/semi": [0], + "block-spacing": [0], + "brace-style": [0], + "comma-dangle": [0], + "comma-spacing": [0], + "comma-style": [0], + "computed-property-spacing": [0], + "constructor-super": [0], + "curly": [0, "all"], + "dot-location": [0], + "eol-last": [0], + "flowtype/boolean-style": [0], + "flowtype/delimiter-dangle": [0], + "flowtype/generic-spacing": [0], + "flowtype/object-type-curly-spacing": [0], + "flowtype/object-type-delimiter": [0], + "flowtype/quotes": [0], + "flowtype/semi": [0], + "flowtype/space-after-type-colon": [0], + "flowtype/space-before-generic-bracket": [0], + "flowtype/space-before-type-colon": [0], + "flowtype/union-intersection-spacing": [0], + "for-direction": [2], + "func-call-spacing": [0], + "function-call-argument-newline": [0], + "function-paren-newline": [0], + "generator-star": [0], + "generator-star-spacing": [0], + "getter-return": [0, { "allowImplicit": false }], + "implicit-arrow-linebreak": [0], + "indent": [0], + "indent-legacy": [0], + "jsx-quotes": [0], + "key-spacing": [0], + "keyword-spacing": [0], + "linebreak-style": [0], + "lines-around-comment": [0], + "local/button-primitive": [2], + "local/focus-safe-disabled": [2], + "local/glyph-link": [2], + "local/input-primitive": [2], + "local/jsx-comment-text": [2], + "local/literal-a11y-text": [2], + "local/locale-format": [2], + "local/sortable-a11y": [2], + "local/surface-role": [2], + "max-len": [0], + "max-statements-per-line": [0], + "multiline-ternary": [0], + "new-parens": [0], + "newline-per-chained-call": [0], + "no-array-constructor": [0], + "no-arrow-condition": [0], + "no-async-promise-executor": [2], + "no-case-declarations": [2], + "no-class-assign": [0], + "no-comma-dangle": [0], + "no-compare-neg-zero": [2], + "no-cond-assign": [2, "except-parens"], + "no-confusing-arrow": [0], + "no-console": [2, {}], + "no-const-assign": [0], + "no-constant-binary-expression": [2], + "no-constant-condition": [2, { "checkLoops": "allExceptWhileTrue" }], + "no-control-regex": [2], + "no-debugger": [2], + "no-delete-var": [2], + "no-dupe-args": [0], + "no-dupe-class-members": [0], + "no-dupe-else-if": [2], + "no-dupe-keys": [0], + "no-duplicate-case": [2], + "no-empty": [2, { "allowEmptyCatch": false }], + "no-empty-character-class": [2], + "no-empty-pattern": [2, { "allowObjectPatternsAsParameters": false }], + "no-empty-static-block": [2], + "no-ex-assign": [2], + "no-extra-boolean-cast": [2, {}], + "no-extra-parens": [0], + "no-extra-semi": [0], + "no-fallthrough": [ + 2, + { "allowEmptyCase": false, "reportUnusedFallthroughComment": false } + ], + "no-floating-decimal": [0], + "no-func-assign": [0], + "no-global-assign": [2, { "exceptions": [] }], + "no-import-assign": [0], + "no-invalid-regexp": [2, {}], + "no-irregular-whitespace": [ + 2, + { + "skipComments": false, + "skipJSXText": false, + "skipRegExps": false, + "skipStrings": true, + "skipTemplates": false + } + ], + "no-loss-of-precision": [2], + "no-misleading-character-class": [2, { "allowEscape": false }], + "no-mixed-operators": [0], + "no-mixed-spaces-and-tabs": [0], + "no-multi-spaces": [0], + "no-multiple-empty-lines": [0], + "no-new-native-nonconstructor": [0], + "no-new-symbol": [0], + "no-nonoctal-decimal-escape": [2], + "no-obj-calls": [0], + "no-octal": [2], + "no-prototype-builtins": [2], + "no-redeclare": [0, { "builtinGlobals": true }], + "no-regex-spaces": [2], + "no-reserved-keys": [0], + "no-restricted-globals": [ + 2, + { + "message": "Use the logged request layer: `fetch` belongs to src/lib/http.ts only (E22 S4 — a request outside it never reaches the client log).", + "name": "fetch" + }, + { + "message": "Use the logged request layer: `XMLHttpRequest` belongs to src/lib/api/core.ts only (E22 S4 — a request outside it never reaches the client log).", + "name": "XMLHttpRequest" + }, + { + "message": "Use the logged request layer: `WebSocket` belongs to src/lib/messagesSocket.ts only (E22 S4 — a request outside it never reaches the client log).", + "name": "WebSocket" + }, + { + "message": "No `EventSource` without a logged owner module (E22 S4).", + "name": "EventSource" + } + ], + "no-restricted-properties": [ + 2, + { + "message": "Use lib/http.ts `send` (E22 S4).", + "object": "window", + "property": "fetch" + }, + { + "message": "Use lib/http.ts `send` (E22 S4).", + "object": "globalThis", + "property": "fetch" + }, + { + "message": "Use lib/api/core.ts `beacon` (E22 S4).", + "object": "navigator", + "property": "sendBeacon" + } + ], + "no-restricted-syntax": [ + 2, + { + "message": "fetchNextPage() cancels and re-sends a page already loading: call fetchNextPage(NEXT_PAGE) (lib/nextPage.ts).", + "selector": "CallExpression[arguments.length=0]:matches([callee.name='fetchNextPage'], [callee.property.name='fetchNextPage'])" + }, + { + "message": "`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).", + "selector": "Literal[value=/(^|\\s)enabled:hover:/]" + }, + { + "message": "`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).", + "selector": "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]" + }, + { + "message": "a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant).", + "selector": "TemplateLiteral > TemplateElement:not(:first-child)[value.raw=/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/]" + }, + { + "message": "No native