Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 'Tools definitions',
files: ['src/tools/**/*.ts'],
Expand Down
2 changes: 2 additions & 0 deletions scripts/eslint_rules/local-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
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},
});
},
};
},
};