@eslint/migrate-config
Version:
Configuration migration for ESLint
1,020 lines (891 loc) • 27.5 kB
JavaScript
/**
* @fileoverview Configuration Migration
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import * as recast from "recast";
import { Legacy } from "@eslint/eslintrc";
import camelCase from "camelcase";
import pluginsNeedingCompat from "./compat-plugins.js";
import configsNeedingCompat from "./compat-configs.js";
import { convertIgnorePatternToMinimatch } from "@eslint/compat";
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/** @typedef {import("eslint").Linter.FlatConfig} FlatConfig */
/** @typedef {import("eslint").Linter.LegacyConfig} LegacyConfig */
/** @typedef {import("eslint").Linter.ConfigOverride} ConfigOverride */
/** @typedef {import("recast").types.namedTypes.ObjectExpression} ObjectExpression */
/** @typedef {import("recast").types.namedTypes.ArrayExpression} ArrayExpression */
/** @typedef {import("recast").types.namedTypes.CallExpression} CallExpression */
/** @typedef {import("recast").types.namedTypes.Property} Property */
/** @typedef {import("recast").types.namedTypes.MemberExpression} MemberExpression */
/** @typedef {import("recast").types.namedTypes.Program} Program */
/** @typedef {import("recast").types.namedTypes.Statement} Statement */
/** @typedef {import("recast").types.namedTypes.Literal} Literal */
/** @typedef {import("recast").types.namedTypes.SpreadElement} SpreadElement */
/** @typedef {import("./types.js").MigrationImport} MigrationImport */
//-----------------------------------------------------------------------------
// Data
//-----------------------------------------------------------------------------
const keysToCopy = ["settings", "rules", "processor"];
const linterOptionsKeysToCopy = [
"noInlineConfig",
"reportUnusedDisableDirectives",
];
//-----------------------------------------------------------------------------
// Classes
//-----------------------------------------------------------------------------
/**
* Represents a migration from one config to another.
*/
class Migration {
/**
* The config to migrate.
* @type {LegacyConfig}
*/
config;
/**
* Any imports required for the new config.
* @type {Map<string,MigrationImport>}
*/
imports = new Map();
/**
* Any messages to display to the user.
* @type {string[]}
*/
messages = [];
/**
* Whether or not the migration needs the `__dirname` variable defined.
* @type {boolean}
*/
needsDirname = false;
/**
* Any initialization needed in the file.
* @type {Array<Statement>}
*/
inits = [];
/**
* Creates a new Migration object.
* @param {LegacyConfig} config The config to migrate.
*/
constructor(config) {
this.config = config;
}
}
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const { builders: b } = recast.types;
const { naming } = Legacy;
/**
* Determines if a string is a valid identifier.
* @param {string} name The name to check.
* @returns {boolean} `true` if the name is a valid identifier.
*/
function isValidIdentifier(name) {
return /^[a-z_$][0-9a-z_$]*$/iu.test(name);
}
/**
* Gets the name of the variable to use for the parser.
* @param {string|undefined} parser The name of the parser.
* @returns {string|undefined} The variable name to use or undefined if none.
*/
function getParserVariableName(parser) {
if (!parser) {
return undefined;
}
if (parser.includes("typescript-eslint")) {
return "tsParser";
}
if (parser.includes("babel")) {
return "babelParser";
}
if (parser === "espree") {
return "espree";
}
return "parser";
}
// cache for plugins needing compat
const pluginsNeedingCompatCache = new Set(pluginsNeedingCompat);
/**
* Determines if a plugin needs the compat utility.
* @param {string} pluginName The name of the plugin.
* @returns {boolean} `true` if the plugin needs the compat utility.
*/
function pluginNeedsCompat(pluginName) {
const pluginNameToTest = pluginName.includes("/")
? pluginName.slice(0, pluginName.indexOf("/"))
: pluginName;
return pluginsNeedingCompatCache.has(
naming.normalizePackageName(pluginNameToTest, "eslint-plugin"),
);
}
/**
* Determines if a shareable config needs the compat utility.
* @param {string} configName The name of the config.
* @returns {boolean} `true` if the config needs the compat utility.
*/
function configNeedsCompat(configName) {
const configNameToTest = configName.includes("/")
? configName.slice(0, configName.indexOf("/"))
: configName;
const fullConfigName = naming.normalizePackageName(
configNameToTest,
"eslint-config",
);
return configsNeedingCompat.includes(fullConfigName);
}
/**
* Gets the name of the variable to use for the plugin. If the plugin name
* contains slashes or an @ symbol, it will be normalized to a camelcase name.
* If the name is "import" or "export", it will be prefixed with an underscore.
* @param {string} pluginName The name of the plugin.
* @returns {string} The variable name to use.
*/
function getPluginVariableName(pluginName) {
let name = pluginName.replace(/^eslint-plugin-/u, "");
if (name === "import" || name === "export") {
return `_${name}`;
}
if (name.startsWith("@")) {
name = name.slice(1);
}
// replace slash with uppercase of following letter
name = name.replace(/\/(.)/gu, (_, letter) => letter.toUpperCase());
return camelCase(name);
}
/**
* Get the initialization code for `__dirname`.
* @returns {Array<Statement>} The AST for the initialization block.
*/
function getDirnameInit() {
/*
* Recast doesn't support `import.meta.url`, so using an uppercase "I" to
* allow for parsing. We then need to replace it with the lowercase "i".
*/
const init = `\n
const __filename = fileURLToPath(Import.meta.url);
const __dirname = path.dirname(__filename);`;
const result = recast.parse(init).program.body;
// Replace uppercase "I" with lowercase "i" in "Import.meta.url"
result[0].declarations[0].init.arguments[0].object.object.name = "import";
return result;
}
/**
* Creates an initialization block for the FlatCompat utility.
* @returns {Array<Statement>} The AST for the initialization block.
*/
function getFlatCompatInit() {
const init = `
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
`;
return recast.parse(init).program.body;
}
/**
* Creates an initialization block for the gitignore file.
* @returns {Statement} The AST for the initialization block.
*/
function getGitignoreInit() {
const init = `
const gitignorePath = path.resolve(__dirname, ".gitignore");
`;
return recast.parse(init).program.body[0];
}
/**
* Converts a glob pattern to a format that can be used in a flat config.
* @param {string} pattern The glob pattern to convert.
* @returns {string} The converted glob pattern.
*/
function convertGlobPattern(pattern) {
const isNegated = pattern.startsWith("!");
const patternToTest = isNegated ? pattern.slice(1) : pattern;
// if the pattern is already in the correct format, return it
if (patternToTest === "**" || patternToTest.includes("/")) {
return pattern;
}
return `${isNegated ? "!" : ""}**/${patternToTest}`;
}
/**
* Creates the entry for the gitignore inclusion.
* @param {Migration} migration The migration object.
* @returns {CallExpression} The AST for the gitignore entry.
*/
function createGitignoreEntry(migration) {
migration.inits.push(getGitignoreInit());
if (!migration.imports.has("@eslint/compat")) {
migration.imports.set("@eslint/compat", {
bindings: ["includeIgnoreFile"],
added: true,
});
} else {
migration.imports
.get("@eslint/compat")
.bindings.push("includeIgnoreFile");
}
if (!migration.imports.has("node:path")) {
migration.imports.set("node:path", {
name: "path",
added: true,
});
}
const code = `includeIgnoreFile(gitignorePath)`;
return recast.parse(code).program.body[0].expression;
}
/**
* Creates the globals object from the config.
* @param {LegacyConfig} config The config to create globals from.
* @returns {ObjectExpression|undefined} The globals object or undefined if none.
*/
function createGlobals(config) {
const { globals, env } = config;
if (!globals && !env) {
return undefined;
}
const properties = [];
if (env) {
properties.push(
...Object.keys(env)
.filter(name => !name.startsWith("es"))
.map(name => {
let envName = name;
const memberExpression = b.memberExpression(
b.identifier("globals"),
b.identifier(name),
);
// plugins environments in the form plugin/env
if (name.includes("/")) {
const [pluginName, pluginEnvName] = name.split("/");
const pluginVariableName =
getPluginVariableName(pluginName);
// looks like plugin.environments.envName.globals
memberExpression.object = b.memberExpression(
b.memberExpression(
b.identifier(pluginVariableName),
b.identifier("environments"),
),
isValidIdentifier(pluginEnvName)
? b.identifier(pluginEnvName)
: b.literal(pluginEnvName),
!isValidIdentifier(pluginEnvName),
);
memberExpression.property = b.identifier("globals");
envName = pluginEnvName;
}
// if the name is not a valid identifier, use computed syntax
if (!isValidIdentifier(envName)) {
memberExpression.computed = true;
memberExpression.property = b.literal(envName);
}
const envValue = env[name];
// environment is enabled
if (envValue) {
return b.spreadProperty(memberExpression);
}
// environment is disabled
return b.spreadProperty(
b.callExpression(
b.memberExpression(
b.identifier("Object"),
b.identifier("fromEntries"),
),
[
b.callExpression(
b.memberExpression(
b.callExpression(
b.memberExpression(
b.identifier("Object"),
b.identifier("entries"),
),
[memberExpression],
),
b.identifier("map"),
),
[
b.arrowFunctionExpression(
[
b.arrayPattern([
b.identifier("key"),
]),
],
b.arrayExpression([
b.identifier("key"),
b.literal("off"),
]),
),
],
),
],
),
);
}),
);
}
if (globals) {
properties.push(
...Object.keys(globals).map(name =>
b.property(
"init",
b.identifier(name),
b.literal(globals[name]),
),
),
);
}
return b.objectExpression(properties);
}
/**
* Creates the linter options object from the config.
* @param {LegacyConfig} config The config to create linter options from.
* @returns {ObjectExpression|undefined} The linter options object or undefined if none.
*/
function createLinterOptions(config) {
if (!config.noInlineConfig && !config.reportUnusedDisableDirectives) {
return undefined;
}
const properties = [];
linterOptionsKeysToCopy.forEach(key => {
if (config[key]) {
properties.push(
b.property("init", b.identifier(key), b.literal(config[key])),
);
}
});
return b.objectExpression(properties);
}
/**
* Creates an array of function arguments from an array of extended configs.
* @param {string|string[]} extendedConfigs The extended configs to convert.
* @returns {Array<Literal>} The AST for the array expression.
*/
function createExtendsArguments(extendedConfigs) {
// create an array of strings
if (typeof extendedConfigs === "string") {
return [b.literal(extendedConfigs)];
}
return extendedConfigs.map(config => b.literal(config));
}
/**
* Creates a an object expression that duplicates an existing object.
* @param {Object} value The object to create the AST for.
* @returns {ObjectExpression|ArrayExpression|Literal} The AST for the object.
*/
function createAST(value) {
if (Array.isArray(value)) {
return b.arrayExpression(value.map(item => createAST(item)));
}
if (value && typeof value === "object") {
const properties = Object.keys(value).map(key => {
const propertyValue = value[key];
const identifier = isValidIdentifier(key)
? b.identifier(key)
: b.literal(key);
return b.property("init", identifier, createAST(propertyValue));
});
return b.objectExpression(properties);
}
return b.literal(value);
}
/**
* Creates an array expression from an array of glob patterns.
* @param {string[]} patterns The glob patterns to convert.
* @returns {ArrayExpression} The AST for the array expression.
*/
function createFilesArray(patterns) {
return b.arrayExpression(
patterns.map(pattern => b.literal(convertGlobPattern(pattern))),
);
}
/**
* Creates an object expression for the language options.
* @param {Migration} migration The migration object.
* @param {LegacyConfig} config The config to create language options from.
* @returns {ObjectExpression|undefined} The AST for the object expression or undefined if none.
*/
function createLanguageOptions(migration, config) {
const properties = [];
const { imports, messages } = migration;
// Both `env` and `globals` end up as globals in flat config
const globals = createGlobals(config);
if (globals) {
properties.push(b.property("init", b.identifier("globals"), globals));
}
/*
* For `env`, we need the `globals` package if there are any environments
* that aren't ECMAScript environments and also aren't from plugins
* (the name contains a slash).
*/
const needsGlobals =
config.env &&
Object.keys(config.env).some(
envName => !envName.startsWith("es") && !envName.includes("/"),
);
if (needsGlobals && !imports.has("globals")) {
imports.set("globals", {
name: "globals",
added: true,
});
}
// Copy over `parser`
const parserName = getParserVariableName(config.parser);
if (parserName) {
properties.push(
b.property(
"init",
b.identifier("parser"),
b.identifier(parserName),
),
);
imports.set(config.parser, {
name: parserName,
});
}
// Copy over `parserOptions`
if (config.parserOptions) {
const {
ecmaVersion = 5,
sourceType = "script",
...otherParserOptions
} = config.parserOptions;
// move `ecmaVersion` to `languageOptions`
properties.push(
b.property(
"init",
b.identifier("ecmaVersion"),
b.literal(ecmaVersion),
),
);
// move `sourceType` to `languageOptions` -- be sure to check for Node.js environment
/** @type {"module"|"script"|"commonjs"} */
let finalSourceType = sourceType;
if (config?.env?.node) {
if (sourceType === "module") {
messages.push(
"The 'node' environment is used, but the sourceType is 'module'. Using sourceType 'module'. If you want to use CommonJS modules, set the sourceType to 'commonjs'.",
);
} else {
finalSourceType = "commonjs";
messages.push(
"The 'node' environment is used, so switching sourceType to 'commonjs'.",
);
}
}
properties.push(
b.property(
"init",
b.identifier("sourceType"),
b.literal(finalSourceType),
),
);
if (Object.keys(otherParserOptions).length > 0) {
properties.push(
b.property(
"init",
b.identifier("parserOptions"),
createAST(otherParserOptions),
),
);
}
}
return properties.length ? b.objectExpression(properties) : undefined;
}
/**
* Creates an object expression for the plugins array. Also adds the necessary imports
* to the migration imports map.
* @param {string[]} plugins The plugins to create the object expression for.
* @param {Migration} migration The migration object.
* @returns {ObjectExpression} The AST for the object expression.
*/
function createPlugins(plugins, migration) {
const { imports } = migration;
const properties = [];
const compatNeeded = plugins.reduce((previous, pluginName) => {
const pluginVariableName = getPluginVariableName(pluginName);
const shortPluginName = naming.getShorthandName(
pluginName,
"eslint-plugin",
);
const needsCompat = pluginNeedsCompat(pluginName);
const pluginValue = needsCompat
? b.callExpression(b.identifier("fixupPluginRules"), [
b.identifier(pluginVariableName),
])
: b.identifier(pluginVariableName);
const pluginsProperty = b.property(
"init",
isValidIdentifier(shortPluginName)
? b.identifier(shortPluginName)
: b.literal(shortPluginName),
pluginValue,
);
if (pluginVariableName === shortPluginName && !needsCompat) {
pluginsProperty.shorthand = true;
}
properties.push(pluginsProperty);
imports.set(naming.normalizePackageName(pluginName, "eslint-plugin"), {
name: pluginVariableName,
});
return needsCompat || previous;
}, false);
if (compatNeeded) {
if (!migration.imports.has("@eslint/compat")) {
migration.imports.set("@eslint/compat", {
bindings: ["fixupPluginRules"],
added: true,
});
} else if (
!migration.imports
.get("@eslint/compat")
.bindings.includes("fixupPluginRules")
) {
migration.imports
.get("@eslint/compat")
.bindings.push("fixupPluginRules");
}
}
return b.objectExpression(properties);
}
/**
* Creates an object expression for the `ignorePatterns` property.
* @param {LegacyConfig} config The config to create the object expression for.
* @returns {CallExpression} The AST for the object expression.
*/
function createGlobalIgnores(config) {
const ignorePatterns = Array.isArray(config.ignorePatterns)
? config.ignorePatterns
: [config.ignorePatterns];
const ignorePatternsArray = b.arrayExpression(
ignorePatterns.map(pattern =>
b.literal(convertIgnorePatternToMinimatch(pattern)),
),
);
return b.callExpression(b.identifier("globalIgnores"), [
ignorePatternsArray,
]);
}
/**
* Migrates a config object to the flat config format.
* @param {Migration} migration The migration object.
* @param {ConfigOverride} config The config object to migrate.
* @returns {Array<ObjectExpression|SpreadElement>} The AST for the object expression.
*/
function migrateConfigObject(migration, config) {
const configArrayElements = [];
const properties = [];
let files, ignores;
// Copy over `files` -- should end up first by convention
if (config.files) {
files = createFilesArray(
Array.isArray(config.files) ? config.files : [config.files],
);
properties.push(b.property("init", b.identifier("files"), files));
}
// Copy over `excludedFiles` -- should end up first if no `files` or second if `files` is present
if (config.excludedFiles) {
ignores = createFilesArray(
Array.isArray(config.excludedFiles)
? config.excludedFiles
: [config.excludedFiles],
);
properties.push(b.property("init", b.identifier("ignores"), ignores));
}
// Handle `extends`
if (config.extends) {
let extendsCallExpression = b.callExpression(
b.memberExpression(b.identifier("compat"), b.identifier("extends")),
createExtendsArguments(config.extends),
);
const extendsArray = Array.isArray(config.extends)
? config.extends
: [config.extends];
// Check if any of the extends are plugins that need the compat utility
const needsCompat = extendsArray.some(extend => {
if (
extend.startsWith("eslint:") ||
extend.startsWith(".") ||
extend.startsWith("/")
) {
return false;
}
if (extend.startsWith("plugin:")) {
return pluginNeedsCompat(extend.slice(7));
}
return configNeedsCompat(extend);
});
if (needsCompat) {
/*
* When even one `extends` item needs compat, we need to mark every
* plugin as needing compat. This is because the `fixupConfigRules`
* function will be called on the entire object, and if any of those
* plugins is also referenced in `plugins`, the user will get a
* "can't redefine plugin" error.
*/
extendsArray.forEach(extend => {
if (extend.startsWith("plugin:")) {
const pluginName = extend.slice(7, extend.indexOf("/"));
const normalizedPluginName = naming.normalizePackageName(
pluginName,
"eslint-plugin",
);
pluginsNeedingCompatCache.add(normalizedPluginName);
}
});
if (!migration.imports.has("@eslint/compat")) {
migration.imports.set("@eslint/compat", {
bindings: ["fixupConfigRules"],
added: true,
});
} else {
migration.imports
.get("@eslint/compat")
.bindings.push("fixupConfigRules");
}
extendsCallExpression = b.callExpression(
b.identifier("fixupConfigRules"),
[extendsCallExpression],
);
}
properties.push(
b.property("init", b.identifier("extends"), extendsCallExpression),
);
}
/*
* Copy over plugins. This must happen after processing `extends` in order to
* properly account for plugins that need the compat utility.
*/
if (config.plugins) {
properties.push(
b.property(
"init",
b.identifier("plugins"),
createPlugins(config.plugins, migration),
),
);
}
// Copy over `noInlineConfig` and `reportUnusedDisableDirectives`
const linterOptions = createLinterOptions(config);
if (linterOptions) {
properties.push(
b.property("init", b.identifier("linterOptions"), linterOptions),
);
}
// Create `languageOptions` from `env`, `globals`, `parser`, and `parserOptions`
const languageOptions = createLanguageOptions(migration, config);
if (languageOptions) {
properties.push(
b.property(
"init",
b.identifier("languageOptions"),
languageOptions,
),
);
}
// Copy over everything that stays the same - `settings`, `rules`, `processor`
keysToCopy.forEach(key => {
if (config[key]) {
const propertyValue =
typeof config[key] === "object"
? createAST(config[key])
: b.literal(config[key]);
properties.push(
b.property("init", b.identifier(key), propertyValue),
);
}
});
/*
* If there is an `extends` with a `files` and/or `ignores`, then it's possible this object
* will contain only `files` (and/or `ignores`), in which case we don't need it because there
* is already a config object with the same properties.
*/
const objectIsNeeded =
!config.extends ||
properties.some(property => {
if (property.key.type === "Identifier") {
return (
property.key.name !== "files" &&
property.key.name !== "ignores"
);
}
return true;
});
if (objectIsNeeded) {
configArrayElements.push(b.objectExpression(properties));
}
return configArrayElements;
}
/**
* Migrates an eslintrc config to flat config format.
* @param {LegacyConfig} config The eslintrc config to migrate.
* @param {Object} [options] Options for the migration.
* @param {"module"|"commonjs"} [options.sourceType] The module type to use.
* @param {boolean} [options.gitignore] `true` to include contents of a .gitignore file.
* @returns {{code:string,messages:Array<string>,imports:Map<string,MigrationImport>}} The migrated config and
* any messages to display to the user.
*/
export function migrateConfig(
config,
{ sourceType = "module", gitignore = false } = {},
) {
const migration = new Migration(config);
const body = [];
// always use defineConfig
migration.imports.set("eslint/config", {
bindings: ["defineConfig"],
});
/** @type {Array<CallExpression|ObjectExpression|SpreadElement>} */
const configArrayElements = [
...migrateConfigObject(
migration,
/** @type {ConfigOverride} */ (config),
),
];
const isModule = sourceType === "module";
// if the base config has no properties, then remove the empty object
if (
configArrayElements[0].type === "ObjectExpression" &&
configArrayElements[0].properties.length === 0
) {
configArrayElements.shift();
}
// add any overrides
if (config.overrides) {
config.overrides.forEach(override => {
configArrayElements.push(
...migrateConfigObject(migration, override),
);
});
}
// if any config has extends then we need to add imports
if (
config.extends ||
config.overrides?.some(override => override.extends)
) {
if (isModule) {
migration.imports.set("node:path", {
name: "path",
added: true,
});
migration.imports.set("node:url", {
bindings: ["fileURLToPath"],
added: true,
});
}
migration.imports.set("@eslint/js", {
name: "js",
added: true,
});
migration.imports.set("@eslint/eslintrc", {
bindings: ["FlatCompat"],
added: true,
});
migration.needsDirname ||= isModule;
migration.inits.push(...getFlatCompatInit());
}
// add .gitignore if necessary
if (gitignore) {
migration.needsDirname ||= isModule;
configArrayElements.unshift(createGitignoreEntry(migration));
if (migration.needsDirname && !migration.imports.has("node:url")) {
migration.imports.set("node:url", {
bindings: ["fileURLToPath"],
added: true,
});
}
}
if (config.ignorePatterns) {
migration.imports.get("eslint/config").bindings.push("globalIgnores");
configArrayElements.unshift(createGlobalIgnores(config));
}
// add imports to the top of the file
if (!isModule) {
migration.imports.forEach(({ name, bindings }, path) => {
const bindingProperties = bindings?.map(binding => {
const bindingProperty = b.property(
"init",
b.identifier(binding),
b.identifier(binding),
);
bindingProperty.shorthand = true;
return bindingProperty;
});
body.push(
name
? b.variableDeclaration("const", [
b.variableDeclarator(
b.identifier(name),
b.callExpression(b.identifier("require"), [
b.literal(path),
]),
),
])
: b.variableDeclaration("const", [
b.variableDeclarator(
b.objectPattern(bindingProperties),
b.callExpression(b.identifier("require"), [
b.literal(path),
]),
),
]),
);
});
} else {
migration.imports.forEach(({ name, bindings }, path) => {
body.push(
name
? b.importDeclaration(
[b.importDefaultSpecifier(b.identifier(name))],
b.literal(path),
)
: b.importDeclaration(
bindings.map(binding =>
b.importSpecifier(b.identifier(binding)),
),
b.literal(path),
),
);
});
}
// add calculation of `__dirname` if needed
if (migration.needsDirname) {
body.push(...getDirnameInit());
}
// output any inits
body.push(...migration.inits);
// the defineConfig() call
const defineConfigNode = b.callExpression(b.identifier("defineConfig"), [
b.arrayExpression(configArrayElements),
]);
// output the actual config array to the program
if (!isModule) {
body.push(
b.expressionStatement(
b.assignmentExpression(
"=",
b.memberExpression(
b.identifier("module"),
b.identifier("exports"),
),
defineConfigNode,
),
),
);
} else {
body.push(b.exportDefaultDeclaration(defineConfigNode));
}
return {
// Recast doesn't export the `StatementKind` type so we need to cast the body to `Array<any>`
code: recast.print(b.program(/** @type {Array<any>}*/ (body)), {
tabWidth: 4,
trailingComma: true,
lineTerminator: "\n",
}).code,
messages: migration.messages,
imports: migration.imports,
};
}