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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user