diff --git a/eslint.config.mjs b/eslint.config.mjs index ade8c4e8f..6009e720c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -135,6 +135,13 @@ export default defineConfig([ ], }, }, + { + name: 'Source files', + files: ['src/**/*.ts'], + rules: { + '@local/no-direct-third-party-imports': 'error', + }, + }, { name: 'Tools definitions', files: ['src/tools/**/*.ts'], diff --git a/scripts/eslint_rules/local-plugin.js b/scripts/eslint_rules/local-plugin.js index b61d399b4..a37fb2957 100644 --- a/scripts/eslint_rules/local-plugin.js +++ b/scripts/eslint_rules/local-plugin.js @@ -6,10 +6,12 @@ import checkLicenseRule from './check-license-rule.js'; import enforceZodSchemaRule from './enforce-zod-schema-rule.js'; +import noDirectThirdPartyImportsRule from './no-direct-third-party-imports-rule.js'; export default { rules: { 'check-license': checkLicenseRule, + 'no-direct-third-party-imports': noDirectThirdPartyImportsRule, 'enforce-zod-schema': enforceZodSchemaRule, }, }; diff --git a/scripts/eslint_rules/no-direct-third-party-imports-rule.js b/scripts/eslint_rules/no-direct-third-party-imports-rule.js new file mode 100644 index 000000000..7514418fd --- /dev/null +++ b/scripts/eslint_rules/no-direct-third-party-imports-rule.js @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * ESLint rule that prevents value (non-type) imports of third-party packages + * that should go through the `src/third_party/index.ts` barrel file. + * + * Type-only imports are allowed because they are erased at compile time and + * do not affect the bundle. + * + * This catches a class of bugs where a direct import works in development + * (because devDependencies are installed) but fails once the package is + * bundled and published via `npm pack`. + * + * The list of bundled packages is derived dynamically by scanning + * `src/third_party/*.ts` for import/export statements at ESLint load time. + * + * See https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1123 + */ + +import {readdirSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; + +const THIRD_PARTY_DIR = join( + import.meta.dirname, + '..', + '..', + 'src', + 'third_party', +); + +/** + * Parse all .ts files in src/third_party/ and extract the bare package names + * from import/export statements. Relative imports and node_modules paths + * (used for chrome-devtools-frontend) are skipped. + */ +function discoverBundledPackages() { + const packages = new Set(); + // Match `from 'pkg'` (may appear on a different line than `import`) + // and side-effect imports like `import 'pkg'`. + const fromRe = /from\s+['"]([^'"]+)['"]/g; + const sideEffectRe = /^import\s+['"]([^'"]+)['"]/gm; + + let files; + try { + files = readdirSync(THIRD_PARTY_DIR).filter(f => f.endsWith('.ts')); + } catch { + return []; + } + + for (const file of files) { + const content = readFileSync(join(THIRD_PARTY_DIR, file), 'utf8'); + for (const re of [fromRe, sideEffectRe]) { + re.lastIndex = 0; + let match; + while ((match = re.exec(content)) !== null) { + const source = match[1]; + // Skip relative imports and node_modules paths. + if (source.startsWith('.') || source.startsWith('/')) { + continue; + } + // Extract the bare package name (handle scoped packages like @foo/bar). + const parts = source.split('/'); + const pkg = source.startsWith('@') + ? parts.slice(0, 2).join('/') + : parts[0]; + packages.add(pkg); + } + } + } + + return [...packages]; +} + +const THIRD_PARTY_PACKAGES = discoverBundledPackages(); + +/** Matches any import source that starts with one of the restricted packages. */ +function isRestrictedSource(source) { + return THIRD_PARTY_PACKAGES.some( + pkg => source === pkg || source.startsWith(pkg + '/'), + ); +} + +/** Returns true when the file is inside src/third_party/. */ +function isThirdPartyBarrel(filename) { + const normalized = filename.replace(/\\/g, '/'); + return normalized.includes('/src/third_party/'); +} + +export default { + name: 'no-direct-third-party-imports', + meta: { + type: 'problem', + docs: { + description: + 'Disallow value imports of bundled third-party packages outside of src/third_party/', + }, + schema: [], + messages: { + noDirectImport: + 'Do not import "{{source}}" directly. Use the re-export from "src/third_party/index.js" instead so the import survives bundling. (Type-only imports are fine.)', + }, + }, + defaultOptions: [], + create(context) { + const filename = context.filename; + if (isThirdPartyBarrel(filename)) { + return {}; + } + + return { + ImportDeclaration(node) { + // `import type { Foo } from '...'` is always safe. + if (node.importKind === 'type') { + return; + } + + const source = node.source.value; + if (!isRestrictedSource(source)) { + return; + } + + // If every specifier is `type`, the import is still safe. + // e.g. `import { type Foo, type Bar } from '...'` + const hasValueSpecifier = node.specifiers.some( + s => s.type !== 'ImportSpecifier' || s.importKind !== 'type', + ); + + if (!hasValueSpecifier) { + return; + } + + context.report({ + node, + messageId: 'noDirectImport', + data: {source}, + }); + }, + }; + }, +};