test(gates): hold the lint and lane configs to a committed snapshot

The register test detected narrowings by modelling config shapes, and each
review round found one more shape it did not model (a basePath global ignore,
a rule src never declares, a widened glob filed under a narrow row, a substring
check on a CI line, knip.json beside knip.jsonc). It now compares instead:
gates.snapshot.json holds ESLint's effective config for every code file and the
lines that run each lane, and any difference fails until it is regenerated.
The register is read off the snapshot's configs, which surfaced the ui/ and
lib/ test scopes the sampling never reached.

The complexity ratchet also names a wrapped function by its binding
(const onSave = useCallback(() => ...)), not as "useCallback callback".
This commit is contained in:
2026-10-04 04:47:10 +02:00
parent 12a39f5291
commit 182ec31120
6 changed files with 2725 additions and 164 deletions
+1 -1
View File
@@ -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`.
+25 -14
View File
@@ -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
+277 -137
View File
@@ -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,11 +28,16 @@ function register(): Map<string, Set<string>> {
const rows = new Map<string, Set<string>>();
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();
// 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<string>();
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<Linter.RulesRecord>;
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<string, string[]> = {
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<Block[]> =>
((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<string> {
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<string, unknown[]>;
linterOptions: Record<string, unknown>;
languageOptions: Record<string, unknown>;
plugins: string[];
settings: unknown;
};
type Snapshot = {
eslint: { configs: Record<string, EslintConfig>; files: Record<string, string[]> };
lanes: Record<string, unknown>;
};
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<string, unknown>)[k])]),
)
: v;
// through JSON, so a value compares as what the file can hold (a RegExp option is `{}` on both sides)
const canonical = <T>(v: T): T => JSON.parse(JSON.stringify(sortKeys(v) ?? null)) as T;
function normalize(cfg: Linter.Config): EslintConfig {
const lang = { ...(cfg.languageOptions ?? {}) } as Record<string, unknown>;
const parser = lang.parser as { meta?: { name?: string } } | undefined;
const parserOptions = { ...(lang.parserOptions as Record<string, unknown> | 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<string, unknown>)
.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<string, unknown>,
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<EslintConfig, "rules"> & {
rules: Record<string, unknown[]>;
notApplied?: string[];
};
function toStored(configs: Record<string, EslintConfig>): Record<string, Stored> {
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<string, Stored>): Record<string, EslintConfig> {
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<string, Stored> };
};
return canonical({ ...s, eslint: { ...s.eslint, configs: fromStored(s.eslint.configs) } });
};
async function eslintActual(names: Snapshot["eslint"]): Promise<Snapshot["eslint"]> {
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<string, { config: EslintConfig; files: string[] }>();
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) =>
// 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<string, EslintConfig> = {};
const files: Record<string, string[]> = { [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);
}
unnamed.forEach((group, i) => {
const votes = new Map<string, number>();
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<string, string> }
).scripts,
"frontend/vitest.config.ts": meaningful("frontend/vitest.config.ts"),
"frontend/.prettierignore": meaningful("frontend/.prettierignore"),
});
let actual: Promise<Snapshot> | 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<string, unknown>) =>
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]);
}
// 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 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<string>();
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);
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<string, Set<string>>(); // 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<string, string> };
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 ----------------------------------------------------------------------------
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
boundNamesIn,
complexityOffenders,
lineCounts,
MAX_COMPLEXITY,
@@ -134,3 +135,15 @@ describe("function complexity", async () => {
expect(Object.keys(COMPLEX).filter((fn) => !(fn in actual))).toEqual([]);
});
});
describe("function names (the ratchet's keys)", () => {
it("names an unnamed function by what it is bound to, through a wrapping call", () => {
const code = [
"const onSave = useCallback(() => 1);",
"useEffect(() => {});",
"const o = { k: function () {} };",
"const v = useMemo(() => g(() => 2));",
].join(" ");
expect(boundNamesIn(code)).toEqual(["onSave", "useEffect callback", "k", "v", "g callback"]);
});
});
+31 -5
View File
@@ -1,6 +1,6 @@
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { ESLint, type Rule } from "eslint";
import { ESLint, Linter, type Rule } from "eslint";
import tseslint from "typescript-eslint";
// Measures the shipped frontend source for the size/complexity ratchet (E32): lines per file, and the
@@ -30,10 +30,11 @@ export function lineCounts(files: string[]): Record<string, number> {
/** `file::function` → its complexity, for every function over MAX_COMPLEXITY. Keyed by NAME, not by
* rank in the file: a ranked list let one function grow while another in the same file shrank by as
* much (E32 review). ESLint names declarations and methods; a function it leaves unnamed takes the
* name it is bound to (`const onSave = () => …`, `{ onSave: () => … }`), else `<callee> callback`
* (`useEffect callback`), else `(anonymous)` — so a ranked key cannot creep back in through the
* arrows (round 2). A name taken twice in a file gets ` 2`, ` 3` in source order. A bare rule run (no type info, no other rules), so it costs a couple of
* seconds, not the full lint. */
* name it is bound to (`const onSave = () => …`, `{ onSave: () => … }`), through a wrapping call
* (`const onSave = useCallback(() => …)`, round 3), else `<callee> callback` (`useEffect
* callback`), else `(anonymous)`. A name taken twice in a file gets ` 2`, ` 3` in source order —
* left only for repeated unbound callbacks (two `useEffect`s), where the order IS the identity. A
* bare rule run (no type info, no other rules), so it costs a couple of seconds, not the full lint. */
type Node = Rule.Node;
const ident = (n: { type: string; name?: string } | null | undefined): string | undefined =>
n?.type === "Identifier" ? n.name : undefined;
@@ -47,6 +48,10 @@ function boundName(p: Node): string | undefined {
case "AssignmentExpression":
return ident(p.left.type === "MemberExpression" ? p.left.property : p.left);
case "CallExpression": {
// a wrapped function takes the wrapper's binding: `const onSave = useCallback(() => …)`
const outer = p.parent as Node | null;
const bound = outer && outer.type !== "CallExpression" ? boundName(outer) : undefined;
if (bound) return bound;
const callee = ident(p.callee.type === "MemberExpression" ? p.callee.property : p.callee);
return callee && `${callee} callback`;
}
@@ -76,6 +81,27 @@ const functionNames: Rule.RuleModule = {
},
};
/** The name `functionNames` gives each unnamed function in `code`, in source order (its own test). */
export function boundNamesIn(code: string): string[] {
return new Linter()
.verify(
code,
[
{
files: ["**/*.tsx"],
languageOptions: {
parser: tseslint.parser,
parserOptions: { ecmaFeatures: { jsx: true } },
},
plugins: { size: { rules: { "function-names": functionNames } } },
rules: { "size/function-names": "error" },
},
],
"probe.tsx",
)
.map((m) => m.message);
}
export async function complexityOffenders(): Promise<Record<string, number>> {
const eslint = new ESLint({
cwd: fileURLToPath(new URL("../../", import.meta.url)),