Files
renovate/tools/lint/rules/no-dev-dependency-import.ts
Jamie Tanna 134dc6cd38 chore(lint): add oxlint rule to block dev-dependency imports in production code (#45851)
As we've recently had a couple of cases where a `devDependency` has been
used in a production file, and CI hasn't caught it, we end up seeing
i.e.

    ERROR: unhandledRejection
           "err": {
             "code": "ERR_MODULE_NOT_FOUND",
             "message": "Cannot find package 'common-tags' imported from
                         /usr/local/renovate/dist/workers/repository/update/pr/index.js"
             }

(see 5ee51fdf39 and
1d33143727 for two cases this has happened
with)

One option for caching this is a linting rule, which stops runtime usage
in `lib/` on a dependency in `devDependencies`.

Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io>
2026-09-10 14:59:09 +00:00

94 lines
2.8 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import type { Context, ESTree } from '@oxlint/plugins';
import { defineRule } from '@oxlint/plugins';
/** Every literal node oxc emits, all of which share `type: 'Literal'`. */
type Literal = Extract<ESTree.Node, { type: 'Literal' }>;
const packageJsonPath = path.join(import.meta.dirname, '../../../package.json');
// oxlint-disable-next-line no-sync -- lint rules run synchronously, so async fs is not an option
const packageJsonRaw = fs.readFileSync(packageJsonPath, 'utf8');
const pkg = JSON.parse(packageJsonRaw) as {
devDependencies?: Record<string, string>;
};
const devDependencies = new Set(Object.keys(pkg.devDependencies ?? {}));
/** Resolves a bare import specifier to the package name it belongs to, or `null` for relative/absolute imports. */
function packageName(specifier: string): string | null {
if (specifier.startsWith('.') || specifier.startsWith('/')) {
return null;
}
const segments = specifier.split('/');
return specifier.startsWith('@')
? segments.slice(0, 2).join('/')
: segments[0];
}
function checkSource(context: Context, source: Literal): void {
if (typeof source.value !== 'string') {
return;
}
const name = packageName(source.value);
if (name && devDependencies.has(name)) {
context.report({
node: source,
messageId: 'noDevDependencyImport',
data: { name },
});
}
}
export default defineRule({
meta: {
type: 'problem',
messages: {
noDevDependencyImport:
'`{{name}}` is a devDependency and is not installed for users of the published package. Move it to `dependencies` in package.json, or use a type-only import if only types are needed.',
},
},
createOnce(context) {
return {
ImportDeclaration(node) {
if (node.importKind === 'type') {
return;
}
if (
node.specifiers.length > 0 &&
node.specifiers.every(
(specifier) =>
specifier.type === 'ImportSpecifier' &&
specifier.importKind === 'type',
)
) {
return;
}
checkSource(context, node.source);
},
ExportNamedDeclaration(node) {
if (!node.source || node.exportKind === 'type') {
return;
}
if (
node.specifiers.length > 0 &&
node.specifiers.every((specifier) => specifier.exportKind === 'type')
) {
return;
}
checkSource(context, node.source);
},
ExportAllDeclaration(node) {
if (node.exportKind === 'type') {
return;
}
checkSource(context, node.source);
},
ImportExpression(node) {
if (node.source.type === 'Literal') {
checkSource(context, node.source);
}
},
};
},
});