eslint-plugin: add auto fix for missing imports
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -20,6 +20,7 @@ const path = require('path');
|
||||
const getPackageMap = require('../lib/getPackages');
|
||||
const visitImports = require('../lib/visitImports');
|
||||
const minimatch = require('minimatch');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const depFields = {
|
||||
dep: 'dependencies',
|
||||
@@ -124,11 +125,13 @@ function getAddFlagForDepsField(depsField) {
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
messages: {
|
||||
undeclared:
|
||||
"{{ packageName }} must be declared in {{ depsField }} of {{ packageJsonPath }}, run 'yarn --cwd {{ packagePath }} add{{ addFlag }} {{ packageName }}' from the project root.",
|
||||
switch:
|
||||
'{{ packageName }} is declared in {{ oldDepsField }}, but should be moved to {{ depsField }} in {{ packageJsonPath }}.',
|
||||
switchBack: 'Switch back to import declaration',
|
||||
},
|
||||
docs: {
|
||||
description:
|
||||
@@ -150,63 +153,150 @@ module.exports = {
|
||||
return {};
|
||||
}
|
||||
|
||||
return visitImports(context, (node, imp) => {
|
||||
// We leave checking of type imports to the repo-tools check,
|
||||
// and we skip builtins and local imports
|
||||
if (
|
||||
imp.kind === 'type' ||
|
||||
imp.type === 'builtin' ||
|
||||
imp.type === 'local'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
/** @type Array<{name: string, flag: string, node: import('estree').Node}> */
|
||||
const importsToAdd = [];
|
||||
|
||||
// We skip imports for the package itself
|
||||
if (imp.packageName === localPkg.packageJson.name) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
// All missing imports that we detect are collected as we traverse, and then we use
|
||||
// the program exit to execute all install directives that have been found.
|
||||
['Program:exit']() {
|
||||
/** @type Record<string, Set<string>> */
|
||||
const byFlag = {};
|
||||
|
||||
const modulePath = path.relative(localPkg.dir, filePath);
|
||||
const expectedType = getExpectedDepType(
|
||||
localPkg.packageJson,
|
||||
imp.packageName,
|
||||
modulePath,
|
||||
);
|
||||
for (const { name, flag } of importsToAdd) {
|
||||
byFlag[flag] = byFlag[flag] ?? new Set();
|
||||
byFlag[flag].add(name);
|
||||
}
|
||||
|
||||
const conflict = findConflict(
|
||||
localPkg.packageJson,
|
||||
imp.packageName,
|
||||
expectedType,
|
||||
);
|
||||
for (const name of byFlag[''] ?? []) {
|
||||
byFlag['--dev']?.delete(name);
|
||||
}
|
||||
for (const name of byFlag['--peer'] ?? []) {
|
||||
byFlag['']?.delete(name);
|
||||
byFlag['--dev']?.delete(name);
|
||||
}
|
||||
|
||||
if (conflict) {
|
||||
try {
|
||||
const fullImport = imp.path
|
||||
? `${imp.packageName}/${imp.path}`
|
||||
: imp.packageName;
|
||||
require.resolve(fullImport, {
|
||||
paths: [localPkg.dir],
|
||||
for (const [flag, names] of Object.entries(byFlag)) {
|
||||
// The security implication of this is a bit interesting, as crafted add-import
|
||||
// directives could be used to install malicious packages. However, the same is true
|
||||
// for adding malicious packages to package.json, so there's significant difference.
|
||||
execFileSync('yarn', ['add', ...(flag || []), ...names], {
|
||||
cwd: localPkg.dir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch {
|
||||
// If the dependency doesn't resolve then it's likely a type import, ignore
|
||||
}
|
||||
|
||||
// This switches all import directives back to the original import.
|
||||
for (const added of importsToAdd) {
|
||||
context.report({
|
||||
node: added.node,
|
||||
messageId: 'switchBack',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(added.node, `'${added.name}'`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
importsToAdd.length = 0;
|
||||
packages.clearCache();
|
||||
},
|
||||
...visitImports(context, (node, imp) => {
|
||||
// We leave checking of type imports to the repo-tools check,
|
||||
// and we skip builtins and local imports
|
||||
if (
|
||||
imp.kind === 'type' ||
|
||||
imp.type === 'builtin' ||
|
||||
imp.type === 'local'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packagePath = path.relative(packages.root.dir, localPkg.dir);
|
||||
const packageJsonPath = path.join(packagePath, 'package.json');
|
||||
// Any import directive that is found is collected for processing later
|
||||
if (imp.type === 'directive') {
|
||||
const parts = imp.path.split(':');
|
||||
if (parts[1] !== 'add-import') {
|
||||
return;
|
||||
}
|
||||
const [type, name] = parts.slice(2);
|
||||
if (!name.match(/^(@[-\w\.~]+\/)?[-\w\.~]*$/i)) {
|
||||
throw new Error(
|
||||
`Invalid package name to add as dependency: '${name}'`,
|
||||
);
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: conflict.oldDepsField ? 'switch' : 'undeclared',
|
||||
data: {
|
||||
...conflict,
|
||||
packagePath,
|
||||
addFlag: getAddFlagForDepsField(conflict.depsField),
|
||||
packageName: imp.packageName,
|
||||
packageJsonPath: packageJsonPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
importsToAdd.push({
|
||||
flag: getAddFlagForDepsField(type).trim(),
|
||||
name,
|
||||
node: imp.node,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// We skip imports for the package itself
|
||||
if (imp.packageName === localPkg.packageJson.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modulePath = path.relative(localPkg.dir, filePath);
|
||||
const expectedType = getExpectedDepType(
|
||||
localPkg.packageJson,
|
||||
imp.packageName,
|
||||
modulePath,
|
||||
);
|
||||
|
||||
const conflict = findConflict(
|
||||
localPkg.packageJson,
|
||||
imp.packageName,
|
||||
expectedType,
|
||||
);
|
||||
|
||||
if (conflict) {
|
||||
try {
|
||||
const fullImport = imp.path
|
||||
? `${imp.packageName}/${imp.path}`
|
||||
: imp.packageName;
|
||||
require.resolve(fullImport, {
|
||||
paths: [localPkg.dir],
|
||||
});
|
||||
} catch {
|
||||
// If the dependency doesn't resolve then it's likely a type import, ignore
|
||||
return;
|
||||
}
|
||||
|
||||
const packagePath = path.relative(packages.root.dir, localPkg.dir);
|
||||
const packageJsonPath = path.join(packagePath, 'package.json');
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: conflict.oldDepsField ? 'switch' : 'undeclared',
|
||||
data: {
|
||||
...conflict,
|
||||
packagePath,
|
||||
addFlag: getAddFlagForDepsField(conflict.depsField),
|
||||
packageName: imp.packageName,
|
||||
packageJsonPath: packageJsonPath,
|
||||
},
|
||||
// This fix callback is always executed, regardless of whether linting is run with
|
||||
// fixes enabled or not. There is no way to determine if fixes are being applied, so
|
||||
// instead our fix will replace the import with a directive that will be picked up
|
||||
// on the next run. When ESLint applies fixes all rules are re-run to make sure the fixes
|
||||
// applied correctly, which means that these directives will be picked up, executed,
|
||||
// and switched back to the original import immediately.
|
||||
// This is not true for all editor integrations. For example, VSCode translates there fixes
|
||||
// to native editor commands, and does not re-run ESLint. This means that the import directive
|
||||
// will end up in source code, and the import directive fix needs to be applied manually too.
|
||||
// There is to my knowledge no way around this that doesn't get very hacky, so it will do for now.
|
||||
fix: conflict.oldDepsField
|
||||
? undefined
|
||||
: fixer => {
|
||||
return fixer.replaceText(
|
||||
imp.node,
|
||||
`'directive:add-import:${conflict.depsField}:${imp.packageName}'`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user