test: parse housekeeping import boundaries

This commit is contained in:
Simo committed 2026-08-25 21:23:58 +02:00
1 parent 0b46a94d57
commit b6bf5e69ca
1 file changed
+188 -279
@@ -1,4 +1,5 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { posix, resolve } from "node:path"; import { posix, resolve } from "node:path";
import { createElement, type ReactNode } from "react"; import { createElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
@@ -99,281 +100,147 @@ const forbiddenModuleRoots = [
"src/app/mod", "src/app/mod",
] as const; ] as const;
function decodeJavaScriptStringEscapes(value: string): string { interface BabelNode {
let decoded = ""; type: string;
[key: string]: unknown;
}
for (let index = 0; index < value.length; index += 1) { interface BabelParser {
const character = value[index]; parse(
if (character !== "\\") { source: string,
decoded += character; options: {
continue; createImportExpressions: boolean;
} plugins: readonly ["typescript", "jsx"];
sourceType: "module";
},
): BabelNode;
}
const escaped = value[index + 1]; interface ModuleAccessScan {
if (escaped === undefined) { specifiers: readonly string[];
decoded += "\\"; violations: readonly string[];
continue; }
}
if (escaped === "\n") {
index += 1;
continue;
}
if (escaped === "\r") {
index += value[index + 2] === "\n" ? 2 : 1;
continue;
}
if (escaped === "x") {
const hexadecimal = value.slice(index + 2, index + 4);
if (/^[0-9A-Fa-f]{2}$/.test(hexadecimal)) {
decoded += String.fromCharCode(Number.parseInt(hexadecimal, 16));
index += 3;
continue;
}
}
if (escaped === "u") {
if (value[index + 2] === "{") {
const closingBrace = value.indexOf("}", index + 3);
const hexadecimal = value.slice(index + 3, closingBrace);
if (
closingBrace >= 0 &&
/^[0-9A-Fa-f]{1,6}$/.test(hexadecimal) &&
Number.parseInt(hexadecimal, 16) <= 0x10ffff
) {
decoded += String.fromCodePoint(Number.parseInt(hexadecimal, 16));
index = closingBrace;
continue;
}
} else {
const hexadecimal = value.slice(index + 2, index + 6);
if (/^[0-9A-Fa-f]{4}$/.test(hexadecimal)) {
decoded += String.fromCharCode(Number.parseInt(hexadecimal, 16));
index += 5;
continue;
}
}
}
const standardEscapes: Readonly<Record<string, string>> = { const projectRequire = createRequire(import.meta.url);
"0": "\0", const requireFromVitest = createRequire(
b: "\b", projectRequire.resolve("vitest/package.json"),
f: "\f", );
n: "\n", const babelParser = requireFromVitest("@babel/parser") as BabelParser;
r: "\r",
t: "\t", const expressionWrapperTypes = new Set([
v: "\v", "ParenthesizedExpression",
"\\": "\\", "TSAsExpression",
"/": "/", "TSInstantiationExpression",
'"': '"', "TSNonNullExpression",
"'": "'", "TSSatisfiesExpression",
"`": "`", "TSTypeAssertion",
}; "TypeCastExpression",
decoded += standardEscapes[escaped] ?? escaped; ]);
index += 1;
function isBabelNode(value: unknown): value is BabelNode {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof value.type === "string"
);
}
function unwrapModuleArgument(node: unknown): BabelNode | null {
let current = isBabelNode(node) ? node : null;
while (current && expressionWrapperTypes.has(current.type)) {
current = isBabelNode(current.expression) ? current.expression : null;
} }
return current;
return decoded;
} }
interface SourceToken { function readLiteralModuleSpecifier(node: unknown): string | null {
kind: "word" | "string" | "punctuation"; const literal = unwrapModuleArgument(node);
value: string; if (!literal) return null;
} if (literal.type === "StringLiteral" && typeof literal.value === "string") {
return literal.value;
interface TokenizeResult {
index: number;
tokens: readonly SourceToken[];
}
function scanQuotedString(
source: string,
start: number,
quote: '"' | "'",
): { index: number; value: string } {
let index = start + 1;
let rawValue = "";
while (index < source.length) {
const character = source[index];
if (character === "\\") {
rawValue += character;
const escaped = source[index + 1];
if (escaped !== undefined) {
rawValue += escaped;
index += 2;
if (escaped === "\r" && source[index] === "\n") {
rawValue += "\n";
index += 1;
}
continue;
}
index += 1;
continue;
}
if (character === quote) {
return {
index: index + 1,
value: decodeJavaScriptStringEscapes(rawValue),
};
}
rawValue += character;
index += 1;
} }
if (literal.type !== "TemplateLiteral") return null;
return { index, value: decodeJavaScriptStringEscapes(rawValue) }; const expressions = Array.isArray(literal.expressions)
} ? literal.expressions
: [];
const quasis = Array.isArray(literal.quasis) ? literal.quasis : [];
if (expressions.length !== 0 || quasis.length !== 1) return null;
function scanTemplateLiteral(source: string, start: number): TokenizeResult { const quasi = isBabelNode(quasis[0]) ? quasis[0] : null;
const tokens: SourceToken[] = []; const value = quasi?.value;
let index = start + 1; if (
let rawValue = ""; typeof value === "object" &&
let interpolated = false; value !== null &&
"cooked" in value &&
while (index < source.length) { typeof value.cooked === "string"
const character = source[index]; ) {
if (character === "\\") { return value.cooked;
rawValue += character;
const escaped = source[index + 1];
if (escaped !== undefined) {
rawValue += escaped;
index += 2;
if (escaped === "\r" && source[index] === "\n") {
rawValue += "\n";
index += 1;
}
continue;
}
index += 1;
continue;
}
if (character === "`") {
if (!interpolated) {
tokens.push({
kind: "string",
value: decodeJavaScriptStringEscapes(rawValue),
});
}
return { index: index + 1, tokens };
}
if (character === "$" && source[index + 1] === "{") {
interpolated = true;
const expression = tokenizeCode(source, index + 2, true);
tokens.push(...expression.tokens);
index = expression.index;
continue;
}
rawValue += character;
index += 1;
} }
return null;
return { index, tokens };
} }
function tokenizeCode( function scanModuleAccesses(source: string): ModuleAccessScan {
source: string, const root = babelParser.parse(source, {
start = 0, createImportExpressions: true,
stopAtClosingBrace = false, plugins: ["typescript", "jsx"],
): TokenizeResult { sourceType: "module",
const tokens: SourceToken[] = []; });
let braceDepth = stopAtClosingBrace ? 1 : 0;
let index = start;
while (index < source.length) {
const character = source[index];
const nextCharacter = source[index + 1];
if (/\s/.test(character)) {
index += 1;
continue;
}
if (character === "/" && nextCharacter === "/") {
const lineEnd = source.indexOf("\n", index + 2);
index = lineEnd === -1 ? source.length : lineEnd + 1;
continue;
}
if (character === "/" && nextCharacter === "*") {
const commentEnd = source.indexOf("*/", index + 2);
index = commentEnd === -1 ? source.length : commentEnd + 2;
continue;
}
if (character === '"' || character === "'") {
const string = scanQuotedString(source, index, character);
tokens.push({ kind: "string", value: string.value });
index = string.index;
continue;
}
if (character === "`") {
const template = scanTemplateLiteral(source, index);
tokens.push(...template.tokens);
index = template.index;
continue;
}
if (/[A-Za-z_$]/.test(character)) {
const wordStart = index;
index += 1;
while (index < source.length && /[A-Za-z0-9_$]/.test(source[index])) {
index += 1;
}
tokens.push({ kind: "word", value: source.slice(wordStart, index) });
continue;
}
if (stopAtClosingBrace && character === "{") {
braceDepth += 1;
}
if (stopAtClosingBrace && character === "}") {
braceDepth -= 1;
if (braceDepth === 0) return { index: index + 1, tokens };
}
tokens.push({ kind: "punctuation", value: character });
index += 1;
}
return { index, tokens };
}
function tokenizeModuleSource(source: string): readonly SourceToken[] {
return tokenizeCode(source).tokens;
}
function extractModuleSpecifiers(source: string): readonly string[] {
const tokens = tokenizeModuleSource(source);
const specifiers: string[] = []; const specifiers: string[] = [];
const violations: string[] = [];
for (let index = 0; index < tokens.length; index += 1) { function recordArgument(argument: unknown, kind: "import" | "require") {
const token = tokens[index]; const specifier = readLiteralModuleSpecifier(argument);
if ( if (specifier === null) {
token.kind !== "word" || violations.push(`<non-literal ${kind}>`);
(token.value !== "import" && token.value !== "export") return;
}
specifiers.push(specifier);
}
function visit(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!isBabelNode(value)) return;
if (value.type === "ImportDeclaration") {
const specifier = readLiteralModuleSpecifier(value.source);
if (specifier !== null) specifiers.push(specifier);
} else if (
(value.type === "ExportNamedDeclaration" ||
value.type === "ExportAllDeclaration") &&
value.source !== null
) { ) {
continue; const specifier = readLiteralModuleSpecifier(value.source);
} if (specifier !== null) specifiers.push(specifier);
} else if (value.type === "ImportExpression") {
const next = tokens[index + 1]; recordArgument(value.source, "import");
if (token.value === "import" && next?.value === "(") { } else if (value.type === "TSImportType") {
const argument = tokens[index + 2]; recordArgument(value.argument, "import");
if (argument?.kind === "string") specifiers.push(argument.value); } else if (value.type === "CallExpression") {
continue; const arguments_ = Array.isArray(value.arguments) ? value.arguments : [];
} if (isBabelNode(value.callee) && value.callee.type === "Import") {
if (token.value === "import" && next?.kind === "string") { recordArgument(arguments_[0], "import");
specifiers.push(next.value); } else if (
continue; isBabelNode(value.callee) &&
} value.callee.type === "Identifier" &&
value.callee.name === "require"
for (let cursor = index + 1; cursor < tokens.length; cursor += 1) { ) {
const candidate = tokens[cursor]; recordArgument(arguments_[0], "require");
if (candidate.value === ";") break;
if (candidate.kind === "word" && candidate.value === "from") {
const moduleSpecifier = tokens[cursor + 1];
if (moduleSpecifier?.kind === "string") {
specifiers.push(moduleSpecifier.value);
}
break;
} }
} }
for (const [key, child] of Object.entries(value)) {
if (key !== "type") visit(child);
}
} }
return specifiers; visit(root);
return { specifiers, violations };
} }
function normalizeLocalSpecifier( function normalizeLocalSpecifier(
routeFile: string, routeFile: string,
specifier: string, specifier: string,
@@ -388,11 +255,12 @@ function normalizeLocalSpecifier(
return null; return null;
} }
function findForbiddenRouteImports( function findRouteImportBoundaryViolations(
source: string, source: string,
routeFile: string, routeFile: string,
): readonly string[] { ): readonly string[] {
return extractModuleSpecifiers(source) const scan = scanModuleAccesses(source);
const forbiddenPaths = scan.specifiers
.map((specifier) => normalizeLocalSpecifier(routeFile, specifier)) .map((specifier) => normalizeLocalSpecifier(routeFile, specifier))
.filter((path): path is string => path !== null) .filter((path): path is string => path !== null)
.filter((path) => .filter((path) =>
@@ -400,6 +268,7 @@ function findForbiddenRouteImports(
(root) => path === root || path.startsWith(`${root}/`), (root) => path === root || path.startsWith(`${root}/`),
), ),
); );
return [...scan.violations, ...forbiddenPaths];
} }
function capabilityContext( function capabilityContext(
@@ -612,7 +481,7 @@ describe("preview route import boundary", () => {
for (const path of routeFiles) { for (const path of routeFiles) {
const source = readFileSync(resolve(process.cwd(), path), "utf8"); const source = readFileSync(resolve(process.cwd(), path), "utf8");
expect(findForbiddenRouteImports(source, path), path).toEqual([]); expect(findRouteImportBoundaryViolations(source, path), path).toEqual([]);
expect(source, path).not.toMatch(/AdminSidebarNav|AdminHubChrome/); expect(source, path).not.toMatch(/AdminSidebarNav|AdminHubChrome/);
} }
}); });
@@ -620,6 +489,48 @@ describe("preview route import boundary", () => {
const interpolationOpen = "$" + "{"; const interpolationOpen = "$" + "{";
it.each([ it.each([
[
"U+2028 line-continuation database import",
"src/app/admin-next/page.tsx",
'import db from "../\\' + "\u2028" + '../lib/db";',
"src/lib/db",
],
[
"U+2029 line-continuation database import",
"src/app/admin-next/page.tsx",
'import db from "../\\' + "\u2029" + '../lib/db";',
"src/lib/db",
],
[
"parenthesized dynamic action import",
"src/app/admin-next/page.tsx",
'import(("../../actions/users"))',
"src/actions/users",
],
[
"regex-brace template-expression action import",
"src/app/admin-next/page.tsx",
`const x = \`${interpolationOpen}/}/.test(value) ? import("../../actions/users") : null}\`;`,
"src/actions/users",
],
[
"CommonJS database require",
"src/app/admin-next/page.tsx",
'require("../../lib/db")',
"src/lib/db",
],
[
"template-literal dynamic action import",
"src/app/admin-next/page.tsx",
"import(`../../actions/users`)",
"src/actions/users",
],
[
"TypeScript-asserted dynamic action import",
"src/app/admin-next/page.tsx",
'import(("../../actions/users" as string))',
"src/actions/users",
],
[ [
"template-expression dynamic action import", "template-expression dynamic action import",
"src/app/admin-next/page.tsx", "src/app/admin-next/page.tsx",
@@ -717,11 +628,28 @@ describe("preview route import boundary", () => {
"src/app/mod/users/page", "src/app/mod/users/page",
], ],
] as const)("detects %s", (_name, routeFile, source, expectedPath) => { ] as const)("detects %s", (_name, routeFile, source, expectedPath) => {
expect(findForbiddenRouteImports(source, routeFile)).toContain( expect(findRouteImportBoundaryViolations(source, routeFile)).toContain(
expectedPath, expectedPath,
); );
}); });
it.each([
[
"dynamic import",
"const path = '../../actions/users'; import(path)",
"<non-literal import>",
],
[
"CommonJS require",
"const path = '../../lib/db'; require(path)",
"<non-literal require>",
],
] as const)("fails closed for non-literal %s", (_name, source, violation) => {
expect(
findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"),
).toContain(violation);
});
it("does not reject substring lookalikes, comments, or ordinary strings", () => { it("does not reject substring lookalikes, comments, or ordinary strings", () => {
const source = [ const source = [
'import database from "@/lib/database";', 'import database from "@/lib/database";',
@@ -733,26 +661,7 @@ describe("preview route import boundary", () => {
].join("\n"); ].join("\n");
expect( expect(
findForbiddenRouteImports(source, "src/app/admin-next/page.tsx"), findRouteImportBoundaryViolations(source, "src/app/admin-next/page.tsx"),
).toEqual([]); ).toEqual([]);
}); });
it("decodes JavaScript string-literal escapes", () => {
expect(decodeJavaScriptStringEscapes(String.raw`\u0041`)).toBe("A");
expect(decodeJavaScriptStringEscapes(String.raw`\u{1f600}`)).toBe("😀");
expect(decodeJavaScriptStringEscapes(String.raw`\x2f`)).toBe("/");
expect(decodeJavaScriptStringEscapes(String.raw`\/`)).toBe("/");
expect(decodeJavaScriptStringEscapes(String.raw`\\`)).toBe("\\");
expect(decodeJavaScriptStringEscapes(String.raw`\"`)).toBe('"');
expect(decodeJavaScriptStringEscapes(String.raw`\'`)).toBe("'");
expect(decodeJavaScriptStringEscapes(String.raw`\b\f\n\r\t\v\0`)).toBe(
"\b\f\n\r\t\v\0",
);
expect(decodeJavaScriptStringEscapes(String.raw`\q`)).toBe("q");
});
it("decodes CRLF and LF string-literal line continuations", () => {
expect(decodeJavaScriptStringEscapes("\\" + "\r\n")).toBe("");
expect(decodeJavaScriptStringEscapes("\\" + "\n")).toBe("");
});
}); });