diff --git a/scripts/check-plugin-extension-import-boundary.mjs b/scripts/check-plugin-extension-import-boundary.mjs index 41b11572dfd..27e619c77ca 100644 --- a/scripts/check-plugin-extension-import-boundary.mjs +++ b/scripts/check-plugin-extension-import-boundary.mjs @@ -7,7 +7,9 @@ import ts from "typescript"; import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs"; import { collectTypeScriptInventory, + createCachedAsync, diffInventoryEntries, + formatGroupedInventoryHuman, normalizeRepoPath, runBaselineInventoryCheck, resolveRepoSpecifier, @@ -28,8 +30,6 @@ const baselinePath = path.join( "fixtures", "plugin-extension-import-boundary-inventory.json", ); -let cachedInventoryPromise = null; -let cachedExpectedInventoryPromise = null; const bundledWebSearchProviders = new Set([ "brave", @@ -158,79 +158,42 @@ function shouldSkipFile(filePath) { ); } -export async function collectPluginExtensionImportBoundaryInventory() { - if (cachedInventoryPromise) { - return cachedInventoryPromise; - } +export const collectPluginExtensionImportBoundaryInventory = createCachedAsync(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), + ...scanWebSearchRegistrySmells(sourceFile, filePath), + ]; + }, + }); +}); - 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), - ...scanWebSearchRegistrySmells(sourceFile, filePath), - ]; - }, - }); - })(); - - try { - return await cachedInventoryPromise; - } catch (error) { - cachedInventoryPromise = null; - throw error; - } -} - -export async function readExpectedInventory() { - if (cachedExpectedInventoryPromise) { - return cachedExpectedInventoryPromise; - } - - cachedExpectedInventoryPromise = fs - .readFile(baselinePath, "utf8") - .then((contents) => JSON.parse(contents)); - try { - return await cachedExpectedInventoryPromise; - } catch (error) { - cachedExpectedInventoryPromise = null; - throw error; - } -} +export const readExpectedInventory = createCachedAsync(async () => + JSON.parse(await fs.readFile(baselinePath, "utf8")), +); export function diffInventory(expected, actual) { return diffInventoryEntries(expected, actual, compareEntries); } -function formatInventoryHuman(inventory) { - if (inventory.length === 0) { - return "Rule: src/plugins/** must not import bundled plugin files\nNo plugin import boundary violations found."; - } - - const lines = [ - "Rule: src/plugins/** must not import bundled plugin files", - "Plugin 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"); -} +const formatInventoryHuman = (inventory) => + formatGroupedInventoryHuman( + { + rule: "Rule: src/plugins/** must not import bundled plugin files", + cleanMessage: "No plugin import boundary violations found.", + inventoryTitle: "Plugin extension import boundary inventory:", + }, + inventory, + ); function formatEntry(entry) { return `${entry.file}:${entry.line} [${entry.kind}] ${entry.reason} (${entry.specifier} -> ${entry.resolvedPath})`; diff --git a/scripts/check-sdk-package-extension-import-boundary.mjs b/scripts/check-sdk-package-extension-import-boundary.mjs index 2931c700bd4..ba346e53d08 100644 --- a/scripts/check-sdk-package-extension-import-boundary.mjs +++ b/scripts/check-sdk-package-extension-import-boundary.mjs @@ -1,146 +1,21 @@ #!/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"; +import { createExtensionImportBoundaryChecker } from "./lib/extension-import-boundary-checker.mjs"; +import { runAsScript } from "./lib/ts-guard-utils.mjs"; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const scanRoots = resolveSourceRoots(repoRoot, ["src/plugin-sdk", "packages"]); -let cachedInventoryPromise = null; +const checker = createExtensionImportBoundaryChecker({ + roots: ["src/plugin-sdk", "packages"], + boundaryLabel: "sdk/package", + rule: "Rule: src/plugin-sdk/** and packages/** must not import bundled plugin files", + cleanMessage: "No sdk/package import boundary violations found.", + inventoryTitle: "SDK/package extension import boundary inventory:", + skipSourcesWithoutBundledPluginPrefix: true, + shouldSkipFile(relativeFile) { + return relativeFile.startsWith("packages/plugin-sdk/dist/"); + }, +}); -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 sdk/package boundary`; -} - -function shouldSkipFile(filePath) { - const relativeFile = normalizeRepoPath(repoRoot, filePath); - return relativeFile.startsWith("packages/plugin-sdk/dist/"); -} - -function shouldParseSource(source) { - return source.includes(BUNDLED_PLUGIN_PATH_PREFIX); -} - -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 collectSdkPackageExtensionImportBoundaryInventory() { - 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); - }, - shouldParseSource, - }); - })(); - - try { - return await cachedInventoryPromise; - } catch (error) { - cachedInventoryPromise = null; - throw error; - } -} - -function formatInventoryHuman(inventory) { - if (inventory.length === 0) { - return "Rule: src/plugin-sdk/** and packages/** must not import bundled plugin files\nNo sdk/package import boundary violations found."; - } - - const lines = [ - "Rule: src/plugin-sdk/** and packages/** must not import bundled plugin files", - "SDK/package 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 collectSdkPackageExtensionImportBoundaryInventory(); - - 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; -} +export const collectSdkPackageExtensionImportBoundaryInventory = checker.collectInventory; +export const main = checker.main; runAsScript(import.meta.url, main); diff --git a/scripts/check-src-extension-import-boundary.mjs b/scripts/check-src-extension-import-boundary.mjs index c457cf4d71c..d57ae6285cf 100644 --- a/scripts/check-src-extension-import-boundary.mjs +++ b/scripts/check-src-extension-import-boundary.mjs @@ -1,151 +1,26 @@ #!/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"; +import { createExtensionImportBoundaryChecker } from "./lib/extension-import-boundary-checker.mjs"; +import { runAsScript } 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 shouldParseSource(source) { - return source.includes(BUNDLED_PLUGIN_PATH_PREFIX); -} - -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); - }, - shouldParseSource, - }); - })(); - - 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.", +const checker = createExtensionImportBoundaryChecker({ + roots: ["src"], + boundaryLabel: "src", + rule: "Rule: production src/** must not import bundled plugin files", + cleanMessage: "No src import boundary violations found.", + inventoryTitle: "Src extension import boundary inventory:", + skipSourcesWithoutBundledPluginPrefix: true, + shouldSkipFile(relativeFile) { + return ( + relativeFile.endsWith(".test.ts") || + relativeFile.endsWith(".test.tsx") || + relativeFile.endsWith(".e2e.test.ts") || + relativeFile.endsWith(".e2e.test.tsx") ); - } + }, +}); - return inventory.length === 0 ? 0 : 1; -} +export const collectSrcExtensionImportBoundaryInventory = checker.collectInventory; +export const main = checker.main; runAsScript(import.meta.url, main); diff --git a/scripts/check-test-helper-extension-import-boundary.mjs b/scripts/check-test-helper-extension-import-boundary.mjs index 4abac264f1d..cae1c034818 100644 --- a/scripts/check-test-helper-extension-import-boundary.mjs +++ b/scripts/check-test-helper-extension-import-boundary.mjs @@ -1,134 +1,17 @@ #!/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"; +import { createExtensionImportBoundaryChecker } from "./lib/extension-import-boundary-checker.mjs"; +import { runAsScript } from "./lib/ts-guard-utils.mjs"; -const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const scanRoots = resolveSourceRoots(repoRoot, ["test/helpers"]); -let cachedInventoryPromise = null; +const checker = createExtensionImportBoundaryChecker({ + roots: ["test/helpers"], + boundaryLabel: "test helper", + rule: "Rule: test/helpers/** must not import bundled plugin files directly", + cleanMessage: "No test-helper import boundary violations found.", + inventoryTitle: "Test-helper extension import boundary inventory:", +}); -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 test helper boundary`; -} - -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 collectTestHelperExtensionImportBoundaryInventory() { - if (cachedInventoryPromise) { - return cachedInventoryPromise; - } - - cachedInventoryPromise = (async () => { - const files = (await collectTypeScriptFilesFromRoots(scanRoots)).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: test/helpers/** must not import bundled plugin files directly\nNo test-helper import boundary violations found."; - } - - const lines = [ - "Rule: test/helpers/** must not import bundled plugin files directly", - "Test-helper 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 collectTestHelperExtensionImportBoundaryInventory(); - - 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; -} +export const collectTestHelperExtensionImportBoundaryInventory = checker.collectInventory; +export const main = checker.main; runAsScript(import.meta.url, main); diff --git a/scripts/lib/bundled-plugin-build-entries-types.d.ts b/scripts/lib/bundled-plugin-build-entries-types.d.ts new file mode 100644 index 00000000000..f5326f85925 --- /dev/null +++ b/scripts/lib/bundled-plugin-build-entries-types.d.ts @@ -0,0 +1,23 @@ +export type BundledPluginBuildEntry = { + id: string; + hasPackageJson: boolean; + packageJson: unknown; + sourceEntries: string[]; +}; + +export type BundledPluginBuildEntryParams = { + cwd?: string; + env?: NodeJS.ProcessEnv; +}; + +export const NON_PACKAGED_BUNDLED_PLUGIN_DIRS: Set; +export function collectBundledPluginBuildEntries( + params?: BundledPluginBuildEntryParams, +): BundledPluginBuildEntry[]; +export function listBundledPluginBuildEntries( + params?: BundledPluginBuildEntryParams, +): Record; +export function listBundledPluginPackArtifacts(params?: BundledPluginBuildEntryParams): string[]; +export function listBundledPluginRuntimeDependencies( + params?: BundledPluginBuildEntryParams, +): string[]; diff --git a/scripts/lib/bundled-plugin-build-entries.d.mts b/scripts/lib/bundled-plugin-build-entries.d.mts index f5326f85925..062516d7524 100644 --- a/scripts/lib/bundled-plugin-build-entries.d.mts +++ b/scripts/lib/bundled-plugin-build-entries.d.mts @@ -1,23 +1 @@ -export type BundledPluginBuildEntry = { - id: string; - hasPackageJson: boolean; - packageJson: unknown; - sourceEntries: string[]; -}; - -export type BundledPluginBuildEntryParams = { - cwd?: string; - env?: NodeJS.ProcessEnv; -}; - -export const NON_PACKAGED_BUNDLED_PLUGIN_DIRS: Set; -export function collectBundledPluginBuildEntries( - params?: BundledPluginBuildEntryParams, -): BundledPluginBuildEntry[]; -export function listBundledPluginBuildEntries( - params?: BundledPluginBuildEntryParams, -): Record; -export function listBundledPluginPackArtifacts(params?: BundledPluginBuildEntryParams): string[]; -export function listBundledPluginRuntimeDependencies( - params?: BundledPluginBuildEntryParams, -): string[]; +export * from "./bundled-plugin-build-entries-types.js"; diff --git a/scripts/lib/bundled-plugin-build-entries.d.ts b/scripts/lib/bundled-plugin-build-entries.d.ts index f5326f85925..062516d7524 100644 --- a/scripts/lib/bundled-plugin-build-entries.d.ts +++ b/scripts/lib/bundled-plugin-build-entries.d.ts @@ -1,23 +1 @@ -export type BundledPluginBuildEntry = { - id: string; - hasPackageJson: boolean; - packageJson: unknown; - sourceEntries: string[]; -}; - -export type BundledPluginBuildEntryParams = { - cwd?: string; - env?: NodeJS.ProcessEnv; -}; - -export const NON_PACKAGED_BUNDLED_PLUGIN_DIRS: Set; -export function collectBundledPluginBuildEntries( - params?: BundledPluginBuildEntryParams, -): BundledPluginBuildEntry[]; -export function listBundledPluginBuildEntries( - params?: BundledPluginBuildEntryParams, -): Record; -export function listBundledPluginPackArtifacts(params?: BundledPluginBuildEntryParams): string[]; -export function listBundledPluginRuntimeDependencies( - params?: BundledPluginBuildEntryParams, -): string[]; +export * from "./bundled-plugin-build-entries-types.js"; diff --git a/scripts/lib/extension-import-boundary-checker.mjs b/scripts/lib/extension-import-boundary-checker.mjs new file mode 100644 index 00000000000..20e280a12e3 --- /dev/null +++ b/scripts/lib/extension-import-boundary-checker.mjs @@ -0,0 +1,104 @@ +import ts from "typescript"; +import { BUNDLED_PLUGIN_PATH_PREFIX } from "./bundled-plugin-paths.mjs"; +import { + collectTypeScriptInventory, + createCachedAsync, + formatGroupedInventoryHuman, + normalizeRepoPath, + resolveRepoSpecifier, + visitModuleSpecifiers, + writeLine, +} from "./guard-inventory-utils.mjs"; +import { + collectTypeScriptFilesFromRoots, + resolveRepoRoot, + resolveSourceRoots, + toLine, +} from "./ts-guard-utils.mjs"; + +const repoRoot = resolveRepoRoot(import.meta.url); + +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, boundaryLabel) { + const verb = + kind === "export" + ? "re-exports" + : kind === "dynamic-import" + ? "dynamically imports" + : "imports"; + return `${verb} bundled plugin file from ${boundaryLabel} boundary`; +} + +function scanImportBoundaryViolations(sourceFile, filePath, boundaryLabel) { + 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, boundaryLabel), + }); + }); + + return entries; +} + +export function createExtensionImportBoundaryChecker(params) { + const scanRoots = resolveSourceRoots(repoRoot, params.roots); + + const collectInventory = createCachedAsync(async () => { + const files = (await collectTypeScriptFilesFromRoots(scanRoots)) + .filter((filePath) => !params.shouldSkipFile?.(normalizeRepoPath(repoRoot, filePath))) + .toSorted((left, right) => + normalizeRepoPath(repoRoot, left).localeCompare(normalizeRepoPath(repoRoot, right)), + ); + return await collectTypeScriptInventory({ + ts, + files, + compareEntries, + collectEntries(sourceFile, filePath) { + return scanImportBoundaryViolations(sourceFile, filePath, params.boundaryLabel); + }, + shouldParseSource: params.skipSourcesWithoutBundledPluginPrefix + ? (source) => source.includes(BUNDLED_PLUGIN_PATH_PREFIX) + : undefined, + }); + }); + + 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 collectInventory(); + + if (json) { + writeLine(streams.stdout, JSON.stringify(inventory, null, 2)); + } else { + writeLine(streams.stdout, formatGroupedInventoryHuman(params, inventory)); + writeLine( + streams.stdout, + inventory.length === 0 ? "Boundary is clean." : "Boundary has violations.", + ); + } + + return inventory.length === 0 ? 0 : 1; + } + + return { collectInventory, main }; +} diff --git a/scripts/lib/guard-inventory-utils.mjs b/scripts/lib/guard-inventory-utils.mjs index 6ba8d836e51..b279c95c96c 100644 --- a/scripts/lib/guard-inventory-utils.mjs +++ b/scripts/lib/guard-inventory-utils.mjs @@ -74,6 +74,42 @@ export function writeLine(stream, text) { stream.write(`${text}\n`); } +export function createCachedAsync(factory) { + let cachedPromise = null; + return async function getCachedValue() { + if (cachedPromise) { + return cachedPromise; + } + + cachedPromise = factory(); + try { + return await cachedPromise; + } catch (error) { + cachedPromise = null; + throw error; + } + }; +} + +export function formatGroupedInventoryHuman(params, inventory) { + if (inventory.length === 0) { + return `${params.rule}\n${params.cleanMessage}`; + } + + const lines = [params.rule, params.inventoryTitle]; + 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 collectTypeScriptInventory(params) { const inventory = []; const scriptKind = params.scriptKind ?? params.ts.ScriptKind.TS; diff --git a/scripts/lib/optional-bundled-clusters-types.d.ts b/scripts/lib/optional-bundled-clusters-types.d.ts new file mode 100644 index 00000000000..7ba3dddcb59 --- /dev/null +++ b/scripts/lib/optional-bundled-clusters-types.d.ts @@ -0,0 +1,11 @@ +export const optionalBundledClusters: string[]; +export const optionalBundledClusterSet: Set; +export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED"; +export function isOptionalBundledCluster(cluster: string): boolean; +export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean; +export function hasReleasedBundledInstall(packageJson: unknown): boolean; +export function shouldBuildBundledCluster( + cluster: string, + env?: NodeJS.ProcessEnv, + options?: { packageJson?: unknown }, +): boolean; diff --git a/scripts/lib/optional-bundled-clusters.d.mts b/scripts/lib/optional-bundled-clusters.d.mts index 7ba3dddcb59..af63b7699a0 100644 --- a/scripts/lib/optional-bundled-clusters.d.mts +++ b/scripts/lib/optional-bundled-clusters.d.mts @@ -1,11 +1 @@ -export const optionalBundledClusters: string[]; -export const optionalBundledClusterSet: Set; -export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED"; -export function isOptionalBundledCluster(cluster: string): boolean; -export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean; -export function hasReleasedBundledInstall(packageJson: unknown): boolean; -export function shouldBuildBundledCluster( - cluster: string, - env?: NodeJS.ProcessEnv, - options?: { packageJson?: unknown }, -): boolean; +export * from "./optional-bundled-clusters-types.js"; diff --git a/scripts/lib/optional-bundled-clusters.d.ts b/scripts/lib/optional-bundled-clusters.d.ts index 7ba3dddcb59..af63b7699a0 100644 --- a/scripts/lib/optional-bundled-clusters.d.ts +++ b/scripts/lib/optional-bundled-clusters.d.ts @@ -1,11 +1 @@ -export const optionalBundledClusters: string[]; -export const optionalBundledClusterSet: Set; -export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED"; -export function isOptionalBundledCluster(cluster: string): boolean; -export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean; -export function hasReleasedBundledInstall(packageJson: unknown): boolean; -export function shouldBuildBundledCluster( - cluster: string, - env?: NodeJS.ProcessEnv, - options?: { packageJson?: unknown }, -): boolean; +export * from "./optional-bundled-clusters-types.js"; diff --git a/scripts/lib/package-root-args.mjs b/scripts/lib/package-root-args.mjs new file mode 100644 index 00000000000..41751c9fe48 --- /dev/null +++ b/scripts/lib/package-root-args.mjs @@ -0,0 +1,23 @@ +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const defaultPackageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +export function parsePackageRootArg(argv, envName) { + let packageRoot = process.env[envName]; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--package-root") { + packageRoot = argv[index + 1]; + index += 1; + continue; + } + if (arg?.startsWith("--package-root=")) { + packageRoot = arg.slice("--package-root=".length); + continue; + } + throw new Error(`unknown argument: ${arg}`); + } + return { packageRoot: path.resolve(packageRoot ?? defaultPackageRoot) }; +} diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index 99503b8c4c3..1e8bd4b90af 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -40,23 +40,6 @@ const isBuildRelevantSourcePath = (relativePath) => { return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath); }; -export const isBuildRelevantRunNodePath = (repoPath) => { - const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, ""); - if (ignoredRunNodeRepoPaths.has(normalizedPath)) { - return false; - } - if (runNodeConfigFiles.includes(normalizedPath)) { - return true; - } - if (normalizedPath.startsWith("src/")) { - return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); - } - if (normalizedPath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) { - return isBuildRelevantSourcePath(normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length)); - } - return false; -}; - const isRestartRelevantExtensionPath = (relativePath) => { const normalizedPath = normalizePath(relativePath); if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) { @@ -65,7 +48,7 @@ const isRestartRelevantExtensionPath = (relativePath) => { return isBuildRelevantSourcePath(normalizedPath); }; -export const isRestartRelevantRunNodePath = (repoPath) => { +const isRelevantRunNodePath = (repoPath, isRelevantBundledPluginPath) => { const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, ""); if (ignoredRunNodeRepoPaths.has(normalizedPath)) { return false; @@ -77,11 +60,17 @@ export const isRestartRelevantRunNodePath = (repoPath) => { return !isIgnoredSourcePath(normalizedPath.slice("src/".length)); } if (normalizedPath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) { - return isRestartRelevantExtensionPath(normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length)); + return isRelevantBundledPluginPath(normalizedPath.slice(BUNDLED_PLUGIN_PATH_PREFIX.length)); } return false; }; +export const isBuildRelevantRunNodePath = (repoPath) => + isRelevantRunNodePath(repoPath, isBuildRelevantSourcePath); + +export const isRestartRelevantRunNodePath = (repoPath) => + isRelevantRunNodePath(repoPath, isRestartRelevantExtensionPath); + const statMtime = (filePath, fsImpl = fs) => { try { return fsImpl.statSync(filePath).mtimeMs; diff --git a/scripts/run-vitest.mjs b/scripts/run-vitest.mjs index ef656d309b3..a59ccbeb0a3 100644 --- a/scripts/run-vitest.mjs +++ b/scripts/run-vitest.mjs @@ -164,22 +164,16 @@ export function forwardVitestOutput(stream, target, shouldSuppressLine = () => f }); } -function main(argv = process.argv.slice(2), env = process.env) { - if (argv.length === 0) { - console.error("usage: node scripts/run-vitest.mjs "); - process.exit(1); - } - - const spawnParams = resolveVitestSpawnParams(env); +export function spawnWatchedVitestProcess({ pnpmArgs, spawnParams, env, label }) { const child = spawnPnpmRunner({ - pnpmArgs: ["exec", "node", ...resolveVitestNodeArgs(env), resolveVitestCliEntry(), ...argv], + pnpmArgs, ...spawnParams, }); const teardownChildCleanup = installVitestProcessGroupCleanup({ child }); const teardownNoOutputWatchdog = installVitestNoOutputWatchdog({ streams: [child.stdout, child.stderr], timeoutMs: resolveVitestNoOutputTimeoutMs(env), - label: argv.join(" "), + label, log: (message) => { console.error(message); }, @@ -201,9 +195,30 @@ function main(argv = process.argv.slice(2), env = process.env) { forwardVitestOutput(child.stdout, process.stdout); forwardVitestOutput(child.stderr, process.stderr, shouldSuppressVitestStderrLine); + return { + child, + teardown: () => { + teardownChildCleanup(); + teardownNoOutputWatchdog(); + }, + }; +} + +function main(argv = process.argv.slice(2), env = process.env) { + if (argv.length === 0) { + console.error("usage: node scripts/run-vitest.mjs "); + process.exit(1); + } + + const { child, teardown } = spawnWatchedVitestProcess({ + pnpmArgs: ["exec", "node", ...resolveVitestNodeArgs(env), resolveVitestCliEntry(), ...argv], + spawnParams: resolveVitestSpawnParams(env), + env, + label: argv.join(" "), + }); + child.on("exit", (code, signal) => { - teardownChildCleanup(); - teardownNoOutputWatchdog(); + teardown(); if (signal) { process.kill(process.pid, signal); return; @@ -212,8 +227,7 @@ function main(argv = process.argv.slice(2), env = process.env) { }); child.on("error", (error) => { - teardownChildCleanup(); - teardownNoOutputWatchdog(); + teardown(); console.error(error); process.exit(1); }); diff --git a/scripts/test-built-bundled-channel-entry-smoke.mjs b/scripts/test-built-bundled-channel-entry-smoke.mjs index 8b12a9424bc..de8268c4817 100644 --- a/scripts/test-built-bundled-channel-entry-smoke.mjs +++ b/scripts/test-built-bundled-channel-entry-smoke.mjs @@ -4,35 +4,17 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { parsePackageRootArg } from "./lib/package-root-args.mjs"; import { installProcessWarningFilter } from "./process-warning-filter.mjs"; installProcessWarningFilter(); process.env.OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK ??= "1"; -function parseArgs(argv) { - let packageRoot = process.env.OPENCLAW_BUNDLED_CHANNEL_SMOKE_ROOT; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === "--package-root") { - packageRoot = argv[index + 1]; - index += 1; - continue; - } - if (arg?.startsWith("--package-root=")) { - packageRoot = arg.slice("--package-root=".length); - continue; - } - throw new Error(`unknown argument: ${arg}`); - } - return { - packageRoot: path.resolve( - packageRoot ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), - ), - }; -} - -const { packageRoot } = parseArgs(process.argv.slice(2)); +const { packageRoot } = parsePackageRootArg( + process.argv.slice(2), + "OPENCLAW_BUNDLED_CHANNEL_SMOKE_ROOT", +); const distExtensionsRoot = path.join(packageRoot, "dist", "extensions"); const installedLayoutEnv = "OPENCLAW_BUNDLED_CHANNEL_SMOKE_INSTALLED_LAYOUT"; diff --git a/scripts/test-built-bundled-runtime-deps.mjs b/scripts/test-built-bundled-runtime-deps.mjs index e8f17e631c4..0e86b08471e 100644 --- a/scripts/test-built-bundled-runtime-deps.mjs +++ b/scripts/test-built-bundled-runtime-deps.mjs @@ -1,37 +1,18 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { collectBuiltBundledPluginStagedRuntimeDependencyErrors, collectBundledPluginRootRuntimeMirrorErrors, collectBundledPluginRuntimeDependencySpecs, collectRootDistBundledRuntimeMirrors, } from "./lib/bundled-plugin-root-runtime-mirrors.mjs"; +import { parsePackageRootArg } from "./lib/package-root-args.mjs"; -function parseArgs(argv) { - let packageRoot = process.env.OPENCLAW_BUNDLED_RUNTIME_DEPS_ROOT; - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - if (arg === "--package-root") { - packageRoot = argv[index + 1]; - index += 1; - continue; - } - if (arg?.startsWith("--package-root=")) { - packageRoot = arg.slice("--package-root=".length); - continue; - } - throw new Error(`unknown argument: ${arg}`); - } - return { - packageRoot: path.resolve( - packageRoot ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."), - ), - }; -} - -const { packageRoot } = parseArgs(process.argv.slice(2)); +const { packageRoot } = parsePackageRootArg( + process.argv.slice(2), + "OPENCLAW_BUNDLED_RUNTIME_DEPS_ROOT", +); const rootPackageJsonPath = path.join(packageRoot, "package.json"); const builtPluginsDir = path.join(packageRoot, "dist", "extensions"); diff --git a/scripts/test-projects.mjs b/scripts/test-projects.mjs index 916ec91343d..15b6c4a3cdd 100644 --- a/scripts/test-projects.mjs +++ b/scripts/test-projects.mjs @@ -1,14 +1,11 @@ import fs from "node:fs"; import { acquireLocalHeavyCheckLockSync } from "./lib/local-heavy-check-runtime.mjs"; import { isCiLikeEnv, resolveLocalFullSuiteProfile } from "./lib/vitest-local-scheduling.mjs"; -import { spawnPnpmRunner } from "./pnpm-runner.mjs"; import { - forwardVitestOutput, - installVitestNoOutputWatchdog, resolveVitestCliEntry, resolveVitestNodeArgs, - resolveVitestNoOutputTimeoutMs, - shouldSuppressVitestStderrLine, + resolveVitestSpawnParams, + spawnWatchedVitestProcess, } from "./run-vitest.mjs"; import { applyParallelVitestCachePaths, @@ -20,11 +17,6 @@ import { shouldAcquireLocalHeavyCheckLock, writeVitestIncludeFile, } from "./test-projects.test-support.mjs"; -import { - forwardSignalToVitestProcessGroup, - installVitestProcessGroupCleanup, - shouldUseDetachedVitestProcessGroup, -} from "./vitest-process-group.mjs"; // Keep this shim so `pnpm test -- src/foo.test.ts` still forwards filters // cleanly instead of leaking pnpm's passthrough sentinel to Vitest. @@ -107,50 +99,24 @@ function runVitestSpec(spec) { writeVitestIncludeFile(spec.includeFilePath, spec.includePatterns); } return new Promise((resolve, reject) => { - const child = spawnPnpmRunner({ - cwd: process.cwd(), - detached: shouldUseDetachedVitestProcessGroup(), + const { child, teardown } = spawnWatchedVitestProcess({ pnpmArgs: spec.pnpmArgs, env: spec.env, - stdio: ["inherit", "pipe", "pipe"], - }); - const teardownChildCleanup = installVitestProcessGroupCleanup({ child }); - const teardownNoOutputWatchdog = installVitestNoOutputWatchdog({ - streams: [child.stdout, child.stderr], - timeoutMs: resolveVitestNoOutputTimeoutMs(spec.env), label: spec.config, - log: (message) => { - console.error(message); - }, - onTimeout: () => { - forwardSignalToVitestProcessGroup({ - child, - signal: "SIGTERM", - kill: process.kill.bind(process), - }); - }, - onForceKill: () => { - forwardSignalToVitestProcessGroup({ - child, - signal: "SIGKILL", - kill: process.kill.bind(process), - }); + spawnParams: { + cwd: process.cwd(), + ...resolveVitestSpawnParams(spec.env), }, }); - forwardVitestOutput(child.stdout, process.stdout); - forwardVitestOutput(child.stderr, process.stderr, shouldSuppressVitestStderrLine); - child.on("exit", (code, signal) => { - teardownChildCleanup(); - teardownNoOutputWatchdog(); + teardown(); cleanupVitestRunSpec(spec); resolve({ code: code ?? 1, signal }); }); child.on("error", (error) => { - teardownChildCleanup(); - teardownNoOutputWatchdog(); + teardown(); cleanupVitestRunSpec(spec); reject(error); }); @@ -171,6 +137,18 @@ function applyDefaultParallelVitestWorkerBudget(specs, env) { })); } +async function runLoggedVitestSpec(spec) { + console.error(`[test] starting ${spec.config}`); + const result = await runVitestSpec(spec); + if (result.signal) { + console.error(`[test] ${spec.config} exited by signal ${result.signal}`); + releaseLockOnce(); + process.kill(process.pid, result.signal); + return null; + } + return result; +} + function orderFullSuiteSpecsForParallelRun(specs) { return specs.toSorted((a, b) => { const weightDelta = @@ -194,12 +172,8 @@ async function runVitestSpecsParallel(specs, concurrency) { if (!spec) { return; } - console.error(`[test] starting ${spec.config}`); - const result = await runVitestSpec(spec); - if (result.signal) { - console.error(`[test] ${spec.config} exited by signal ${result.signal}`); - releaseLockOnce(); - process.kill(process.pid, result.signal); + const result = await runLoggedVitestSpec(spec); + if (!result) { return; } if (result.code !== 0) { @@ -292,12 +266,8 @@ async function main() { let exitCode = 0; for (const spec of runSpecs) { - console.error(`[test] starting ${spec.config}`); - const result = await runVitestSpec(spec); - if (result.signal) { - console.error(`[test] ${spec.config} exited by signal ${result.signal}`); - releaseLockOnce(); - process.kill(process.pid, result.signal); + const result = await runLoggedVitestSpec(spec); + if (!result) { return; } if (result.code !== 0) {