fix(gate): knip sees the watch entry, the build must emit watch.html, and two hand copies are held

- The raw-source contract tests read files from disk (rawSources.testkit)
  instead of import.meta.glob, which knip counted as importing every src file —
  its unused-files lane could never name an orphan. knip now names
  src/watchMain.tsx as an entry (registered in docs/gates.md).
- vite build fails when index.html or watch.html is not emitted.
- The watch-entry import walk follows dynamic import() too.
- audit_metrics.py's plan-code patterns are held equal to the guard's.
- CrashBoundary is domain-free: it moves into components/ui.
This commit is contained in:
2026-10-06 03:19:33 +02:00
parent b20e9a3947
commit d44d8d6dda
19 changed files with 119 additions and 55 deletions
+20 -5
View File
@@ -7,9 +7,11 @@ import ts from "typescript";
// loads the player page and nothing of the app. That holds only while nothing the page imports
// reaches back into the app — and the first build of the entry showed how easily it does: one
// file-size formatter imported the app's i18n, and with it every locale of every area (≈ 120 KB gzip,
// half the page). This walks the entry's static imports, as the bundler does, and names the path to
// any of what the app alone may load.
// half the page). This walks the entry's imports as the bundler does — the static ones and the lazy
// `import("…")` chunks it would fetch later — and names the path to any of what the app alone may load.
const SRC = resolve(__dirname);
// Stands for an `import()` whose argument is not a string literal.
const DYNAMIC = "<computed import()>";
const FORBIDDEN: Record<string, string> = {
"src/i18n/index.ts":
"the app's i18n (every locale) — the page has its own strings (watchStrings)",
@@ -21,9 +23,11 @@ const FORBIDDEN: Record<string, string> = {
"src/lib/notifications.ts": "the app's notification center",
"src/lib/logHooks.ts": "the app's client-log wiring",
"flag-icons": "the app's flag sprites",
[DYNAMIC]: "an import() of a computed path — no walk can say what it loads",
};
function resolveSpec(from: string, spec: string): string | null {
if (spec === DYNAMIC) return DYNAMIC;
if (!spec.startsWith(".")) {
const parts = spec.split("/");
return parts.slice(0, spec.startsWith("@") ? 2 : 1).join("/");
@@ -36,7 +40,8 @@ function resolveSpec(from: string, spec: string): string | null {
return null;
}
/** Specifiers a file loads at run time: value imports and re-exports, never `import type`. */
/** Specifiers a file loads at run time: value imports and re-exports (never `import type`), and
* every `import()` anywhere in it — a lazy chunk is loaded by the page as surely as a static one. */
function runtimeImports(file: string): string[] {
const sf = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true);
const specs: string[] = [];
@@ -50,6 +55,14 @@ function runtimeImports(file: string): string[] {
specs.push((st.moduleSpecifier as ts.StringLiteral).text);
}
}
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
const arg = node.arguments[0];
specs.push(arg && ts.isStringLiteralLike(arg) ? arg.text : DYNAMIC);
}
ts.forEachChild(node, visit);
};
visit(sf);
return specs;
}
@@ -91,8 +104,10 @@ describe("the /watch entry", () => {
});
it("catches a leak one import deep (the walk is not vacuous)", () => {
// main.tsx is the app: walking it must find the app's i18n.
expect(reach(join(SRC, "main.tsx")).has("src/i18n/index.ts")).toBe(true);
// main.tsx is the app: walking it must find the app's i18n, and a page it only loads lazily.
const app = reach(join(SRC, "main.tsx"));
expect(app.has("src/i18n/index.ts")).toBe(true);
expect(app.has("src/components/legal/Terms.tsx")).toBe(true);
});
it("serves the same head as the app's page, so the server's OG block lands the same way", () => {