diff --git a/.eslint-plugin-local/code-no-static-node-module-import.ts b/.eslint-plugin-local/code-no-static-node-module-import.ts new file mode 100644 index 00000000000..674e5f9e6eb --- /dev/null +++ b/.eslint-plugin-local/code-no-static-node-module-import.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TSESTree } from '@typescript-eslint/typescript-estree'; +import * as eslint from 'eslint'; +import { builtinModules } from 'module'; +import { join, normalize, relative } from 'path'; +import minimatch from 'minimatch'; +import { createImportRuleListener } from './utils.ts'; + +const nodeBuiltins = new Set([ + ...builtinModules, + ...builtinModules.map(m => `node:${m}`) +]); + +const REPO_ROOT = normalize(join(import.meta.dirname, '../')); + +export default new class implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + staticImport: 'Static imports of \'{{module}}\' are not allowed here because they are loaded synchronously on startup. Use a dynamic `await import(...)` or `import type` instead.' + }, + docs: { + description: 'Disallow static imports of node_modules packages to prevent synchronous loading on startup. Allows Node.js built-ins, electron, relative imports, and whitelisted file paths.' + }, + schema: { + type: 'array', + items: { + type: 'string' + } + } + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + const allowedPaths = context.options as string[]; + const filePath = normalize(relative(REPO_ROOT, normalize(context.getFilename()))).replace(/\\/g, '/'); + + // Skip whitelisted files + if (allowedPaths.some(pattern => filePath === pattern || minimatch(filePath, pattern))) { + return {}; + } + + return createImportRuleListener((node, value) => { + // Allow `import type` and `export type` declarations + if (node.parent?.type === TSESTree.AST_NODE_TYPES.ImportDeclaration && node.parent.importKind === 'type') { + return; + } + if (node.parent && 'exportKind' in node.parent && node.parent.exportKind === 'type') { + return; + } + + // Allow relative imports + if (value.startsWith('.')) { + return; + } + + // Allow Node.js built-in modules + if (nodeBuiltins.has(value)) { + return; + } + + // Allow electron + if (value === 'electron') { + return; + } + + context.report({ + loc: node.parent!.loc, + messageId: 'staticImport', + data: { + module: value + } + }); + }); + } +}; diff --git a/eslint.config.js b/eslint.config.js index 955fe0033bd..ff390f5c4b2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1042,6 +1042,33 @@ export default tseslint.config( ] } }, + // electron-main layer: prevent static imports of heavy node_modules + // that would be synchronously loaded on startup + { + files: [ + 'src/vs/code/electron-main/**/*.ts', + 'src/vs/code/node/**/*.ts', + 'src/vs/platform/*/electron-main/**/*.ts', + 'src/vs/platform/*/node/**/*.ts', + ], + languageOptions: { + parser: tseslint.parser, + }, + plugins: { + 'local': pluginLocal, + }, + rules: { + 'local/code-no-static-node-module-import': [ + 'error', + // Files that run in separate processes, not on the electron-main startup path + 'src/vs/platform/agentHost/node/copilot/**/*.ts', + 'src/vs/platform/files/node/watcher/**/*.ts', + 'src/vs/platform/terminal/node/**/*.ts', + // Files that use small, safe modules + 'src/vs/platform/environment/node/argv.ts', + ] + } + }, // browser/electron-browser layer { files: [