diff --git a/extensions/matrix/src/cli.ts b/extensions/matrix/src/cli.ts index aece66537cb..497520951d7 100644 --- a/extensions/matrix/src/cli.ts +++ b/extensions/matrix/src/cli.ts @@ -26,6 +26,21 @@ import { matrixSetupAdapter } from "./setup-core.js"; import type { CoreConfig } from "./types.js"; let matrixCliExitScheduled = false; +type MatrixActionClientModule = typeof import("./matrix/actions/client.js"); +type MatrixDirectManagementModule = typeof import("./matrix/direct-management.js"); + +let matrixActionClientModulePromise: Promise | undefined; +let matrixDirectManagementModulePromise: Promise | undefined; + +function loadMatrixActionClientModule(): Promise { + matrixActionClientModulePromise ??= import("./matrix/actions/client.js"); + return matrixActionClientModulePromise; +} + +function loadMatrixDirectManagementModule(): Promise { + matrixDirectManagementModulePromise ??= import("./matrix/direct-management.js"); + return matrixDirectManagementModulePromise; +} export function resetMatrixCliStateForTests(): void { matrixCliExitScheduled = false; @@ -332,8 +347,8 @@ async function inspectMatrixDirectRoom(params: { userId: string; }): Promise { const [{ withResolvedActionClient }, { inspectMatrixDirectRooms }] = await Promise.all([ - import("./matrix/actions/client.js"), - import("./matrix/direct-management.js"), + loadMatrixActionClientModule(), + loadMatrixDirectManagementModule(), ]); return await withResolvedActionClient( { accountId: params.accountId }, @@ -363,8 +378,8 @@ async function repairMatrixDirectRoom(params: { const cfg = getMatrixRuntime().config.loadConfig() as CoreConfig; const account = resolveMatrixAccount({ cfg, accountId: params.accountId }); const [{ withStartedActionClient }, { repairMatrixDirectRooms }] = await Promise.all([ - import("./matrix/actions/client.js"), - import("./matrix/direct-management.js"), + loadMatrixActionClientModule(), + loadMatrixDirectManagementModule(), ]); return await withStartedActionClient({ accountId: params.accountId }, async (client) => { const repaired = await repairMatrixDirectRooms({ diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index be2930fa97a..3437f3db2f4 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -85,14 +85,21 @@ import { resolveTelegramToken } from "./token.js"; import { parseTelegramTopicConversation } from "./topic-conversation.js"; type TelegramSendFn = typeof import("./send.js").sendMessageTelegram; +type TelegramUpdateOffsetRuntime = typeof import("../update-offset-runtime-api.js"); let telegramSendModulePromise: Promise | undefined; +let telegramUpdateOffsetRuntimePromise: Promise | undefined; async function loadTelegramSendModule() { telegramSendModulePromise ??= import("./send.js"); return await telegramSendModulePromise; } +async function loadTelegramUpdateOffsetRuntime() { + telegramUpdateOffsetRuntimePromise ??= import("../update-offset-runtime-api.js"); + return await telegramUpdateOffsetRuntimePromise; +} + type TelegramSendOptions = NonNullable[2]>; function resolveTelegramProbe() { @@ -734,12 +741,12 @@ export const telegramPlugin = createChatChannelPlugin({ const previousToken = resolveTelegramAccount({ cfg: prevCfg, accountId }).token.trim(); const nextToken = resolveTelegramAccount({ cfg: nextCfg, accountId }).token.trim(); if (previousToken !== nextToken) { - const { deleteTelegramUpdateOffset } = await import("../update-offset-runtime-api.js"); + const { deleteTelegramUpdateOffset } = await loadTelegramUpdateOffsetRuntime(); await deleteTelegramUpdateOffset({ accountId }); } }, onAccountRemoved: async ({ accountId }) => { - const { deleteTelegramUpdateOffset } = await import("../update-offset-runtime-api.js"); + const { deleteTelegramUpdateOffset } = await loadTelegramUpdateOffsetRuntime(); await deleteTelegramUpdateOffset({ accountId }); }, }, diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index c08f0f34f7a..0ab5233a06c 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -60,6 +60,7 @@ const ZALO_TYPING_TIMEOUT_MS = 5_000; type ZaloCoreRuntime = ReturnType; type ZaloStatusSink = (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +type ZaloWebhookModule = typeof import("./monitor.webhook.js"); type ZaloProcessingContext = { token: string; account: ResolvedZaloAccount; @@ -78,6 +79,13 @@ type ZaloUpdateProcessingParams = ZaloProcessingContext & { update: ZaloUpdate; mediaMaxMb: number; }; + +let zaloWebhookModulePromise: Promise | undefined; + +function loadZaloWebhookModule(): Promise { + zaloWebhookModulePromise ??= import("./monitor.webhook.js"); + return zaloWebhookModulePromise; +} type ZaloMessagePipelineParams = ZaloProcessingContext & { message: ZaloMessage; text?: string; @@ -130,7 +138,7 @@ export async function handleZaloWebhookRequest( res: ServerResponse, ): Promise { const { handleZaloWebhookRequest: handleZaloWebhookRequestInternal } = - await import("./monitor.webhook.js"); + await loadZaloWebhookModule(); return await handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => { await processUpdate({ update, @@ -657,7 +665,7 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise< try { if (useWebhook) { - const { registerZaloWebhookTarget } = await import("./monitor.webhook.js"); + const { registerZaloWebhookTarget } = await loadZaloWebhookModule(); if (!webhookUrl || !webhookSecret) { throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode"); } diff --git a/package.json b/package.json index 62c8da41b97..4230b166798 100644 --- a/package.json +++ b/package.json @@ -1314,6 +1314,7 @@ "lint:plugins:plugin-sdk-subpaths-exported": "node scripts/check-plugin-sdk-subpath-exports.mjs", "lint:swift": "swiftlint lint --config .swiftlint.yml && (cd apps/ios && swiftlint lint --config .swiftlint.yml)", "lint:tmp:channel-agnostic-boundaries": "node scripts/check-channel-agnostic-boundaries.mjs", + "lint:tmp:dynamic-import-warts": "node scripts/check-dynamic-import-warts.mjs", "lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs", "lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs", "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", diff --git a/scripts/check-dynamic-import-warts.mjs b/scripts/check-dynamic-import-warts.mjs new file mode 100644 index 00000000000..9744d6d554a --- /dev/null +++ b/scripts/check-dynamic-import-warts.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import ts from "typescript"; +import { + collectTypeScriptFilesFromRoots, + resolveRepoRoot, + runAsScript, + toLine, +} from "./lib/ts-guard-utils.mjs"; + +const repoRoot = resolveRepoRoot(import.meta.url); +const defaultRoots = [path.join(repoRoot, "src"), path.join(repoRoot, "extensions")]; + +function readStringLiteral(node) { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return node.text; + } + return null; +} + +function isTypeOnlyImportDeclaration(node) { + const clause = node.importClause; + if (!clause) { + return false; + } + if (clause.isTypeOnly) { + return true; + } + if (clause.name) { + return false; + } + const bindings = clause.namedBindings; + return ( + Boolean(bindings) && + ts.isNamedImports(bindings) && + bindings.elements.length > 0 && + bindings.elements.every((element) => element.isTypeOnly) + ); +} + +function isIgnoredTestHelperContent(content) { + return /\bfrom\s+["']vitest["']/.test(content) || /\bfrom\s+["']@vitest\//.test(content); +} + +function isIgnoredTestHelperPath(filePath) { + const normalized = filePath.split(path.sep).join("/"); + const base = path.basename(filePath); + return ( + normalized.includes("/test/") || + /(?:^|[./-])test(?:[./-]|$)/.test(base) || + base.includes("test-support") || + base.includes("test-harness") || + base.includes("test-helper") || + base.includes("test-mocks") + ); +} + +export function findDynamicImportAdvisories(content, fileName = "source.ts") { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const staticRuntimeImports = new Map(); + const dynamicImports = new Map(); + + const addLine = (map, specifier, line) => { + const lines = map.get(specifier) ?? []; + lines.push(line); + map.set(specifier, lines); + }; + + const visit = (node) => { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + !isTypeOnlyImportDeclaration(node) + ) { + addLine(staticRuntimeImports, node.moduleSpecifier.text, toLine(sourceFile, node)); + } + + if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length > 0 + ) { + const specifier = readStringLiteral(node.arguments[0]); + if (specifier) { + addLine(dynamicImports, specifier, toLine(sourceFile, node)); + } + } + + ts.forEachChild(node, visit); + }; + + visit(sourceFile); + + const advisories = []; + for (const [specifier, dynamicLines] of dynamicImports) { + const staticLines = staticRuntimeImports.get(specifier); + if (staticLines?.length) { + advisories.push({ + line: dynamicLines[0], + reason: `runtime static + dynamic import of "${specifier}" (static line ${staticLines[0]})`, + }); + } + if (dynamicLines.length > 1) { + advisories.push({ + line: dynamicLines[0], + reason: `repeated direct dynamic import of "${specifier}" (${dynamicLines.length} callsites: ${dynamicLines.join(", ")})`, + }); + } + } + return advisories; +} + +export async function collectDynamicImportAdvisories(options = {}) { + const roots = options.roots ?? defaultRoots; + const files = await collectTypeScriptFilesFromRoots(roots, { + extraTestSuffixes: [".suite.ts"], + }); + const advisories = []; + for (const filePath of files) { + if (isIgnoredTestHelperPath(filePath)) { + continue; + } + const content = await fs.readFile(filePath, "utf8"); + if (isIgnoredTestHelperContent(content)) { + continue; + } + for (const advisory of findDynamicImportAdvisories(content, filePath)) { + advisories.push({ + path: path.relative(repoRoot, filePath), + ...advisory, + }); + } + } + return advisories; +} + +export async function main(argv = process.argv.slice(2)) { + const fail = argv.includes("--fail"); + const json = argv.includes("--json"); + const advisories = await collectDynamicImportAdvisories(); + + if (json) { + console.log(JSON.stringify({ advisories }, null, 2)); + } else if (advisories.length === 0) { + console.log("No dynamic import advisories found."); + } else { + console.log(`Dynamic import advisories (${advisories.length}):`); + for (const advisory of advisories) { + console.log(`- ${advisory.path}:${advisory.line} ${advisory.reason}`); + } + console.log("Advisory only. Use --fail when ratcheting this into a hard check."); + } + + if (fail && advisories.length > 0) { + process.exit(1); + } +} + +runAsScript(import.meta.url, main); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index a3f79754da0..e10849cdda2 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -73,11 +73,20 @@ function isOpenAIProvider(provider?: string) { const MEMORY_FLUSH_ALLOWED_TOOL_NAMES = new Set(["read", "write"]); +type BashToolsModule = typeof import("./bash-tools.js"); + +let bashToolsModulePromise: Promise | undefined; + +function loadBashToolsModule(): Promise { + bashToolsModulePromise ??= import("./bash-tools.js"); + return bashToolsModulePromise; +} + function createLazyExecTool(defaults?: ExecToolDefaults): AnyAgentTool { let loadedTool: AnyAgentTool | undefined; const loadTool = async () => { if (!loadedTool) { - const { createExecTool } = await import("./bash-tools.js"); + const { createExecTool } = await loadBashToolsModule(); loadedTool = createExecTool(defaults) as unknown as AnyAgentTool; } return loadedTool; @@ -103,7 +112,7 @@ function createLazyProcessTool(defaults?: ProcessToolDefaults): AnyAgentTool { let loadedTool: AnyAgentTool | undefined; const loadTool = async () => { if (!loadedTool) { - const { createProcessTool } = await import("./bash-tools.js"); + const { createProcessTool } = await loadBashToolsModule(); loadedTool = createProcessTool(defaults) as unknown as AnyAgentTool; } return loadedTool; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 7ace5b2f391..347acb8908b 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -15,6 +15,22 @@ import type { ChannelChoice } from "../onboard-types.js"; import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js"; import { channelLabel, requireValidConfigFileSnapshot, shouldUseWizard } from "./shared.js"; +type ChannelSetupPluginInstallModule = typeof import("../channel-setup/plugin-install.js"); +type OnboardChannelsModule = typeof import("../onboard-channels.js"); + +let channelSetupPluginInstallPromise: Promise | undefined; +let onboardChannelsPromise: Promise | undefined; + +function loadChannelSetupPluginInstall(): Promise { + channelSetupPluginInstallPromise ??= import("../channel-setup/plugin-install.js"); + return channelSetupPluginInstallPromise; +} + +function loadOnboardChannels(): Promise { + onboardChannelsPromise ??= import("../onboard-channels.js"); + return onboardChannelsPromise; +} + export type ChannelsAddOptions = { channel?: string; account?: string; @@ -57,7 +73,7 @@ export async function channelsAddCommand( if (useWizard) { const [{ buildAgentSummaries }, onboardChannels] = await Promise.all([ import("../agents.config.js"), - import("../onboard-channels.js"), + loadOnboardChannels(), ]); const prompter = createClackPrompter(); const postWriteHooks = onboardChannels.createChannelOnboardingPostWriteHookCollector(); @@ -206,7 +222,7 @@ export async function channelsAddCommand( return existing; } const { loadChannelSetupPluginRegistrySnapshotForChannel } = - await import("../channel-setup/plugin-install.js"); + await loadChannelSetupPluginInstall(); const snapshot = loadChannelSetupPluginRegistrySnapshotForChannel({ cfg: nextConfig, runtime, @@ -230,8 +246,7 @@ export async function channelsAddCommand( workspaceDir, }) ) { - const { ensureChannelSetupPluginInstalled } = - await import("../channel-setup/plugin-install.js"); + const { ensureChannelSetupPluginInstalled } = await loadChannelSetupPluginInstall(); const prompter = createClackPrompter(); const result = await ensureChannelSetupPluginInstalled({ cfg: nextConfig, @@ -360,7 +375,7 @@ export async function channelsAddCommand( runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`); const afterAccountConfigWritten = plugin.setup?.afterAccountConfigWritten; if (afterAccountConfigWritten) { - const { runCollectedChannelOnboardingPostWriteHooks } = await import("../onboard-channels.js"); + const { runCollectedChannelOnboardingPostWriteHooks } = await loadOnboardChannels(); await runCollectedChannelOnboardingPostWriteHooks({ hooks: [ { diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 91de71280f4..96036c1b2f4 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -50,6 +50,14 @@ import { promptRemoteGatewayConfig } from "./onboard-remote.js"; import { setupSkills } from "./onboard-skills.js"; type ConfigureSectionChoice = WizardSection | "__continue"; +type SetupPluginConfigModule = typeof import("../wizard/setup.plugin-config.js"); + +let setupPluginConfigModulePromise: Promise | undefined; + +function loadSetupPluginConfigModule(): Promise { + setupPluginConfigModulePromise ??= import("../wizard/setup.plugin-config.js"); + return setupPluginConfigModulePromise; +} function mergeWizardConfigOntoLatest(current: unknown, base: unknown, next: unknown): unknown { if (isDeepStrictEqual(next, base)) { @@ -617,7 +625,7 @@ export async function runConfigureWizard( } if (selected.includes("plugins")) { - const { configurePluginConfig } = await import("../wizard/setup.plugin-config.js"); + const { configurePluginConfig } = await loadSetupPluginConfigModule(); nextConfig = await configurePluginConfig({ config: nextConfig, prompter, @@ -683,7 +691,7 @@ export async function runConfigureWizard( } if (choice === "plugins") { - const { configurePluginConfig } = await import("../wizard/setup.plugin-config.js"); + const { configurePluginConfig } = await loadSetupPluginConfigModule(); nextConfig = await configurePluginConfig({ config: nextConfig, prompter, diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index 6e4a2dd15a1..934c88ce819 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -1,5 +1,14 @@ import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +type ChannelDoctorModule = typeof import("./channel-doctor.js"); + +let channelDoctorModulePromise: Promise | undefined; + +function loadChannelDoctorModule(): Promise { + channelDoctorModulePromise ??= import("./channel-doctor.js"); + return channelDoctorModulePromise; +} + function hasRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } @@ -89,7 +98,7 @@ export async function collectDoctorPreviewWarnings(params: { } if (hasChannelConfig) { - const { collectChannelDoctorPreviewWarnings } = await import("./channel-doctor.js"); + const { collectChannelDoctorPreviewWarnings } = await loadChannelDoctorModule(); const channelDoctorWarnings = await collectChannelDoctorPreviewWarnings({ cfg: params.cfg, doctorFixCommand: params.doctorFixCommand, @@ -144,7 +153,7 @@ export async function collectDoctorPreviewWarnings(params: { } if (hasChannelConfig) { - const { collectChannelDoctorEmptyAllowlistExtraWarnings } = await import("./channel-doctor.js"); + const { collectChannelDoctorEmptyAllowlistExtraWarnings } = await loadChannelDoctorModule(); const { scanEmptyAllowlistPolicyWarnings } = await import("./empty-allowlist-scan.js"); const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(params.cfg, { doctorFixCommand: params.doctorFixCommand, diff --git a/src/commands/health.ts b/src/commands/health.ts index c60f6bcc5e1..4766d022377 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -36,6 +36,15 @@ export type { const DEFAULT_TIMEOUT_MS = 10_000; +type ConfigModule = typeof import("../config/config.js"); + +let configModulePromise: Promise | undefined; + +function loadConfigModule(): Promise { + configModulePromise ??= import("../config/config.js"); + return configModulePromise; +} + const debugHealth = (...args: unknown[]) => { if (isTruthyEnvValue(process.env.OPENCLAW_DEBUG_HEALTH)) { console.warn("[health:debug]", ...args); @@ -208,7 +217,7 @@ export async function getHealthSnapshot(params?: { probe?: boolean; }): Promise { const timeoutMs = params?.timeoutMs; - const { loadConfig } = await import("../config/config.js"); + const { loadConfig } = await loadConfigModule(); const cfg = loadConfig(); const { defaultAgentId, ordered } = resolveAgentOrder(cfg); const channelBindings = buildChannelAccountBindings(cfg); @@ -636,6 +645,6 @@ export async function healthCommand( } async function readBestEffortHealthConfig(): Promise { - const { readBestEffortConfig } = await import("../config/config.js"); + const { readBestEffortConfig } = await loadConfigModule(); return await readBestEffortConfig(); } diff --git a/src/commands/setup.ts b/src/commands/setup.ts index bbd9ce11085..3c2e94e0b76 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -32,8 +32,31 @@ type SetupCommandDeps = { writeConfigFile?: (config: OpenClawConfig) => Promise; }; +type AgentWorkspaceModule = typeof import("../agents/workspace.js"); +type ConfigIOModule = typeof import("../config/io.js"); +type ConfigLoggingModule = typeof import("../config/logging.js"); + +let agentWorkspaceModulePromise: Promise | undefined; +let configIOModulePromise: Promise | undefined; +let configLoggingModulePromise: Promise | undefined; + +function loadAgentWorkspaceModule(): Promise { + agentWorkspaceModulePromise ??= import("../agents/workspace.js"); + return agentWorkspaceModulePromise; +} + +function loadConfigIOModule(): Promise { + configIOModulePromise ??= import("../config/io.js"); + return configIOModulePromise; +} + +function loadConfigLoggingModule(): Promise { + configLoggingModulePromise ??= import("../config/logging.js"); + return configLoggingModulePromise; +} + async function createDefaultConfigIO(): Promise { - const { createConfigIO } = await import("../config/io.js"); + const { createConfigIO } = await loadConfigIOModule(); return createConfigIO(); } @@ -45,24 +68,24 @@ async function resolveDefaultAgentWorkspaceDir(deps: SetupCommandDeps): Promise< if (typeof override === "function") { return await override(); } - const { DEFAULT_AGENT_WORKSPACE_DIR } = await import("../agents/workspace.js"); + const { DEFAULT_AGENT_WORKSPACE_DIR } = await loadAgentWorkspaceModule(); return DEFAULT_AGENT_WORKSPACE_DIR; } async function ensureDefaultAgentWorkspace( params: Parameters[0], ): ReturnType { - const { ensureAgentWorkspace } = await import("../agents/workspace.js"); + const { ensureAgentWorkspace } = await loadAgentWorkspaceModule(); return ensureAgentWorkspace(params); } async function writeDefaultConfigFile(config: OpenClawConfig): Promise { - const { writeConfigFile } = await import("../config/io.js"); + const { writeConfigFile } = await loadConfigIOModule(); await writeConfigFile(config); } async function formatDefaultConfigPath(configPath: string): Promise { - const { formatConfigPath } = await import("../config/logging.js"); + const { formatConfigPath } = await loadConfigLoggingModule(); return formatConfigPath(configPath); } @@ -70,7 +93,7 @@ async function logDefaultConfigUpdated( runtime: RuntimeEnv, opts: { path?: string; suffix?: string }, ): Promise { - const { logConfigUpdated } = await import("../config/logging.js"); + const { logConfigUpdated } = await loadConfigLoggingModule(); logConfigUpdated(runtime, opts); } diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 9919464ad89..bb9ab29fc35 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -93,6 +93,15 @@ import type { } from "./types.js"; import { assertValidParams } from "./validation.js"; +type SessionsRuntimeModule = typeof import("./sessions.runtime.js"); + +let sessionsRuntimeModulePromise: Promise | undefined; + +function loadSessionsRuntimeModule(): Promise { + sessionsRuntimeModulePromise ??= import("./sessions.runtime.js"); + return sessionsRuntimeModulePromise; +} + function requireSessionKey(key: unknown, respond: RespondFn): string | null { const raw = typeof key === "string" @@ -1331,7 +1340,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { } const reason = p.reason === "new" ? "new" : "reset"; - const { performGatewaySessionReset } = await import("./sessions.runtime.js"); + const { performGatewaySessionReset } = await loadSessionsRuntimeModule(); const result = await performGatewaySessionReset({ key, reason, @@ -1377,7 +1386,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { cleanupSessionBeforeMutation, emitGatewaySessionEndPluginHook, emitSessionUnboundLifecycleEvent, - } = await import("./sessions.runtime.js"); + } = await loadSessionsRuntimeModule(); const { entry, legacyKey, canonicalKey } = loadSessionEntry(key); const mutationCleanupError = await cleanupSessionBeforeMutation({ diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index f375a389e40..fc1fdc61509 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -49,6 +49,15 @@ type FinalizeOnboardingOptions = { runtime: RuntimeEnv; }; +type OnboardSearchModule = typeof import("../commands/onboard-search.js"); + +let onboardSearchModulePromise: Promise | undefined; + +function loadOnboardSearchModule(): Promise { + onboardSearchModulePromise ??= import("../commands/onboard-search.js"); + return onboardSearchModulePromise; +} + export async function finalizeSetupWizard( options: FinalizeOnboardingOptions, ): Promise<{ launchedTui: boolean }> { @@ -522,8 +531,7 @@ export async function finalizeSetupWizard( const webSearchEnabled = nextConfig.tools?.web?.search?.enabled; const configuredSearchProviders = listConfiguredWebSearchProviders({ config: nextConfig }); if (webSearchProvider) { - const { resolveExistingKey, hasExistingKey, hasKeyInEnv } = - await import("../commands/onboard-search.js"); + const { resolveExistingKey, hasExistingKey, hasKeyInEnv } = await loadOnboardSearchModule(); const entry = configuredSearchProviders.find((e) => e.id === webSearchProvider); const label = entry?.label ?? webSearchProvider; const storedKey = entry ? resolveExistingKey(nextConfig, webSearchProvider) : undefined; @@ -585,7 +593,7 @@ export async function finalizeSetupWizard( } else { // Legacy configs may have a working key (e.g. apiKey or BRAVE_API_KEY) without // an explicit provider. Runtime auto-detects these, so avoid saying "skipped". - const { hasExistingKey, hasKeyInEnv } = await import("../commands/onboard-search.js"); + const { hasExistingKey, hasKeyInEnv } = await loadOnboardSearchModule(); const legacyDetected = configuredSearchProviders.find( (e) => hasExistingKey(nextConfig, e.id) || hasKeyInEnv(e), ); diff --git a/src/wizard/setup.plugin-config.ts b/src/wizard/setup.plugin-config.ts index defbc7ad236..9374b9027a5 100644 --- a/src/wizard/setup.plugin-config.ts +++ b/src/wizard/setup.plugin-config.ts @@ -15,6 +15,15 @@ export type ConfigurablePlugin = { jsonSchema?: Record; }; +type ManifestRegistryModule = typeof import("../plugins/manifest-registry.js"); + +let manifestRegistryModulePromise: Promise | undefined; + +function loadManifestRegistryModule(): Promise { + manifestRegistryModulePromise ??= import("../plugins/manifest-registry.js"); + return manifestRegistryModulePromise; +} + type JsonSchemaProperty = { type?: string; enum?: unknown[]; @@ -289,7 +298,7 @@ export async function setupPluginConfig(params: { prompter: WizardPrompter; workspaceDir?: string; }): Promise { - const { loadPluginManifestRegistry } = await import("../plugins/manifest-registry.js"); + const { loadPluginManifestRegistry } = await loadManifestRegistryModule(); const registry = loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, @@ -351,7 +360,7 @@ export async function configurePluginConfig(params: { prompter: WizardPrompter; workspaceDir?: string; }): Promise { - const { loadPluginManifestRegistry } = await import("../plugins/manifest-registry.js"); + const { loadPluginManifestRegistry } = await loadManifestRegistryModule(); const registry = loadPluginManifestRegistry({ config: params.config, workspaceDir: params.workspaceDir, diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index febaad42653..158d670d8d9 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -21,6 +21,29 @@ import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; import { resolveSetupSecretInputString } from "./setup.secret-input.js"; import type { QuickstartGatewayDefaults, WizardFlow } from "./setup.types.js"; +type AuthChoiceModule = typeof import("../commands/auth-choice.js"); +type ConfigLoggingModule = typeof import("../config/logging.js"); +type ModelPickerModule = typeof import("../commands/model-picker.js"); + +let authChoiceModulePromise: Promise | undefined; +let configLoggingModulePromise: Promise | undefined; +let modelPickerModulePromise: Promise | undefined; + +function loadAuthChoiceModule(): Promise { + authChoiceModulePromise ??= import("../commands/auth-choice.js"); + return authChoiceModulePromise; +} + +function loadConfigLoggingModule(): Promise { + configLoggingModulePromise ??= import("../config/logging.js"); + return configLoggingModulePromise; +} + +function loadModelPickerModule(): Promise { + modelPickerModulePromise ??= import("../commands/model-picker.js"); + return modelPickerModulePromise; +} + async function resolveAuthChoiceModelSelectionPolicy(params: { authChoice: string; config: OpenClawConfig; @@ -465,7 +488,7 @@ export async function runSetupWizard( if (mode === "remote") { const { promptRemoteGatewayConfig } = await import("../commands/onboard-remote.js"); - const { logConfigUpdated } = await import("../config/logging.js"); + const { logConfigUpdated } = await loadConfigLoggingModule(); let nextConfig = await promptRemoteGatewayConfig(baseConfig, prompter, { secretInputMode: opts.secretInputMode, }); @@ -523,7 +546,7 @@ export async function runSetupWizard( // Explicit skip should stay cold: do not bootstrap auth/profile machinery // or run model/auth checks when the caller already chose to skip setup. if (authChoiceFromPrompt) { - const { applyPrimaryModel, promptDefaultModel } = await import("../commands/model-picker.js"); + const { applyPrimaryModel, promptDefaultModel } = await loadModelPickerModule(); const modelSelection = await promptDefaultModel({ config: nextConfig, prompter, @@ -540,13 +563,14 @@ export async function runSetupWizard( nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); } - const { warnIfModelConfigLooksOff } = await import("../commands/auth-choice.js"); + const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); await warnIfModelConfigLooksOff(nextConfig, prompter); } } else { - const { applyAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff } = - await import("../commands/auth-choice.js"); - const { applyPrimaryModel, promptDefaultModel } = await import("../commands/model-picker.js"); + const [ + { applyAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff }, + { applyPrimaryModel, promptDefaultModel }, + ] = await Promise.all([loadAuthChoiceModule(), loadModelPickerModule()]); const authResult = await applyAuthChoice({ authChoice, config: nextConfig, @@ -629,7 +653,7 @@ export async function runSetupWizard( } await writeConfigFile(nextConfig); - const { logConfigUpdated } = await import("../config/logging.js"); + const { logConfigUpdated } = await loadConfigLoggingModule(); logConfigUpdated(runtime); await onboardHelpers.ensureWorkspaceAndSessions(workspaceDir, runtime, { skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), diff --git a/test/scripts/check-dynamic-import-warts.test.ts b/test/scripts/check-dynamic-import-warts.test.ts new file mode 100644 index 00000000000..b5937eed3bf --- /dev/null +++ b/test/scripts/check-dynamic-import-warts.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { findDynamicImportAdvisories } from "../../scripts/check-dynamic-import-warts.mjs"; + +describe("check-dynamic-import-warts", () => { + it("flags runtime static plus dynamic imports of the same module", () => { + const source = ` + import { run } from "./runtime.js"; + export async function start() { + return await import("./runtime.js"); + } + `; + expect(findDynamicImportAdvisories(source)).toEqual([ + { + line: 4, + reason: 'runtime static + dynamic import of "./runtime.js" (static line 2)', + }, + ]); + }); + + it("ignores type-only static imports", () => { + const source = ` + import { type Runtime } from "./runtime.js"; + export async function start(): Promise { + return (await import("./runtime.js")).createRuntime(); + } + `; + expect(findDynamicImportAdvisories(source)).toEqual([]); + }); + + it("flags repeated direct dynamic imports", () => { + const source = ` + export async function one() { + return await import("./runtime.js"); + } + export async function two() { + return await import("./runtime.js"); + } + `; + expect(findDynamicImportAdvisories(source)).toEqual([ + { + line: 3, + reason: 'repeated direct dynamic import of "./runtime.js" (2 callsites: 3, 6)', + }, + ]); + }); + + it("ignores cached loader patterns", () => { + const source = ` + let runtimePromise: Promise | undefined; + function loadRuntime() { + runtimePromise ??= import("./runtime.js"); + return runtimePromise; + } + `; + expect(findDynamicImportAdvisories(source)).toEqual([]); + }); +});