eslint-plugin: add auto fix for missing imports

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-03-05 20:17:14 +01:00
parent 7aee063a27
commit 911c25de59
5 changed files with 232 additions and 59 deletions
@@ -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}'`,
);
},
});
}
}),
};
},
};