From a42f346a32da119d158eb725a0556782d876d745 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 4 Oct 2026 22:09:38 +0200 Subject: [PATCH] test(gates): the snapshot holds a config per scope, not a path per file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gates.snapshot.json listed ~800 file paths under their config, so every new or deleted frontend file regenerated it (31% of commits, E34 S2) and the regen diff buried the gate changes it exists to show. Now a scope is named by path (scopeOf: src, src tests, ui, lib tests, …), the snapshot holds one effective config per scope (2345 → 1456 lines), and a new test fails when a file's config is not its scope's. Not narrowed: a new file in a known place changes nothing (it gets its scope's config, checked), a glob that moves one file into another config fails (seen: an override on Feed.tsx → "src: src/components/Feed.tsx"), a scope whose rules change is a snapshot diff, and a file no config lints is still a register miss. --- CLAUDE.md | 2 +- docs/gates.md | 14 +- frontend/src/lib/gates.contract.test.ts | 121 +- frontend/src/lib/gates.snapshot.json | 3324 +++++++++-------------- 4 files changed, 1341 insertions(+), 2120 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 83b17e98..4dcd5f86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ green **before** any merge, release, or handing work to review. - Full gate: `bash scripts/check.sh` (`lint` for the static lanes only); the private ops dispatcher's `siftlode check` runs the same script. Single lanes: [`docs/development.md`](docs/development.md) § The gate. - **There is no CI** (owner, 2026-10-04): this gate, run here, is the only one — green before every hand-over, and `release` refuses to merge without it. -- **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 gate/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). +- **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 scope and the gate/lane lines are pinned in `frontend/src/lib/gates.snapshot.json`: 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 67de78a8..cbe432d5 100644 --- a/docs/gates.md +++ b/docs/gates.md @@ -13,16 +13,18 @@ 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. -**The snapshot.** ESLint's effective config for every code file `eslint .` reaches, and the lines -that run the lanes (`scripts/check.sh`, `backend/docker-compose.test.yml`, +**The snapshot.** ESLint's effective config for every scope `eslint .` reaches, and the lines that +run the lanes (`scripts/check.sh`, `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 gate -flag. So a change to a gate is always a diff of that file in review, never a shape a detector failed +ANY difference — a rule, an option, a new ignore, a gate flag. A scope is named by path (`scopeOf` in +the test: `src`, `src tests`, `ui`, …) and every file in it must get the same config, so a glob that +moves one file into another config fails too, while a new file in a known place changes nothing +(until E35 the snapshot listed every file, and 31% of commits regenerated it). 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. +(from `frontend/`), and read its diff as a gate change. A new kind of file (a new config, a new place) +needs a line in `scopeOf`. **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 diff --git a/frontend/src/lib/gates.contract.test.ts b/frontend/src/lib/gates.contract.test.ts index 6996b029..5267ba69 100644 --- a/frontend/src/lib/gates.contract.test.ts +++ b/frontend/src/lib/gates.contract.test.ts @@ -101,20 +101,23 @@ function restrictedEntries(entry: Linter.RuleEntry | undefined): Set { // --- 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 gate line. A comparison -// against everything has no shape to miss.) +// COMMITTED SNAPSHOT (E32 review): ESLint's effective config per scope, 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 gate line. A comparison against everything has no +// shape to miss.) +// +// A scope is named by PATH (`scopeOf`), and every file of a scope must get the same effective config. +// So a new file in a known place changes nothing (until E35 the snapshot listed every file, and 31% of +// commits regenerated it), while a glob that moves a file into another config splits its scope and +// fails below, and a scope whose rules change is a snapshot diff. // // 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). +// then read the diff like any gate change. A new kind of file (a new config or a new place) needs a +// line in `scopeOf` and, when it narrows anything, its register rows. const SNAPSHOT = "frontend/src/lib/gates.snapshot.json"; -const NOT_LINTED = "(not linted)"; type EslintConfig = { rules: Record; linterOptions: Record; @@ -123,10 +126,24 @@ type EslintConfig = { settings: unknown; }; type Snapshot = { - eslint: { configs: Record; files: Record }; + eslint: Record; lanes: Record; }; +/** The register scope a code file belongs to, by its path (relative to frontend/). */ +function scopeOf(file: string): string { + if (!file.includes("/")) return /[.][mc]?js$/.test(file) ? "js configs" : "ts configs"; + if (file.startsWith("e2e/")) return "e2e"; + if (file.startsWith("src/design-lab/")) return "design-lab"; + // the network owners (NETWORK_OWNERS in eslint.config.js) + if (file === "src/lib/http.ts") return "lib/http.ts"; + if (file === "src/lib/api/core.ts") return "lib/api/core.ts"; + const test = /[.]test[.]tsx?$/.test(file); + if (file.startsWith("src/components/ui/")) return test ? "ui tests" : "ui"; + if (file.startsWith("src/lib/")) return test ? "lib tests" : "src/lib"; + return test ? "src tests" : "src"; +} + const sortKeys = (v: unknown): unknown => Array.isArray(v) ? v.map(sortKeys) @@ -173,7 +190,7 @@ function normalize(cfg: Linter.Config): EslintConfig { // 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)`. +// Every other file is asked for its config, so a file a new ignore drops is reported as not linted. const WALK_SKIP = new Set([ "node_modules", "dist", @@ -192,7 +209,7 @@ function codeFiles(dir = ""): string[] { // 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. +// is not repeated a dozen times. In memory it is always whole. type Stored = Omit & { rules: Record; notApplied?: string[]; @@ -225,15 +242,22 @@ function fromStored(stored: Record): Record { - const s = JSON.parse(read(SNAPSHOT)) as Snapshot & { - eslint: { configs: Record }; + const s = JSON.parse(read(SNAPSHOT)) as Omit & { + eslint: Record; }; - return canonical({ ...s, eslint: { ...s.eslint, configs: fromStored(s.eslint.configs) } }); + return canonical({ ...s, eslint: fromStored(s.eslint) }); }; -async function eslintActual(names: Snapshot["eslint"]): Promise { +type EslintActual = { + configs: Record; + /** the files no config lints */ + notLinted: string[]; + /** `scope: file` for each file whose config is not its scope's (the scope's majority) */ + strays: string[]; +}; +async function eslintActual(): Promise { const eslint = new ESLint({ cwd: FRONTEND }); - const groups = new Map(); + const byScope = new Map>(); const notLinted: string[] = []; for (const file of codeFiles().sort()) { const raw = (await eslint.calculateConfigForFile(file)) as Linter.Config | undefined; @@ -243,35 +267,22 @@ async function eslintActual(names: Snapshot["eslint"]): Promise(); const group = groups.get(key) ?? { config, files: [] }; group.files.push(file); groups.set(key, group); + byScope.set(scope, groups); } - // 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); + const strays: string[] = []; + for (const [scope, groups] of byScope) { + const [first, ...rest] = [...groups.values()].sort((a, b) => b.files.length - a.files.length); + configs[scope] = first!.config; + for (const g of rest) strays.push(...g.files.map((f) => `${scope}: ${f}`)); } - 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 }); + return { configs: canonical(configs), notLinted, strays }; } // the lines that run each lane, comments and blank lines aside @@ -295,10 +306,10 @@ const lanes = () => ({ "frontend/.prettierignore": meaningful("frontend/.prettierignore"), }); -let actual: Promise | undefined; -const current = () => - (actual ??= (async () => - canonical({ eslint: await eslintActual(committed().eslint), lanes: lanes() }))()); +let actual: Promise | undefined; +const linted = () => (actual ??= eslintActual()); +const current = async (): Promise => + canonical({ eslint: (await linted()).configs, lanes: lanes() }); // --- the register, read off the snapshot ------------------------------------------------------------ @@ -310,11 +321,8 @@ const ignorable = (rule: string, all: Record) => 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`, - ]); +function eslintNarrowings({ configs, notLinted }: EslintActual): [string, string][] { + const found: [string, string][] = notLinted.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)) { @@ -347,22 +355,29 @@ function eslintNarrowings({ configs, files }: Snapshot["eslint"]): [string, stri } describe("the gate snapshot (gates.snapshot.json)", () => { - it("holds the effective ESLint config of every file and the lines that run each lane", async () => { + it("holds the effective ESLint config of every scope 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) } }; + const stored = { ...now, eslint: toStored(now.eslint) }; writeFileSync(path, await format(JSON.stringify(sortKeys(stored)), options)); return; } expect(now).toEqual(committed()); }, 120_000); + + // A glob that moves one file into another config (a product file into the test config) leaves its + // scope's config as it was, so the snapshot alone would not see it: the file now differs from the + // rest of its scope, and that is what fails here. + it("gives every file of a scope the same config", async () => { + expect((await linted()).strays).toEqual([]); + }, 120_000); }); describe("the gate register (docs/gates.md)", () => { it("lists every ESLint rule narrowed, by scope", async () => { - expect(missingFrom(eslintNarrowings((await current()).eslint))).toEqual([]); + expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]); }, 120_000); it("keeps --max-warnings 0, which is what makes a warn-level rule fail", () => { diff --git a/frontend/src/lib/gates.snapshot.json b/frontend/src/lib/gates.snapshot.json index c96525f4..259589c9 100644 --- a/frontend/src/lib/gates.snapshot.json +++ b/frontend/src/lib/gates.snapshot.json @@ -1,2075 +1,1279 @@ { "eslint": { - "configs": { - "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 + "design-lab": { + "languageOptions": { + "ecmaVersion": 2022, + "globals": "1169 names, sha1 aee621727a03", + "parser": "typescript-eslint/parser", + "parserOptions": { "projectService": true, "tsconfigRootDir": "." }, + "sourceType": "module" }, - "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/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/gating", - "react-hooks/globals", - "react-hooks/hooks", - "react-hooks/immutability", - "react-hooks/incompatible-library", - "react-hooks/invariant", - "react-hooks/memo-dependencies", - "react-hooks/no-deriving-state-in-effects", - "react-hooks/preserve-manual-memoization", - "react-hooks/purity", - "react-hooks/refs", - "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/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/gating", - "react-hooks/globals", - "react-hooks/hooks", - "react-hooks/immutability", - "react-hooks/incompatible-library", - "react-hooks/invariant", - "react-hooks/memo-dependencies", - "react-hooks/no-deriving-state-in-effects", - "react-hooks/preserve-manual-memoization", - "react-hooks/purity", - "react-hooks/refs", - "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/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": "No `WebSocket` without a logged owner module (E22 S4).", - "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": "No `WebSocket` without a logged owner module (E22 S4).", - "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": "No `WebSocket` without a logged owner module (E22 S4).", - "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 - }, - "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/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": "No `WebSocket` without a logged owner module (E22 S4).", - "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 : use ui/SegmentedToggle for a short fixed set, ui/FilterSelect for a longer one or a user's own list.", + "selector": "JSXOpeningElement[name.name='select']" + }, + { + "message": "px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).", + "selector": "Literal[value=/text-\\[[\\d.]+px\\]/]" + }, + { + "message": "px-frozen font size doesn't scale with --font-scale: use text-2xs/text-3xs or text-[Nrem], not text-[Npx] (R10 S2).", + "selector": "TemplateElement[value.raw=/text-\\[[\\d.]+px\\]/]" + }, + { + "message": "px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60) or rem (minmax(9.375rem,1fr)), not w-[Npx] or minmax(Npx,…).", + "selector": "Literal[value=/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]|minmax\\([\\d.]+px,/]" + }, + { + "message": "px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60) or rem (minmax(9.375rem,1fr)), not w-[Npx] or minmax(Npx,…).", + "selector": "TemplateElement[value.raw=/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]|minmax\\([\\d.]+px,/]" + }, + { + "message": "hover by a colour swap: use the B3 layer (`state-layer`, Pressable `layer`, `state-filled` on a fill), not hover:bg-card / bg-border / opacity-N.", + "selector": "Literal[value=/(^|[\\s:])hover:(bg-(card|border|surface|muted)(?![\\w-])|bg-\\(--(hover|press)\\)|opacity-)/]" + }, + { + "message": "hover by a colour swap: use the B3 layer (`state-layer`, Pressable `layer`, `state-filled` on a fill), not hover:bg-card / bg-border / opacity-N.", + "selector": "TemplateElement[value.raw=/(^|[\\s:])hover:(bg-(card|border|surface|muted)(?![\\w-])|bg-\\(--(hover|press)\\)|opacity-)/]" + }, + { + "message": "a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.", + "selector": "Literal[value=/(^|[\\s:])(text|bg|border|fill|stroke|from|via|to|ring|outline|decoration|divide|caret|shadow)-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\\d/]" + }, + { + "message": "a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.", + "selector": "TemplateElement[value.raw=/(^|[\\s:])(text|bg|border|fill|stroke|from|via|to|ring|outline|decoration|divide|caret|shadow)-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\\d/]" + } + ], + "no-self-assign": [2, { "props": true }], + "no-setter-return": [0], + "no-shadow-restricted-names": [2, { "reportGlobalThis": false }], + "no-space-before-semi": [0], + "no-spaced-func": [0], + "no-sparse-arrays": [2], + "no-tabs": [0], + "no-this-before-super": [0], + "no-trailing-spaces": [0], + "no-undef": [0, { "typeof": false }], + "no-unexpected-multiline": [0], + "no-unreachable": [0], + "no-unsafe-finally": [2], + "no-unsafe-negation": [0, { "enforceForOrderingRelations": false }], + "no-unsafe-optional-chaining": [2, { "disallowArithmeticOperators": false }], + "no-unused-expressions": [ + 0, + { + "allowShortCircuit": false, + "allowTaggedTemplates": false, + "allowTernary": false, + "enforceForJSX": false, + "ignoreDirectives": false + } + ], + "no-unused-labels": [2], + "no-unused-private-class-members": [2], + "no-unused-vars": [0], + "no-useless-backreference": [2], + "no-useless-catch": [2], + "no-useless-escape": [2, { "allowRegexCharacters": [] }], + "no-var": [2], + "no-whitespace-before-property": [0], + "no-with": [0], + "no-wrap-func": [0], + "nonblock-statement-body-position": [0], + "object-curly-newline": [0], + "object-curly-spacing": [0], + "object-property-newline": [0], + "one-var-declaration-per-line": [0], + "operator-linebreak": [0], + "padded-blocks": [0], + "prefer-const": [2, { "destructuring": "any", "ignoreReadBeforeAssign": false }], + "prefer-rest-params": [2], + "prefer-spread": [2], + "quote-props": [0], + "quotes": [0], + "react-hooks/capitalized-calls": [2], + "react-hooks/component-hook-factories": [2], + "react-hooks/config": [2], + "react-hooks/error-boundaries": [2], + "react-hooks/exhaustive-deps": [1], + "react-hooks/gating": [2], + "react-hooks/globals": [2], + "react-hooks/hooks": [2], + "react-hooks/immutability": [2], + "react-hooks/incompatible-library": [1], + "react-hooks/invariant": [2], + "react-hooks/memo-dependencies": [2], + "react-hooks/no-deriving-state-in-effects": [2], + "react-hooks/preserve-manual-memoization": [2], + "react-hooks/purity": [2], + "react-hooks/refs": [2], + "react-hooks/rules-of-hooks": [2], + "react-hooks/set-state-in-effect": [2], + "react-hooks/set-state-in-render": [2], + "react-hooks/static-components": [2], + "react-hooks/syntax": [2], + "react-hooks/todo": [2], + "react-hooks/unsupported-syntax": [1], + "react-hooks/use-memo": [2], + "react-hooks/void-use-memo": [2], + "react-refresh/only-export-components": [1, { "allowConstantExport": true }], + "react/jsx-child-element-spacing": [0], + "react/jsx-closing-bracket-location": [0], + "react/jsx-closing-tag-location": [0], + "react/jsx-curly-newline": [0], + "react/jsx-curly-spacing": [0], + "react/jsx-equals-spacing": [0], + "react/jsx-first-prop-new-line": [0], + "react/jsx-indent": [0], + "react/jsx-indent-props": [0], + "react/jsx-max-props-per-line": [0], + "react/jsx-newline": [0], + "react/jsx-one-expression-per-line": [0], + "react/jsx-props-no-multi-spaces": [0], + "react/jsx-space-before-closing": [0], + "react/jsx-tag-spacing": [0], + "react/jsx-wrap-multilines": [0], + "require-yield": [2], + "rest-spread-spacing": [0], + "semi": [0], + "semi-spacing": [0], + "semi-style": [0], + "space-after-function-name": [0], + "space-after-keywords": [0], + "space-before-blocks": [0], + "space-before-function-paren": [0], + "space-before-function-parentheses": [0], + "space-before-keywords": [0], + "space-in-brackets": [0], + "space-in-parens": [0], + "space-infix-ops": [0], + "space-return-throw-case": [0], + "space-unary-ops": [0], + "space-unary-word-ops": [0], + "standard/array-bracket-even-spacing": [0], + "standard/computed-property-even-spacing": [0], + "standard/object-curly-even-spacing": [0], + "switch-colon-spacing": [0], + "template-curly-spacing": [0], + "template-tag-spacing": [0], + "unicorn/empty-brace-spaces": [0], + "unicorn/no-nested-ternary": [0], + "unicorn/number-literal-case": [0], + "unicorn/template-indent": [0], + "use-isnan": [2, { "enforceForIndexOf": false, "enforceForSwitchCase": true }], + "valid-typeof": [2, { "requireStringLiterals": false }], + "vue/array-bracket-newline": [0], + "vue/array-bracket-spacing": [0], + "vue/array-element-newline": [0], + "vue/arrow-spacing": [0], + "vue/block-spacing": [0], + "vue/block-tag-newline": [0], + "vue/brace-style": [0], + "vue/comma-dangle": [0], + "vue/comma-spacing": [0], + "vue/comma-style": [0], + "vue/dot-location": [0], + "vue/func-call-spacing": [0], + "vue/html-closing-bracket-newline": [0], + "vue/html-closing-bracket-spacing": [0], + "vue/html-end-tags": [0], + "vue/html-indent": [0], + "vue/html-quotes": [0], + "vue/html-self-closing": [0], + "vue/key-spacing": [0], + "vue/keyword-spacing": [0], + "vue/max-attributes-per-line": [0], + "vue/max-len": [0], + "vue/multiline-html-element-content-newline": [0], + "vue/multiline-ternary": [0], + "vue/mustache-interpolation-spacing": [0], + "vue/no-extra-parens": [0], + "vue/no-multi-spaces": [0], + "vue/no-spaces-around-equal-signs-in-attribute": [0], + "vue/object-curly-newline": [0], + "vue/object-curly-spacing": [0], + "vue/object-property-newline": [0], + "vue/operator-linebreak": [0], + "vue/quote-props": [0], + "vue/script-indent": [0], + "vue/singleline-html-element-content-newline": [0], + "vue/space-in-parens": [0], + "vue/space-infix-ops": [0], + "vue/space-unary-ops": [0], + "vue/template-curly-spacing": [0], + "wrap-iife": [0], + "wrap-regex": [0], + "yield-star-spacing": [0] + }, + "settings": null + }, + "src 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" ], - "ui": [ - "src/components/ui/ActionMenu.tsx", - "src/components/ui/AnchoredPopover.tsx", - "src/components/ui/Avatar.tsx", - "src/components/ui/BackToTop.tsx", - "src/components/ui/Badge.tsx", - "src/components/ui/Banner.tsx", - "src/components/ui/Button.tsx", - "src/components/ui/Carousel.tsx", - "src/components/ui/Chip.tsx", - "src/components/ui/ClampedText.tsx", - "src/components/ui/ColumnSortControl.tsx", - "src/components/ui/Combobox.tsx", - "src/components/ui/CommandPalette.tsx", - "src/components/ui/ContextMenu.tsx", - "src/components/ui/CountBadge.tsx", - "src/components/ui/DataTable.tsx", - "src/components/ui/DraftSaveBar.tsx", - "src/components/ui/DragHandle.tsx", - "src/components/ui/FilterChipGroup.tsx", - "src/components/ui/FilterFold.tsx", - "src/components/ui/FilterSelect.tsx", - "src/components/ui/FocusSafeButton.tsx", - "src/components/ui/Headings.tsx", - "src/components/ui/HiddenFilterCue.tsx", - "src/components/ui/IconButton.tsx", - "src/components/ui/InlineNotice.tsx", - "src/components/ui/Input.tsx", - "src/components/ui/ItemCount.tsx", - "src/components/ui/KeyboardHelp.tsx", - "src/components/ui/LinkButton.tsx", - "src/components/ui/MatchToggle.tsx", - "src/components/ui/MediaCard.tsx", - "src/components/ui/MediaPreview.tsx", - "src/components/ui/MenuCheck.tsx", - "src/components/ui/Modal.tsx", - "src/components/ui/MoreButton.tsx", - "src/components/ui/NativeControls.tsx", - "src/components/ui/NavGrid.tsx", - "src/components/ui/NavRail.tsx", - "src/components/ui/OrderButtons.tsx", - "src/components/ui/Overlay.tsx", - "src/components/ui/PageMenu.tsx", - "src/components/ui/PageScroller.tsx", - "src/components/ui/PageShell.tsx", - "src/components/ui/Pager.tsx", - "src/components/ui/PanelGroup.tsx", - "src/components/ui/PanelGroups.tsx", - "src/components/ui/Pressable.tsx", - "src/components/ui/PriorityStepper.tsx", - "src/components/ui/QueryState.tsx", - "src/components/ui/RelativeTime.tsx", - "src/components/ui/SegmentedToggle.tsx", - "src/components/ui/SelectTrigger.tsx", - "src/components/ui/SidePanel.tsx", - "src/components/ui/Skeleton.tsx", - "src/components/ui/SkipLink.tsx", - "src/components/ui/SortControl.tsx", - "src/components/ui/StatTiles.tsx", - "src/components/ui/Tabs.tsx", - "src/components/ui/Toaster.tsx", - "src/components/ui/Tooltip.tsx", - "src/components/ui/UndoToolbar.tsx", - "src/components/ui/VirtualGrid.tsx", - "src/components/ui/anchoredPopoverHooks.ts", - "src/components/ui/backToTopState.ts", - "src/components/ui/buttonStyles.ts", - "src/components/ui/chipStyles.ts", - "src/components/ui/dialogFocus.ts", - "src/components/ui/fieldStyles.ts", - "src/components/ui/form.tsx", - "src/components/ui/gridColumns.ts", - "src/components/ui/landmarks.ts", - "src/components/ui/mediaCardStyles.ts", - "src/components/ui/menuGroups.ts", - "src/components/ui/overlayOrder.ts", - "src/components/ui/pageHeading.ts", - "src/components/ui/pageMenuItems.ts", - "src/components/ui/pageMenuSlot.ts", - "src/components/ui/pageToolbarSlot.ts", - "src/components/ui/pagerContent.ts", - "src/components/ui/progressiveRows.ts", - "src/components/ui/readAhead.ts", - "src/components/ui/reveal.ts", - "src/components/ui/scrollFold.ts", - "src/components/ui/sortableA11y.ts", - "src/components/ui/stickyTop.ts", - "src/components/ui/surfaces.ts", - "src/components/ui/tabPanel.ts", - "src/components/ui/tableAlign.ts", - "src/components/ui/tableHeader.tsx", - "src/components/ui/toastLevels.ts", - "src/components/ui/useContextMenu.ts", - "src/components/ui/useMediaPreview.ts", - "src/components/ui/useOrderItems.tsx", - "src/components/ui/useReturnFocus.ts" + "rules": { + "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": "No `WebSocket` without a logged owner module (E22 S4).", + "name": "WebSocket" + }, + { + "message": "No `EventSource` without a logged owner module (E22 S4).", + "name": "EventSource" + } + ], + "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 + }, + "src/lib": { + "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" ], - "ui tests": [ - "src/components/ui/ActionMenu.dom.test.tsx", - "src/components/ui/AnchoredPopover.dom.test.tsx", - "src/components/ui/AnchoredPopover.returnFocus.dom.test.tsx", - "src/components/ui/Avatar.dom.test.tsx", - "src/components/ui/BackToTop.dom.test.tsx", - "src/components/ui/Banner.dom.test.tsx", - "src/components/ui/Button.dom.test.tsx", - "src/components/ui/Carousel.dom.test.tsx", - "src/components/ui/Chip.dom.test.tsx", - "src/components/ui/ClampedText.dom.test.tsx", - "src/components/ui/Combobox.dom.test.tsx", - "src/components/ui/CommandPalette.dom.test.tsx", - "src/components/ui/CommandPalette.ordering.dom.test.tsx", - "src/components/ui/ContextMenu.dom.test.tsx", - "src/components/ui/DataTable.fixedLayout.dom.test.tsx", - "src/components/ui/DataTable.keyboard.dom.test.tsx", - "src/components/ui/DataTable.progressive.dom.test.tsx", - "src/components/ui/FilterChipGroup.dom.test.tsx", - "src/components/ui/FilterFold.dom.test.tsx", - "src/components/ui/FilterSelect.dom.test.tsx", - "src/components/ui/FocusSafeButton.select.dom.test.tsx", - "src/components/ui/HiddenFilterCue.dom.test.tsx", - "src/components/ui/IconButton.dom.test.tsx", - "src/components/ui/Input.dom.test.tsx", - "src/components/ui/ItemCount.dom.test.tsx", - "src/components/ui/KeyboardHelp.dom.test.tsx", - "src/components/ui/MatchToggle.dom.test.tsx", - "src/components/ui/Modal.dom.test.tsx", - "src/components/ui/Modal.fullscreen.dom.test.tsx", - "src/components/ui/Modal.purpose.dom.test.tsx", - "src/components/ui/Modal.strictmode.dom.test.tsx", - "src/components/ui/NavRail.dom.test.tsx", - "src/components/ui/OrderButtons.dom.test.tsx", - "src/components/ui/PageMenu.dom.test.tsx", - "src/components/ui/PageScroller.dom.test.tsx", - "src/components/ui/PageShell.dom.test.tsx", - "src/components/ui/Pager.dom.test.tsx", - "src/components/ui/PanelGroups.dom.test.tsx", - "src/components/ui/PriorityStepper.dom.test.tsx", - "src/components/ui/QueryState.dom.test.tsx", - "src/components/ui/RelativeTime.dom.test.tsx", - "src/components/ui/SegmentedToggle.dom.test.tsx", - "src/components/ui/SidePanel.drawer.dom.test.tsx", - "src/components/ui/SkipLink.dom.test.tsx", - "src/components/ui/SortControl.dom.test.tsx", - "src/components/ui/StatTiles.dom.test.tsx", - "src/components/ui/Tabs.dom.test.tsx", - "src/components/ui/Toaster.dom.test.tsx", - "src/components/ui/Toaster.hosts.test.ts", - "src/components/ui/Toaster.strictmode.dom.test.tsx", - "src/components/ui/Tooltip.layer.dom.test.tsx", - "src/components/ui/dialogFocus.dom.test.tsx", - "src/components/ui/form.dom.test.tsx", - "src/components/ui/gridColumns.test.ts", - "src/components/ui/hiddenFilterCue.contract.test.ts", - "src/components/ui/mediaRail.contract.test.ts", - "src/components/ui/menuCheck.contract.test.ts", - "src/components/ui/pageMenuScope.contract.test.ts", - "src/components/ui/pressableSelectable.contract.test.ts", - "src/components/ui/readAhead.test.ts", - "src/components/ui/scrollFold.test.ts", - "src/components/ui/sortableA11y.dom.test.tsx", - "src/components/ui/stickyTop.dom.test.tsx", - "src/components/ui/tailwindScan.contract.test.ts", - "src/components/ui/uiI18n.contract.test.ts", - "src/components/ui/useItemTriggers.dom.test.ts", - "src/components/ui/useReturnFocus.dom.test.tsx" - ] + "rules": { + "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 + }, + "ts configs": { + "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/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/gating", + "react-hooks/globals", + "react-hooks/hooks", + "react-hooks/immutability", + "react-hooks/incompatible-library", + "react-hooks/invariant", + "react-hooks/memo-dependencies", + "react-hooks/no-deriving-state-in-effects", + "react-hooks/preserve-manual-memoization", + "react-hooks/purity", + "react-hooks/refs", + "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"], + "rules": { + "@typescript-eslint/no-unused-expressions": [ + 2, + { "allowShortCircuit": false, "allowTaggedTemplates": false, "allowTernary": false } + ] + }, + "settings": null + }, + "ui": { + "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-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 + }, + "ui 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/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": "No `WebSocket` without a logged owner module (E22 S4).", + "name": "WebSocket" + }, + { + "message": "No `EventSource` without a logged owner module (E22 S4).", + "name": "EventSource" + } + ], + "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(/|$)" + } + ] + } + ], + "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 } }, "lanes": {