Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ export default defineConfig([
],
},
},
{
name: 'Source files',
files: ['src/**/*.ts'],
rules: {
'@local/no-direct-third-party-imports': 'error',
},
},
{
name: 'Tests',
files: ['**/*.test.ts'],
Expand Down
8 changes: 7 additions & 1 deletion scripts/eslint_rules/local-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,11 @@
*/

import checkLicenseRule from './check-license-rule.js';
import noDirectThirdPartyImportsRule from './no-direct-third-party-imports-rule.js';

export default {rules: {'check-license': checkLicenseRule}};
export default {
rules: {
'check-license': checkLicenseRule,
'no-direct-third-party-imports': noDirectThirdPartyImportsRule,
},
};
144 changes: 144 additions & 0 deletions scripts/eslint_rules/no-direct-third-party-imports-rule.js
Original file line number Diff line number Diff line change
@@ -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},
});
},
};
},
};