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