From a47b4eb195e345299dbc97b59b7ee960dff5e124 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 25 Aug 2026 21:35:10 +0200 Subject: [PATCH] test: canonicalize housekeeping module boundaries --- .../foundation/preview-route-contract.test.ts | 217 ++++++++++++++++-- 1 file changed, 193 insertions(+), 24 deletions(-) diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index eef6b2ff..52a8d753 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -136,6 +136,7 @@ const expressionWrapperTypes = new Set([ "TSTypeAssertion", "TypeCastExpression", ]); +const resolverExtensionPattern = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/i; function isBabelNode(value: unknown): value is BabelNode { return ( @@ -181,6 +182,38 @@ function readLiteralModuleSpecifier(node: unknown): string | 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 { const root = babelParser.parse(source, { createImportExpressions: true, @@ -220,17 +253,18 @@ function scanModuleAccesses(source: string): ModuleAccessScan { recordArgument(value.source, "import"); } else if (value.type === "TSImportType") { 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 : []; if (isBabelNode(value.callee) && value.callee.type === "Import") { recordArgument(arguments_[0], "import"); - } else if ( - isBabelNode(value.callee) && - value.callee.type === "Identifier" && - value.callee.name === "require" - ) { + } else if (isGuardedRequireCallee(value.callee)) { recordArgument(arguments_[0], "require"); } + } else if (value.type === "TSExternalModuleReference") { + recordArgument(value.expression, "require"); } for (const [key, child] of Object.entries(value)) { @@ -241,18 +275,50 @@ function scanModuleAccesses(source: string): ModuleAccessScan { visit(root); return { specifiers, violations }; } -function normalizeLocalSpecifier( +interface CanonicalLocalSpecifier { + candidates: readonly string[]; + violation: string | null; +} + +function canonicalizeLocalSpecifier( routeFile: string, specifier: string, -): string | null { - if (specifier.startsWith("@/")) { - return posix.normalize(`src/${specifier.slice(2)}`); - } - if (specifier.startsWith(".")) { - return posix.normalize(posix.join(posix.dirname(routeFile), specifier)); +): CanonicalLocalSpecifier { + const suffixIndex = specifier.search(/[?#]/); + const withoutSuffix = + suffixIndex === -1 ? specifier : specifier.slice(0, suffixIndex); + const slashNormalized = withoutSuffix.replaceAll("\\", "/"); + if (!slashNormalized.startsWith("@/") && !slashNormalized.startsWith(".")) { + return { candidates: [], violation: null }; } - return null; + let decoded: string; + try { + decoded = decodeURIComponent(withoutSuffix).replaceAll("\\", "/"); + } catch { + return { candidates: [], violation: "" }; + } + + 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: "" }; + } + + 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( @@ -260,15 +326,17 @@ function findRouteImportBoundaryViolations( routeFile: string, ): readonly string[] { const scan = scanModuleAccesses(source); - const forbiddenPaths = scan.specifiers - .map((specifier) => normalizeLocalSpecifier(routeFile, specifier)) - .filter((path): path is string => path !== null) - .filter((path) => - forbiddenModuleRoots.some( - (root) => path === root || path.startsWith(`${root}/`), - ), - ); - return [...scan.violations, ...forbiddenPaths]; + const violations = [...scan.violations]; + const forbiddenPaths: string[] = []; + + for (const specifier of scan.specifiers) { + const canonical = canonicalizeLocalSpecifier(routeFile, specifier); + if (canonical.violation) violations.push(canonical.violation); + const forbiddenPath = canonical.candidates.find(isForbiddenModulePath); + if (forbiddenPath) forbiddenPaths.push(forbiddenPath); + } + + return [...violations, ...forbiddenPaths]; } function capabilityContext( @@ -489,6 +557,96 @@ describe("preview route import boundary", () => { const interpolationOpen = "$" + "{"; 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", "src/app/admin-next/page.tsx", @@ -634,6 +792,16 @@ describe("preview route import boundary", () => { }); it.each([ + [ + "optional CommonJS require", + "const path = '../../lib/db'; require?.(path)", + "", + ], + [ + "malformed local percent escape", + 'import db from "../../lib/db%ZZ";', + "", + ], [ "dynamic import", "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", () => { 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 preview from "../admin-next-shared";', "const documentation = \"import db from '../../lib/db'\";",