test: canonicalize housekeeping module boundaries

This commit is contained in:
Simo committed 2026-08-25 21:35:10 +02:00
1 parent b6bf5e69ca
commit a47b4eb195
1 file changed
+193 -24
@@ -136,6 +136,7 @@ const expressionWrapperTypes = new Set([
"TSTypeAssertion", "TSTypeAssertion",
"TypeCastExpression", "TypeCastExpression",
]); ]);
const resolverExtensionPattern = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/i;
function isBabelNode(value: unknown): value is BabelNode { function isBabelNode(value: unknown): value is BabelNode {
return ( return (
@@ -181,6 +182,38 @@ function readLiteralModuleSpecifier(node: unknown): string | null {
return null; return null;
} }
function memberPropertyName(node: BabelNode): string | null {
const property = unwrapModuleArgument(node.property);
if (!property) return null;
if (node.computed === true) return readLiteralModuleSpecifier(property);
return property.type === "Identifier" && typeof property.name === "string"
? property.name
: null;
}
function isGuardedRequireCallee(node: unknown): boolean {
const callee = unwrapModuleArgument(node);
if (!callee) return false;
if (callee.type === "Identifier" && callee.name === "require") return true;
if (
callee.type !== "MemberExpression" &&
callee.type !== "OptionalMemberExpression"
) {
return false;
}
const object = unwrapModuleArgument(callee.object);
const property = memberPropertyName(callee);
return (
(object?.type === "Identifier" &&
object.name === "module" &&
property === "require") ||
(object?.type === "Identifier" &&
object.name === "require" &&
property === "resolve")
);
}
function scanModuleAccesses(source: string): ModuleAccessScan { function scanModuleAccesses(source: string): ModuleAccessScan {
const root = babelParser.parse(source, { const root = babelParser.parse(source, {
createImportExpressions: true, createImportExpressions: true,
@@ -220,17 +253,18 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
recordArgument(value.source, "import"); recordArgument(value.source, "import");
} else if (value.type === "TSImportType") { } else if (value.type === "TSImportType") {
recordArgument(value.argument, "import"); recordArgument(value.argument, "import");
} else if (value.type === "CallExpression") { } else if (
value.type === "CallExpression" ||
value.type === "OptionalCallExpression"
) {
const arguments_ = Array.isArray(value.arguments) ? value.arguments : []; const arguments_ = Array.isArray(value.arguments) ? value.arguments : [];
if (isBabelNode(value.callee) && value.callee.type === "Import") { if (isBabelNode(value.callee) && value.callee.type === "Import") {
recordArgument(arguments_[0], "import"); recordArgument(arguments_[0], "import");
} else if ( } else if (isGuardedRequireCallee(value.callee)) {
isBabelNode(value.callee) &&
value.callee.type === "Identifier" &&
value.callee.name === "require"
) {
recordArgument(arguments_[0], "require"); recordArgument(arguments_[0], "require");
} }
} else if (value.type === "TSExternalModuleReference") {
recordArgument(value.expression, "require");
} }
for (const [key, child] of Object.entries(value)) { for (const [key, child] of Object.entries(value)) {
@@ -241,18 +275,50 @@ function scanModuleAccesses(source: string): ModuleAccessScan {
visit(root); visit(root);
return { specifiers, violations }; return { specifiers, violations };
} }
function normalizeLocalSpecifier( interface CanonicalLocalSpecifier {
candidates: readonly string[];
violation: string | null;
}
function canonicalizeLocalSpecifier(
routeFile: string, routeFile: string,
specifier: string, specifier: string,
): string | null { ): CanonicalLocalSpecifier {
if (specifier.startsWith("@/")) { const suffixIndex = specifier.search(/[?#]/);
return posix.normalize(`src/${specifier.slice(2)}`); const withoutSuffix =
} suffixIndex === -1 ? specifier : specifier.slice(0, suffixIndex);
if (specifier.startsWith(".")) { const slashNormalized = withoutSuffix.replaceAll("\\", "/");
return posix.normalize(posix.join(posix.dirname(routeFile), specifier)); if (!slashNormalized.startsWith("@/") && !slashNormalized.startsWith(".")) {
return { candidates: [], violation: null };
} }
return null; let decoded: string;
try {
decoded = decodeURIComponent(withoutSuffix).replaceAll("\\", "/");
} catch {
return { candidates: [], violation: "<malformed local specifier>" };
}
let normalized: string;
if (decoded.startsWith("@/")) {
normalized = posix.normalize(`src/${decoded.slice(2)}`);
} else if (decoded.startsWith(".")) {
normalized = posix.normalize(posix.join(posix.dirname(routeFile), decoded));
} else {
return { candidates: [], violation: "<malformed local specifier>" };
}
const extensionless = normalized.replace(resolverExtensionPattern, "");
return {
candidates: [...new Set([normalized, extensionless])],
violation: null,
};
}
function isForbiddenModulePath(path: string): boolean {
return forbiddenModuleRoots.some(
(root) => path === root || path.startsWith(`${root}/`),
);
} }
function findRouteImportBoundaryViolations( function findRouteImportBoundaryViolations(
@@ -260,15 +326,17 @@ function findRouteImportBoundaryViolations(
routeFile: string, routeFile: string,
): readonly string[] { ): readonly string[] {
const scan = scanModuleAccesses(source); const scan = scanModuleAccesses(source);
const forbiddenPaths = scan.specifiers const violations = [...scan.violations];
.map((specifier) => normalizeLocalSpecifier(routeFile, specifier)) const forbiddenPaths: string[] = [];
.filter((path): path is string => path !== null)
.filter((path) => for (const specifier of scan.specifiers) {
forbiddenModuleRoots.some( const canonical = canonicalizeLocalSpecifier(routeFile, specifier);
(root) => path === root || path.startsWith(`${root}/`), if (canonical.violation) violations.push(canonical.violation);
), const forbiddenPath = canonical.candidates.find(isForbiddenModulePath);
); if (forbiddenPath) forbiddenPaths.push(forbiddenPath);
return [...scan.violations, ...forbiddenPaths]; }
return [...violations, ...forbiddenPaths];
} }
function capabilityContext( function capabilityContext(
@@ -489,6 +557,96 @@ describe("preview route import boundary", () => {
const interpolationOpen = "$" + "{"; const interpolationOpen = "$" + "{";
it.each([ it.each([
[
"relative database import with resolver extension",
"src/app/admin-next/page.tsx",
'import db from "../../lib/db.js";',
"src/lib/db",
],
[
"aliased database import with resolver extension",
"src/app/admin-next/page.tsx",
'import db from "@/lib/db.js";',
"src/lib/db",
],
[
"action root import with resolver extension",
"src/app/admin-next/page.tsx",
'import actions from "../../actions.mjs";',
"src/actions",
],
[
"legacy mod root import with resolver extension",
"src/app/admin-next/layout.tsx",
'import mod from "../mod.cjs";',
"src/app/mod",
],
[
"database import with query suffix",
"src/app/admin-next/page.tsx",
'import db from "../../lib/db?server-only";',
"src/lib/db",
],
[
"action import with hash suffix",
"src/app/admin-next/page.tsx",
'import("../../actions/users#server")',
"src/actions/users",
],
[
"Windows-style relative database import",
"src/app/admin-next/page.tsx",
String.raw`import db from "..\\..\\lib\\db";`,
"src/lib/db",
],
[
"Windows-style aliased database import",
"src/app/admin-next/page.tsx",
String.raw`import db from "@\\lib\\db";`,
"src/lib/db",
],
[
"percent-encoded database import",
"src/app/admin-next/page.tsx",
'import db from "../../lib/%64%62";',
"src/lib/db",
],
[
"optional CommonJS database require",
"src/app/admin-next/page.tsx",
'require?.("../../lib/db")',
"src/lib/db",
],
[
"module database require",
"src/app/admin-next/page.tsx",
'module.require("../../lib/db")',
"src/lib/db",
],
[
"require.resolve database access",
"src/app/admin-next/page.tsx",
'require.resolve("../../lib/db")',
"src/lib/db",
],
[
"optional module database require",
"src/app/admin-next/page.tsx",
'module.require?.("../../lib/db")',
"src/lib/db",
],
[
"optional require.resolve database access",
"src/app/admin-next/page.tsx",
'require.resolve?.("../../lib/db")',
"src/lib/db",
],
[
"TypeScript import-equals database access",
"src/app/admin-next/page.tsx",
'import db = require("../../lib/db");',
"src/lib/db",
],
[ [
"U+2028 line-continuation database import", "U+2028 line-continuation database import",
"src/app/admin-next/page.tsx", "src/app/admin-next/page.tsx",
@@ -634,6 +792,16 @@ describe("preview route import boundary", () => {
}); });
it.each([ it.each([
[
"optional CommonJS require",
"const path = '../../lib/db'; require?.(path)",
"<non-literal require>",
],
[
"malformed local percent escape",
'import db from "../../lib/db%ZZ";',
"<malformed local specifier>",
],
[ [
"dynamic import", "dynamic import",
"const path = '../../actions/users'; import(path)", "const path = '../../actions/users'; import(path)",
@@ -652,7 +820,8 @@ describe("preview route import boundary", () => {
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.js?raw";',
'import dbTools from "../../lib/db-tools.ts";',
'import auth from "../../lib/authentication";', 'import auth from "../../lib/authentication";',
'import preview from "../admin-next-shared";', 'import preview from "../admin-next-shared";',
"const documentation = \"import db from '../../lib/db'\";", "const documentation = \"import db from '../../lib/db'\";",