Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell, the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate register, with its three imports. Placement: Plex's chip builder moves into lib beside the feed's, the folder names into features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed" itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic. The floating header re-measures when one page's pills replace another's in the same commit (the claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a page with a rail adds in lib.
670 lines
30 KiB
TypeScript
670 lines
30 KiB
TypeScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { relative, resolve } 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";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
// The gate register (docs/gates.md) is held to the EFFECTIVE configuration. A narrowing that
|
|
// is not on the register fails here — a rule switched off for a scope, a weak compiler flag, a ruff
|
|
// ignore, a knip exclusion, an accepted CVE. So "the gate checks X" is a claim this test verifies,
|
|
// not one a comment makes. (The comment that claimed the React Compiler's rules "are also linted"
|
|
// stood for months while the rule that would have said so was off.)
|
|
//
|
|
// Each narrowing is a (scope, id) pair; the register must hold a table row whose first cell is the
|
|
// scope and which names the id in backticks.
|
|
|
|
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
|
const FRONTEND = `${REPO}frontend/`;
|
|
const read = (path: string) => readFileSync(REPO + path, "utf8");
|
|
|
|
/** register scope → every backticked id on its rows. */
|
|
function register(): Map<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
|
|
// 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;
|
|
}
|
|
|
|
function missingFrom(found: [scope: string, id: string][]): string[] {
|
|
const reg = register();
|
|
return found.filter(([scope, id]) => !reg.get(scope)?.has(id)).map(([s, id]) => `${s}: ${id}`);
|
|
}
|
|
|
|
const jsonc = (path: string) => {
|
|
const { config, error } = ts.parseConfigFileTextToJson(path, read(path));
|
|
if (error)
|
|
throw new Error(`${path}: ${ts.flattenDiagnosticMessageText(error.messageText, "\n")}`);
|
|
return config as Record<string, unknown>;
|
|
};
|
|
|
|
// --- ESLint ---------------------------------------------------------------------------------------
|
|
|
|
const severity = (v: Linter.RuleEntry | undefined): number => {
|
|
if (v === undefined) return -1; // not applied at all
|
|
const s = Array.isArray(v) ? v[0] : v;
|
|
return s === 2 || s === "error" ? 2 : s === 1 || s === "warn" ? 1 : 0;
|
|
};
|
|
// Formatting is Prettier's (its own lane): the rules eslint-config-prettier switches off are not a
|
|
// narrowing of anything.
|
|
const PRETTIER_OFF = new Set(
|
|
Object.entries(prettierConfig.rules ?? {})
|
|
.filter(([, v]) => severity(v as Linter.RuleEntry) === 0)
|
|
.map(([r]) => r),
|
|
);
|
|
// …and the core rules typescript-eslint's compatibility layer switches off for TS files because the
|
|
// type checker reports them itself (no-undef, no-redeclare, no-dupe-keys, …).
|
|
const TSC_OWNED = new Set(
|
|
Object.entries(tseslint.configs.eslintRecommended.rules ?? {})
|
|
.filter(([, v]) => severity(v as Linter.RuleEntry) === 0)
|
|
.map(([r]) => r),
|
|
);
|
|
const family = (rule: string) => (rule.includes("/") ? `${rule.split("/")[0]}/*` : rule);
|
|
|
|
// 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.
|
|
const RELAXING_KEY = /^(allow|ignore|except)/;
|
|
const relaxes = (options: unknown[]): boolean =>
|
|
options.some(
|
|
(o) =>
|
|
o !== null &&
|
|
typeof o === "object" &&
|
|
Object.entries(o).some(
|
|
([k, v]) => RELAXING_KEY.test(k) && v !== false && !(Array.isArray(v) && v.length === 0),
|
|
),
|
|
);
|
|
function restrictedEntries(entry: Linter.RuleEntry | undefined): Set<string> {
|
|
const opts: unknown[] = Array.isArray(entry) ? entry.slice(1) : [];
|
|
const flat = opts.flatMap((o) => {
|
|
if (o === null || typeof o !== "object" || Array.isArray(o)) return [o];
|
|
const { paths = [], patterns = [] } = o as { paths?: unknown[]; patterns?: unknown[] };
|
|
return "paths" in o || "patterns" in o ? [...paths, ...patterns] : [o];
|
|
});
|
|
return new Set(flat.map((e) => JSON.stringify(e)));
|
|
}
|
|
|
|
// --- the snapshot ---------------------------------------------------------------------------------
|
|
// The configs that can narrow a gate in more shapes than a check can enumerate are held to a
|
|
// COMMITTED SNAPSHOT: 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 (the snapshot once 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. 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";
|
|
type EslintConfig = {
|
|
rules: Record<string, unknown[]>;
|
|
linterOptions: Record<string, unknown>;
|
|
languageOptions: Record<string, unknown>;
|
|
plugins: string[];
|
|
settings: unknown;
|
|
};
|
|
type Snapshot = {
|
|
eslint: Record<string, EslintConfig>;
|
|
lanes: Record<string, unknown>;
|
|
};
|
|
|
|
/** 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"
|
|
: /[.]test[.]ts$/.test(file)
|
|
? "tool tests"
|
|
: "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)
|
|
: 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 is reported as 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 a dozen 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 Omit<Snapshot, "eslint"> & {
|
|
eslint: Record<string, Stored>;
|
|
};
|
|
return canonical({ ...s, eslint: fromStored(s.eslint) });
|
|
};
|
|
|
|
type EslintActual = {
|
|
configs: Record<string, EslintConfig>;
|
|
/** 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<EslintActual> {
|
|
const eslint = new ESLint({ cwd: FRONTEND });
|
|
const byScope = new Map<string, 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;
|
|
}
|
|
const config = normalize(raw);
|
|
const key = JSON.stringify(config);
|
|
const scope = scopeOf(file);
|
|
const groups =
|
|
byScope.get(scope) ?? new Map<string, { config: EslintConfig; files: string[] }>();
|
|
const group = groups.get(key) ?? { config, files: [] };
|
|
group.files.push(file);
|
|
groups.set(key, group);
|
|
byScope.set(scope, groups);
|
|
}
|
|
const configs: Record<string, EslintConfig> = {};
|
|
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}`));
|
|
}
|
|
return { configs: canonical(configs), notLinted, strays };
|
|
}
|
|
|
|
// 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 = () => ({
|
|
"scripts/check.sh": meaningful("scripts/check.sh"),
|
|
"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<EslintActual> | undefined;
|
|
const linted = () => (actual ??= eslintActual());
|
|
const current = async (): Promise<Snapshot> =>
|
|
canonical({ eslint: (await linted()).configs, 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);
|
|
const entry = (v: unknown[] | undefined) => v as Linter.RuleEntry | undefined;
|
|
|
|
/** Every narrowing in the effective ESLint configs, as (scope, id). */
|
|
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)) {
|
|
// 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(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]);
|
|
}
|
|
return found;
|
|
}
|
|
|
|
describe("the gate snapshot (gates.snapshot.json)", () => {
|
|
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: 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);
|
|
});
|
|
|
|
// Git for Windows' own bash: on a Windows PATH `bash` is often WSL's, which cannot run the script.
|
|
function bash(): string {
|
|
if (process.platform !== "win32") return "bash";
|
|
const exec = execFileSync("git", ["--exec-path"], { encoding: "utf8" }).trim();
|
|
return resolve(exec, "../../../bin/bash.exe");
|
|
}
|
|
const checkSh = (...args: string[]) =>
|
|
spawnSync(bash(), ["scripts/check.sh", ...args], { cwd: REPO, encoding: "utf8" });
|
|
|
|
// A runner that schedules lanes one by one learns them from `--list` and runs each with `--only`; a
|
|
// lane missing from the list would be a lane it never runs, and an `--only` that passed with nothing
|
|
// run would be a green about nothing.
|
|
describe("the gate script's lane list (check.sh --list / --only)", () => {
|
|
it("lists every lane the script runs, in order", () => {
|
|
const declared = [...read("scripts/check.sh").matchAll(/^\s*lane "([^"]+)"/gm)].map(
|
|
(m) => m[1],
|
|
);
|
|
const listed = checkSh("--list");
|
|
expect(listed.status).toBe(0);
|
|
expect(listed.stdout.trim().split("\n")).toEqual(declared);
|
|
const lint = checkSh("lint", "--list").stdout.trim().split("\n");
|
|
expect(lint.length).toBeGreaterThan(0);
|
|
expect(declared.slice(0, lint.length)).toEqual(lint);
|
|
});
|
|
it("refuses an unknown lane instead of passing with nothing run", () => {
|
|
const r = checkSh("--only", "no such lane");
|
|
expect(r.status).toBe(2);
|
|
expect(r.stdout).not.toContain("green");
|
|
});
|
|
});
|
|
|
|
describe("the gate register (docs/gates.md)", () => {
|
|
it("lists every ESLint rule narrowed, by scope", async () => {
|
|
expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]);
|
|
}, 120_000);
|
|
|
|
// A spec left out of the webkit and ipad projects runs on one engine only.
|
|
it("lists every e2e spec that runs on one engine only", () => {
|
|
const cfg = read("frontend/playwright.config.ts");
|
|
const alternatives = /const CHROMIUM_ONLY = \/\(([^)]+)\)\\.spec\\.ts\//.exec(cfg)?.[1];
|
|
expect(alternatives, "CHROMIUM_ONLY").toBeTruthy();
|
|
const found = alternatives!
|
|
.split("|")
|
|
.map((name): [string, string] => ["e2e", `${name}.spec.ts`]);
|
|
expect(missingFrom(found)).toEqual([]);
|
|
// Any other way of leaving a spec out of a project would be a narrowing this does not read.
|
|
expect([...cfg.matchAll(/testIgnore:\s*([^,\n]+)/g)].map((m) => m[1])).toEqual([
|
|
"CHROMIUM_ONLY",
|
|
"CHROMIUM_ONLY",
|
|
]);
|
|
});
|
|
|
|
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).toBe("eslint . --max-warnings 0");
|
|
});
|
|
|
|
// --- TypeScript ---------------------------------------------------------------------------------
|
|
const PROJECTS = [
|
|
"frontend/tsconfig.json",
|
|
"frontend/tsconfig.node.json",
|
|
"frontend/e2e/tsconfig.json",
|
|
];
|
|
const REQUIRED_ON = [
|
|
"strict",
|
|
"noUncheckedIndexedAccess",
|
|
"noUnusedLocals",
|
|
"noUnusedParameters",
|
|
"noFallthroughCasesInSwitch",
|
|
];
|
|
it.each(PROJECTS)("%s keeps the strict flags on", (path) => {
|
|
const opts = jsonc(path).compilerOptions as Record<string, unknown>;
|
|
expect(REQUIRED_ON.filter((flag) => opts[flag] !== true)).toEqual([]);
|
|
});
|
|
// `strict: true` is a bundle: a sub-flag set false beside it, an `extends` this test cannot see
|
|
// through, or a narrowed `include` / an `exclude` weakens the project while the five flags above
|
|
// still read true.
|
|
const STRICT_FAMILY = [
|
|
"noImplicitAny",
|
|
"strictNullChecks",
|
|
"strictFunctionTypes",
|
|
"strictBindCallApply",
|
|
"strictPropertyInitialization",
|
|
"noImplicitThis",
|
|
"alwaysStrict",
|
|
"useUnknownInCatchVariables",
|
|
"strictBuiltinIteratorReturn",
|
|
];
|
|
const INCLUDE: Record<string, string[]> = {
|
|
"frontend/tsconfig.json": ["src"],
|
|
"frontend/tsconfig.node.json": [
|
|
"vite.config.ts",
|
|
"vitest.config.ts",
|
|
"reactPlugin.ts",
|
|
"devOnlyGuard.ts",
|
|
"chunkGuard.ts",
|
|
"chunkGuard.test.ts",
|
|
"playwright.config.ts",
|
|
"postcss.config.js",
|
|
],
|
|
"frontend/e2e/tsconfig.json": [".", "../playwright.config.ts"],
|
|
};
|
|
it("lists every weakening compiler flag", () => {
|
|
const found: [string, string][] = [];
|
|
for (const path of PROJECTS) {
|
|
const cfg = jsonc(path);
|
|
const opts = cfg.compilerOptions as Record<string, unknown>;
|
|
if (opts.skipLibCheck === true) found.push(["all three projects", "skipLibCheck"]);
|
|
for (const flag of STRICT_FAMILY) if (opts[flag] === false) found.push([path, flag]);
|
|
for (const key of ["extends", "exclude", "files"]) if (key in cfg) found.push([path, key]);
|
|
expect(cfg.include, path).toEqual(INCLUDE[path]);
|
|
}
|
|
expect(missingFrom(found)).toEqual([]);
|
|
});
|
|
it("puts no file in two projects", () => {
|
|
// A src file the config project also lists builds into that project's output, and the app
|
|
// project (which references it) then refuses the source (TS6305) — but only on a clean
|
|
// build: a local run reuses the referenced project's cached output and stays green, so only
|
|
// the Docker image build said so.
|
|
const node = jsonc("frontend/tsconfig.node.json").include as string[];
|
|
expect(node.filter((f) => f === "src" || f.startsWith("src/"))).toEqual([]);
|
|
});
|
|
it("type-checks every config file at the frontend root, or lists it", () => {
|
|
const checked = new Set(INCLUDE["frontend/tsconfig.node.json"]);
|
|
const roots = readdirSync(FRONTEND).filter((f) => /[.](ts|js|mjs|cjs)$/.test(f));
|
|
const found = roots
|
|
.filter((f) => !checked.has(f))
|
|
.map((f): [string, string] => ["config files", `${f} — not type-checked`]);
|
|
expect(missingFrom(found)).toEqual([]);
|
|
// a `.js` in `include` is dropped without a word unless allowJs, and read unchecked without
|
|
// checkJs
|
|
const opts = jsonc("frontend/tsconfig.node.json").compilerOptions as Record<string, unknown>;
|
|
if ([...checked].some((f) => /[.][mc]?js$/.test(f)))
|
|
expect([opts.allowJs, opts.checkJs]).toEqual([true, true]);
|
|
});
|
|
|
|
// --- Ruff -----------------------------------------------------------------------------------------
|
|
// Ruff, pyright, pytest and knip are read as whitelists: a key this test does not know (an
|
|
// `extend-exclude`, a `lint.extend-ignore`, a narrowing `select`, a pyright `exclude`, a pytest
|
|
// `-m "not slow"`, a knip `rules`) fails until someone decides whether it narrows (only
|
|
// `ignore` and the immutable calls used to be read). The TOML is PARSED, not scanned line by
|
|
// line: a `[lint.per-file-ignores]` table or a quoted key is a key path like any other (a line
|
|
// regex saw neither).
|
|
const keyPaths = (o: Record<string, unknown>, prefix = ""): string[] =>
|
|
Object.entries(o).flatMap(([k, v]) =>
|
|
v !== null && typeof v === "object" && !Array.isArray(v)
|
|
? keyPaths(v as Record<string, unknown>, `${prefix}${k}.`)
|
|
: [`${prefix}${k}`],
|
|
);
|
|
it("lists every ruff ignore and immutable call, and knows every key", () => {
|
|
const cfg = parseToml(read("backend/ruff.toml"));
|
|
expect(keyPaths(cfg).sort()).toEqual(
|
|
[
|
|
"lint.extend-select",
|
|
"lint.flake8-bugbear.extend-immutable-calls",
|
|
"line-length",
|
|
"target-version",
|
|
].sort(),
|
|
);
|
|
const lint = cfg.lint as Record<string, unknown>;
|
|
// the selection on top of ruff's defaults (E, F) — dropping one narrows the lane
|
|
expect(lint["extend-select"]).toEqual([
|
|
"LOG",
|
|
"G",
|
|
"T20",
|
|
"TRY400",
|
|
"TRY401",
|
|
"S110",
|
|
"S112",
|
|
"BLE001",
|
|
"RUF100",
|
|
"PLW1510",
|
|
"B",
|
|
]);
|
|
const immutable = (lint["flake8-bugbear"] as Record<string, string[]>)[
|
|
"extend-immutable-calls"
|
|
]!;
|
|
const found = immutable.map((c): [string, string] => ["backend + scripts", c]);
|
|
expect(missingFrom(found)).toEqual([]);
|
|
});
|
|
// 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. The snapshot holds every line of
|
|
// the gate script; these two are spelled out because the reason is (a substring check once let
|
|
// an extra `--config "lint.extend-ignore=…"` override through).
|
|
it("runs both ruff lanes on backend/ruff.toml and nothing else", () => {
|
|
const lanes = read("scripts/check.sh")
|
|
.split("\n")
|
|
.filter((l) => /python -m ruff (check|format)/.test(l))
|
|
.map((l) => l.trim());
|
|
expect(lanes).toEqual([
|
|
"ruff_check() { ruff_pinned && python -m ruff check --config backend/ruff.toml backend scripts; }",
|
|
"ruff_format() { ruff_pinned && python -m ruff format --check --config backend/ruff.toml backend scripts; }",
|
|
]);
|
|
});
|
|
|
|
// --- basedpyright ---------------------------------------------------------------------------------
|
|
const PYRIGHT_KEYS = [
|
|
"include",
|
|
"extraPaths",
|
|
"typeCheckingMode",
|
|
"pythonVersion",
|
|
"pythonPlatform",
|
|
];
|
|
it("lists the type-check mode, the baseline and every rule override, and knows every key", () => {
|
|
const cfg = jsonc("backend/pyrightconfig.json");
|
|
const found: [string, string][] = [];
|
|
if (cfg.typeCheckingMode !== "all" && cfg.typeCheckingMode !== "strict")
|
|
found.push(["backend/app + alembic", "typeCheckingMode"]);
|
|
found.push(["backend/app + alembic", "baseline.json"]);
|
|
const weak = [false, "none", "warning", "information", "hint"];
|
|
for (const [key, value] of Object.entries(cfg))
|
|
if (key.startsWith("report") && weak.includes(value as string | boolean))
|
|
found.push(["backend/app + alembic", key]);
|
|
expect(
|
|
Object.keys(cfg).filter((k) => !k.startsWith("report") && !PYRIGHT_KEYS.includes(k)),
|
|
).toEqual([]);
|
|
expect(cfg.include).toEqual(["app", "alembic"]); // tests/ out — its row says why
|
|
expect(missingFrom(found)).toEqual([]);
|
|
});
|
|
|
|
// --- pytest ---------------------------------------------------------------------------------------
|
|
// Held line by line, not key by key: pytest's ini reader also takes `key: value` and indented
|
|
// continuation lines (`addopts = -q` + ` -m "not slow"`), and a key regex saw neither.
|
|
// Every line that is not blank or a comment must be one of these, and all must be there.
|
|
it("keeps pytest strict: the whole tests/ tree, no selection, no warning filters, --strict-markers", () => {
|
|
const lines = read("backend/pytest.ini")
|
|
.split("\n")
|
|
.map((l) => l.trimEnd())
|
|
.filter((l) => l.trim() !== "" && !/^[#;]/.test(l.trim()));
|
|
expect(lines).toEqual([
|
|
"[pytest]",
|
|
"testpaths = tests",
|
|
"pythonpath = .",
|
|
// `-p tests.db_per_worker` selects nothing and filters nothing: under `-n` it hands each worker its
|
|
// own database (DATABASE_URL + `_gw<N>`), so the same tests can run side by side
|
|
"addopts = -q -rs --strict-markers -p tests.db_per_worker",
|
|
"db_per_worker_env = DATABASE_URL",
|
|
]);
|
|
});
|
|
|
|
// --- knip -----------------------------------------------------------------------------------------
|
|
it("lists every knip exclusion and every other key", () => {
|
|
const cfg = jsonc("frontend/knip.jsonc") as { project?: string[] } & Record<string, unknown>;
|
|
const found: [string, string][] = (cfg.project ?? [])
|
|
.filter((p) => p.startsWith("!"))
|
|
.map((p): [string, string] => ["knip", p]);
|
|
for (const key of Object.keys(cfg).filter(
|
|
(k) => !["$schema", "project", "playwright"].includes(k),
|
|
))
|
|
found.push(["knip", key]);
|
|
expect(missingFrom(found)).toEqual([]);
|
|
const pkg = JSON.parse(read("frontend/package.json")) as { scripts: Record<string, string> };
|
|
expect(pkg.scripts.knip).toBe("knip");
|
|
// the positive pattern is the project itself: narrowing it drops files from the lane
|
|
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
|
|
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 ----------------------------------------------------------------------------
|
|
it("accepts a CVE only with a statement, an expiry, and a row", () => {
|
|
const yaml = read(".trivyignore.yaml");
|
|
const entries = yaml
|
|
.split(/\n\s*- id:\s*/)
|
|
.slice(1)
|
|
.map((chunk) => ({
|
|
id: chunk.split("\n")[0]!.trim(),
|
|
statement: /\n\s*statement:\s*\S/.test(chunk),
|
|
expires: /\n\s*expired_at:\s*\d{4}-\d{2}-\d{2}/.test(chunk),
|
|
}));
|
|
expect(entries.filter((e) => !e.statement || !e.expires).map((e) => e.id)).toEqual([]);
|
|
const pipIgnores = read("scripts/check.sh")
|
|
.split("\n")
|
|
.filter((l) => !l.trim().startsWith("#")) // the lane's comment explains the flag; it is not one
|
|
.flatMap((l) => [...l.matchAll(/--ignore-vuln[ =](\S+)/g)].map((m) => m[1]!));
|
|
const found = [...entries.map((e) => e.id), ...pipIgnores].map((id): [string, string] => [
|
|
"accepted",
|
|
id,
|
|
]);
|
|
expect(missingFrom(found)).toEqual([]);
|
|
});
|
|
});
|