diff --git a/scripts/check-src-extension-import-boundary.mjs b/scripts/check-src-extension-import-boundary.mjs new file mode 100644 index 00000000000..adb5e4fbfe6 --- /dev/null +++ b/scripts/check-src-extension-import-boundary.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs"; +import { + collectTypeScriptInventory, + normalizeRepoPath, + resolveRepoSpecifier, + visitModuleSpecifiers, + writeLine, +} from "./lib/guard-inventory-utils.mjs"; +import { + collectTypeScriptFilesFromRoots, + resolveSourceRoots, + runAsScript, + toLine, +} from "./lib/ts-guard-utils.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const scanRoots = resolveSourceRoots(repoRoot, ["src"]); +let cachedInventoryPromise = null; + +function compareEntries(left, right) { + return ( + left.file.localeCompare(right.file) || + left.line - right.line || + left.kind.localeCompare(right.kind) || + left.specifier.localeCompare(right.specifier) || + left.reason.localeCompare(right.reason) + ); +} + +function classifyResolvedExtensionReason(kind) { + const verb = + kind === "export" + ? "re-exports" + : kind === "dynamic-import" + ? "dynamically imports" + : "imports"; + return `${verb} bundled plugin file from src boundary`; +} + +function shouldSkipFile(filePath) { + const relativeFile = normalizeRepoPath(repoRoot, filePath); + return ( + relativeFile.endsWith(".test.ts") || + relativeFile.endsWith(".test.tsx") || + relativeFile.endsWith(".e2e.test.ts") || + relativeFile.endsWith(".e2e.test.tsx") + ); +} + +function scanImportBoundaryViolations(sourceFile, filePath) { + const entries = []; + const relativeFile = normalizeRepoPath(repoRoot, filePath); + + visitModuleSpecifiers(ts, sourceFile, ({ kind, specifier, specifierNode }) => { + const resolvedPath = resolveRepoSpecifier(repoRoot, specifier, filePath); + if (!resolvedPath?.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) { + return; + } + entries.push({ + file: relativeFile, + line: toLine(sourceFile, specifierNode), + kind, + specifier, + resolvedPath, + reason: classifyResolvedExtensionReason(kind), + }); + }); + + return entries; +} + +export async function collectSrcExtensionImportBoundaryInventory() { + if (cachedInventoryPromise) { + return cachedInventoryPromise; + } + + cachedInventoryPromise = (async () => { + const files = (await collectTypeScriptFilesFromRoots(scanRoots)) + .filter((filePath) => !shouldSkipFile(filePath)) + .toSorted((left, right) => + normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)), + ); + return await collectTypeScriptInventory({ + ts, + files, + compareEntries, + collectEntries(sourceFile, filePath) { + return scanImportBoundaryViolations(sourceFile, filePath); + }, + }); + })(); + + try { + return await cachedInventoryPromise; + } catch (error) { + cachedInventoryPromise = null; + throw error; + } +} + +function formatInventoryHuman(inventory) { + if (inventory.length === 0) { + return "Rule: production src/** must not import bundled plugin files\nNo src import boundary violations found."; + } + + const lines = [ + "Rule: production src/** must not import bundled plugin files", + "Src extension import boundary inventory:", + ]; + let activeFile = ""; + for (const entry of inventory) { + if (entry.file !== activeFile) { + activeFile = entry.file; + lines.push(activeFile); + } + lines.push(` - line ${entry.line} [${entry.kind}] ${entry.reason}`); + lines.push(` specifier: ${entry.specifier}`); + lines.push(` resolved: ${entry.resolvedPath}`); + } + return lines.join("\n"); +} + +export async function main(argv = process.argv.slice(2), io) { + const streams = io ?? { stdout: process.stdout, stderr: process.stderr }; + const json = argv.includes("--json"); + const inventory = await collectSrcExtensionImportBoundaryInventory(); + + if (json) { + writeLine(streams.stdout, JSON.stringify(inventory, null, 2)); + } else { + writeLine(streams.stdout, formatInventoryHuman(inventory)); + writeLine( + streams.stdout, + inventory.length === 0 ? "Boundary is clean." : "Boundary has violations.", + ); + } + + return inventory.length === 0 ? 0 : 1; +} + +runAsScript(import.meta.url, main); diff --git a/test/src-extension-import-boundary.test.ts b/test/src-extension-import-boundary.test.ts new file mode 100644 index 00000000000..449364af468 --- /dev/null +++ b/test/src-extension-import-boundary.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + collectSrcExtensionImportBoundaryInventory, + main, +} from "../scripts/check-src-extension-import-boundary.mjs"; +import { createCapturedIo } from "./helpers/captured-io.js"; + +describe("src extension import boundary inventory", () => { + it("stays empty", async () => { + expect(await collectSrcExtensionImportBoundaryInventory()).toEqual([]); + }); + + it("produces stable sorted output", async () => { + const first = await collectSrcExtensionImportBoundaryInventory(); + const second = await collectSrcExtensionImportBoundaryInventory(); + + expect(second).toEqual(first); + }); + + it("script json output stays empty", async () => { + const captured = createCapturedIo(); + const exitCode = await main(["--json"], captured.io); + + expect(exitCode).toBe(0); + expect(captured.readStderr()).toBe(""); + expect(JSON.parse(captured.readStdout())).toEqual([]); + }); +});