145 lines
4.1 KiB
JavaScript
145 lines
4.1 KiB
JavaScript
/**
|
|
* @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},
|
|
});
|
|
},
|
|
};
|
|
},
|
|
};
|