diff --git a/packages/elastic-eslint-config-kibana/BUILD.bazel b/packages/elastic-eslint-config-kibana/BUILD.bazel index d8b727bf3b6bc..9dceec268418b 100644 --- a/packages/elastic-eslint-config-kibana/BUILD.bazel +++ b/packages/elastic-eslint-config-kibana/BUILD.bazel @@ -28,6 +28,7 @@ NPM_MODULE_EXTRA_FILES = [ RUNTIME_DEPS = [ "//packages/kbn-babel-preset", "//packages/kbn-dev-utils", + "//packages/kbn-eslint-plugin-imports", "@npm//eslint-config-prettier", "@npm//semver", ] diff --git a/packages/kbn-eslint-plugin-imports/BUILD.bazel b/packages/kbn-eslint-plugin-imports/BUILD.bazel index 6d5facdd14e0f..efb6a40a1d9f0 100644 --- a/packages/kbn-eslint-plugin-imports/BUILD.bazel +++ b/packages/kbn-eslint-plugin-imports/BUILD.bazel @@ -57,6 +57,7 @@ TYPES_DEPS = [ "//packages/kbn-utils:npm_module_types", "//packages/kbn-dev-utils:npm_module_types", # only required for the tests, which are excluded except on windows "//packages/kbn-import-resolver:npm_module_types", + "@npm//dedent", # only required for the tests, which are excluded except on windows "@npm//@types/eslint", "@npm//@types/jest", "@npm//@types/node", diff --git a/packages/kbn-eslint-plugin-imports/README.md b/packages/kbn-eslint-plugin-imports/README.md index dc8abb4dd0772..ba22ed2400643 100644 --- a/packages/kbn-eslint-plugin-imports/README.md +++ b/packages/kbn-eslint-plugin-imports/README.md @@ -1,9 +1,56 @@ # @kbn/eslint-plugin-imports -ESLint plugin providing custom rules for validating imports in the Kibana repo with custom logic beyond what's possible with custom config to eslint-plugin-imports and even a custom resolver +ESLint plugin providing custom rules for validating imports in the Kibana repo with custom logic beyond what's possible with custom config to eslint-plugin-imports and even a custom resolver. -## `resolveKibanaImport(request: string, dirname: string)` +For the purposes of this ESLint plugin "imports" include: -Resolve an import request (the "from" string from an import statement, or any other relative/absolute import path) from a given directory. The `dirname` should be the same for all files in a given directory. + - `import` statements + - `import()` expressions + - `export ... from` statements + - `require()` calls + - `require.resolve()` calls + - `jest.mock()` and related calls -Result will be `null` when the import path does not resolve, but all valid/committed import paths *should* resolve. Other result values are documented in src/resolve_result.ts. \ No newline at end of file +An "import request" is the string defining the target package/module by any of the previous mentioned "import" types + +## `@kbn/imports/no_unresolvable_imports` + +This rule validates that every import request in the repository can be resolved by `@kbn/import-resolver`. + +This rule is not configurable, should never be skipped, and is auto-fixable. + +If a valid import request can't be resolved for some reason please reach out to Kibana Operations to work on either a different strategy for the import or help updating the resolve to support the new import strategy. + +## `@kbn/imports/uniform_imports` + +This rule validates that every import request in the repsitory follows a standard set of formatting rules. See the rule implemeation for a full breakdown but here is a breif summary: + + - imports within a single package must use relative paths + - imports across packages must reference the other package using it's module id + - imports to code not in a package must use relative paths + - imports to an `index` file end with the directory name, ie `/index` or `/index.{ext}` are stripped + - unless this is a `require.resolve()`, the imports should not mention file extensions. `require.resolve()` calls will retain the extension if added manually + +This rule is not configurable, should never be skipped, and is auto-fixable. + +## `@kbn/imports/exports_moved_packages` + +This rule assists package authors who are doing the good work of breaking up large packages. The goal is to define exports which used to be part of one package as having moved to another package. The configuration maintains this mapping and is designed to be extended in the future is additional needs arrise like targetting specific package types. + +Config example: +```ts +'@kbn/imports/exports_moved_packages': ['error', [ + { + fromPackage: '@kbn/kitchen-sink', + toPackage: '@kbn/spatula', + exportNames: [ + 'Spatula', + 'isSpatula' + ] + } +]] +``` + +This config will find any import of `@kbn/kitchen-sink` which specifically references the `Spatula` or `isSpatula` exports, remove the old exports from the import (potentially removing the entire import), and add a new import after the previous following it's style pointing to the new package. + +The auto-fixer here covers the vast majority of import styles in the repository but might not cover everything, including `import * as Namespace from '@kbn/kitchen-sink'`. Imports like this will need to be found and updated manually, though TypeScript should be able to find the vast majority of those. \ No newline at end of file diff --git a/packages/kbn-eslint-plugin-imports/jest.config.js b/packages/kbn-eslint-plugin-imports/jest.config.js new file mode 100644 index 0000000000000..bf0bad97843ef --- /dev/null +++ b/packages/kbn-eslint-plugin-imports/jest.config.js @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +module.exports = { + preset: '@kbn/test/jest_node', + rootDir: '../..', + roots: ['/packages/kbn-eslint-plugin-imports'], +}; diff --git a/packages/kbn-eslint-plugin-imports/src/helpers/visit_all_import_statements.ts b/packages/kbn-eslint-plugin-imports/src/helpers/visit_all_import_statements.ts index fe6b9b2ed04ba..6f549d93ef26e 100644 --- a/packages/kbn-eslint-plugin-imports/src/helpers/visit_all_import_statements.ts +++ b/packages/kbn-eslint-plugin-imports/src/helpers/visit_all_import_statements.ts @@ -22,9 +22,27 @@ const JEST_MODULE_METHODS = [ 'jest.requireMock', ]; +export type Importer = + | TSESTree.ImportDeclaration + | T.ImportDeclaration + | TSESTree.ExportNamedDeclaration + | T.ExportNamedDeclaration + | TSESTree.ExportAllDeclaration + | T.ExportAllDeclaration + | TSESTree.CallExpression + | T.CallExpression + | TSESTree.ImportExpression + | TSESTree.CallExpression + | T.CallExpression; + export type SomeNode = TSESTree.Node | T.Node; -type Visitor = (req: string | null, node: SomeNode, type: ImportType) => void; +interface VisitorContext { + node: SomeNode; + type: ImportType; + importer: Importer; +} +type Visitor = (req: string | null, context: VisitorContext) => void; const isIdent = (node: SomeNode): node is TSESTree.Identifier | T.Identifier => T.isIdentifier(node) || node.type === AST_NODE_TYPES.Identifier; @@ -36,28 +54,38 @@ const isStringLiteral = (node: SomeNode): node is TSESTree.StringLiteral | T.Str const isTemplateLiteral = (node: SomeNode): node is TSESTree.TemplateLiteral | T.TemplateLiteral => T.isTemplateLiteral(node) || node.type === AST_NODE_TYPES.TemplateLiteral; -function passSourceAsString(source: SomeNode | null | undefined, type: ImportType, fn: Visitor) { - if (!source) { +function passSourceAsString( + fn: Visitor, + node: SomeNode | null | undefined, + importer: Importer, + type: ImportType +) { + if (!node) { return; } - if (isStringLiteral(source)) { - return fn(source.value, source, type); + const ctx = { + node, + importer, + type, + }; + + if (isStringLiteral(node)) { + return fn(node.value, ctx); } - if (isTemplateLiteral(source)) { - if (source.expressions.length) { + if (isTemplateLiteral(node)) { + if (node.expressions.length) { return null; } return fn( - [...source.quasis].reduce((acc, q) => acc + q.value.raw, ''), - source, - type + [...node.quasis].reduce((acc, q) => acc + q.value.raw, ''), + ctx ); } - return fn(null, source, type); + return fn(null, ctx); } /** @@ -68,27 +96,28 @@ function passSourceAsString(source: SomeNode | null | undefined, type: ImportTyp export function visitAllImportStatements(fn: Visitor) { const visitor = { ImportDeclaration(node: TSESTree.ImportDeclaration | T.ImportDeclaration) { - passSourceAsString(node.source, 'esm', fn); + passSourceAsString(fn, node.source, node, 'esm'); }, ExportNamedDeclaration(node: TSESTree.ExportNamedDeclaration | T.ExportNamedDeclaration) { - passSourceAsString(node.source, 'esm', fn); + passSourceAsString(fn, node.source, node, 'esm'); }, ExportAllDeclaration(node: TSESTree.ExportAllDeclaration | T.ExportAllDeclaration) { - passSourceAsString(node.source, 'esm', fn); + passSourceAsString(fn, node.source, node, 'esm'); }, ImportExpression(node: TSESTree.ImportExpression) { - passSourceAsString(node.source, 'esm', fn); + passSourceAsString(fn, node.source, node, 'esm'); }, - CallExpression({ callee, arguments: args }: TSESTree.CallExpression | T.CallExpression) { + CallExpression(node: TSESTree.CallExpression | T.CallExpression) { + const { callee, arguments: args } = node; // babel parser used for .js files treats import() calls as CallExpressions with callees of type "Import" if (T.isImport(callee)) { - passSourceAsString(args[0], 'esm', fn); + passSourceAsString(fn, args[0], node, 'esm'); return; } // is this a `require()` call? if (isIdent(callee) && callee.name === 'require') { - passSourceAsString(args[0], 'require', fn); + passSourceAsString(fn, args[0], node, 'require'); return; } @@ -103,12 +132,12 @@ export function visitAllImportStatements(fn: Visitor) { // is it "require.resolve()"? if (name === 'require.resolve') { - passSourceAsString(args[0], 'require-resolve', fn); + passSourceAsString(fn, args[0], node, 'require-resolve'); } // is it one of jest's mock methods? if (left.name === 'jest' && JEST_MODULE_METHODS.includes(name)) { - passSourceAsString(args[0], 'jest', fn); + passSourceAsString(fn, args[0], node, 'jest'); } } }, diff --git a/packages/kbn-eslint-plugin-imports/src/index.ts b/packages/kbn-eslint-plugin-imports/src/index.ts index 5e6fd1f578a4a..24dd819502b58 100644 --- a/packages/kbn-eslint-plugin-imports/src/index.ts +++ b/packages/kbn-eslint-plugin-imports/src/index.ts @@ -9,6 +9,7 @@ export * from './get_import_resolver'; import { NoUnresolvableImportsRule } from './rules/no_unresolvable_imports'; import { UniformImportsRule } from './rules/uniform_imports'; +import { ExportsMovedPackagesRule } from './rules/exports_moved_packages'; /** * Custom ESLint rules, add `'@kbn/eslint-plugin-imports'` to your eslint config to use them @@ -17,4 +18,5 @@ import { UniformImportsRule } from './rules/uniform_imports'; export const rules = { no_unresolvable_imports: NoUnresolvableImportsRule, uniform_imports: UniformImportsRule, + exports_moved_packages: ExportsMovedPackagesRule, }; diff --git a/packages/kbn-eslint-plugin-imports/src/rules/exports_moved_packages.test.ts b/packages/kbn-eslint-plugin-imports/src/rules/exports_moved_packages.test.ts new file mode 100644 index 0000000000000..720d738e2ca90 --- /dev/null +++ b/packages/kbn-eslint-plugin-imports/src/rules/exports_moved_packages.test.ts @@ -0,0 +1,279 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { RuleTester } from 'eslint'; +import { ExportsMovedPackagesRule, MovedExportsRule } from './exports_moved_packages'; +import dedent from 'dedent'; + +const fmt = (str: TemplateStringsArray) => dedent(str) + '\n'; + +const OPTIONS: MovedExportsRule[][] = [ + [ + { + exportNames: ['foo', 'bar'], + fromPackage: 'old', + toPackage: 'new', + }, + ], +]; + +const tsTester = [ + '@typescript-eslint/parser', + new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + sourceType: 'module', + ecmaVersion: 2018, + ecmaFeatures: { + jsx: true, + }, + }, + }), +] as const; + +const babelTester = [ + '@babel/eslint-parser', + new RuleTester({ + parser: require.resolve('@babel/eslint-parser'), + parserOptions: { + sourceType: 'module', + ecmaVersion: 2018, + requireConfigFile: false, + babelOptions: { + presets: ['@kbn/babel-preset/node_preset'], + }, + }, + }), +] as const; + +for (const [name, tester] of [tsTester, babelTester]) { + describe(name, () => { + tester.run('@kbn/imports/exports_moved_packages', ExportsMovedPackagesRule, { + valid: [ + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + import { foo, bar as Bar } from 'new' + `, + }, + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + const { foo, bar: Bar } = require('new') + `, + }, + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + export async function x () { + const { foo, bar: Bar } = await import('new') + return { foo, Bar } + } + `, + }, + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + const Old = require('old') + `, + }, + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + import Old from 'old' + `, + }, + { + // we aren't going to try to figure out which imports you use from an async import in + // a Promise.all + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + const [{ foo }] = Promise.all([ + import('old') + ]) + `, + }, + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + export * from 'old' + `, + }, + ], + + invalid: [ + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + import { foo, bar as Bar } from 'old' + `, + errors: [ + { + line: 1, + message: 'Exports "foo", "bar" are now in package "new"', + }, + ], + output: fmt` + import { foo, bar as Bar } from 'new'; + `, + }, + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + import type { foo, bar as Bar } from 'old' + `, + errors: [ + { + line: 1, + message: 'Exports "foo", "bar" are now in package "new"', + }, + ], + output: fmt` + import type { foo, bar as Bar } from 'new'; + `, + }, + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + export { foo, box } from 'old'; + `, + errors: [ + { + line: 1, + message: 'Export "foo" is now in package "new"', + }, + ], + output: fmt` + export { box } from 'old'; + export { foo } from 'new'; + `, + }, + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + export { foo, bar as Bar } from 'old'; + `, + errors: [ + { + line: 1, + message: 'Exports "foo", "bar" are now in package "new"', + }, + ], + output: fmt` + export { foo, bar as Bar } from 'new'; + `, + }, + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + export type { foo, bar as Bar } from 'old' + `, + errors: [ + { + line: 1, + message: 'Exports "foo", "bar" are now in package "new"', + }, + ], + output: fmt` + export type { foo, bar as Bar } from 'new'; + `, + }, + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + export type { foo, box } from 'old'; + `, + errors: [ + { + line: 1, + message: 'Export "foo" is now in package "new"', + }, + ], + output: fmt` + export type { box } from 'old'; + export type { foo } from 'new'; + `, + }, + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + const { foo, bar: Bar } = require('old') + `, + errors: [ + { + line: 1, + message: 'Exports "foo", "bar" are now in package "new"', + }, + ], + output: fmt` + const { foo, bar: Bar } = require('new'); + `, + }, + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + export async function x () { + const { foo, bar: Bar } = await import('old') + return { foo, Bar } + } + `, + errors: [ + { + line: 2, + message: 'Exports "foo", "bar" are now in package "new"', + }, + ], + output: fmt` + export async function x () { + const { foo, bar: Bar } = await import('new'); + return { foo, Bar } + } + `, + }, + { + filename: 'foo.ts', + options: OPTIONS, + code: fmt` + export async function x () { + const { foo, box } = await import('old') + return { foo, box } + } + `, + errors: [ + { + line: 2, + message: 'Export "foo" is now in package "new"', + }, + ], + output: fmt` + export async function x () { + const { box } = await import('old') + const { foo } = await import('new'); + return { foo, box } + } + `, + }, + ], + }); + }); +} diff --git a/packages/kbn-eslint-plugin-imports/src/rules/exports_moved_packages.ts b/packages/kbn-eslint-plugin-imports/src/rules/exports_moved_packages.ts new file mode 100644 index 0000000000000..2962ab4a5cb12 --- /dev/null +++ b/packages/kbn-eslint-plugin-imports/src/rules/exports_moved_packages.ts @@ -0,0 +1,375 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Rule, AST } from 'eslint'; +import * as T from '@babel/types'; +import { TSESTree } from '@typescript-eslint/typescript-estree'; + +import { visitAllImportStatements, Importer } from '../helpers/visit_all_import_statements'; + +export interface MovedExportsRule { + fromPackage: string; + toPackage: string; + exportNames: string[]; +} + +interface Imported { + type: 'require' | 'import expression' | 'export' | 'export type' | 'import' | 'import type'; + node: + | TSESTree.ImportSpecifier + | T.ImportSpecifier + | TSESTree.Property + | T.Property + | TSESTree.ExportSpecifier + | T.ExportSpecifier; + name: string; + id?: string; +} + +interface BadImport extends Imported { + id: string; + newPkg: string; +} + +function getParent(node: T.Node | TSESTree.Node): T.Node | TSESTree.Node | undefined { + if ('parent' in node) { + return node.parent as any; + } +} + +function findDeclaration(node: T.Node | TSESTree.Node) { + let cursor: T.Node | TSESTree.Node | undefined = node; + while ( + cursor && + !T.isVariableDeclaration(cursor) && + cursor.type !== TSESTree.AST_NODE_TYPES.VariableDeclaration + ) { + cursor = getParent(cursor); + } + return cursor; +} + +function getBadImports(imported: Imported[], rules: MovedExportsRule[]): BadImport[] { + return imported.flatMap((i): BadImport | never[] => { + if (!i.id) { + return []; + } + + const name = i.name; + const match = rules.find((r) => r.exportNames.includes(name)); + if (!match) { + return []; + } + + return { + type: i.type, + node: i.node, + id: i.id, + name: i.name, + newPkg: match.toPackage, + }; + }); +} + +function inspectImports( + importer: Importer, + rules: MovedExportsRule[] +): undefined | { importCount: number; allBadImports: BadImport[] } { + // get import names from require() and await import() calls + if ( + T.isCallExpression(importer) || + importer.type === TSESTree.AST_NODE_TYPES.CallExpression || + importer.type === TSESTree.AST_NODE_TYPES.ImportExpression + ) { + const declaration = findDeclaration(importer); + if (!declaration || !declaration.declarations[0]) { + return; + } + const declarator = declaration.declarations[0]; + if ( + !T.isObjectPattern(declarator.id) && + declarator.id.type !== TSESTree.AST_NODE_TYPES.ObjectPattern + ) { + return; + } + + const properties = declarator.id.properties; + return { + importCount: properties.length, + allBadImports: getBadImports( + properties.flatMap((prop): Imported | never[] => { + if ( + prop.type !== TSESTree.AST_NODE_TYPES.Property || + prop.kind !== 'init' || + prop.key.type !== TSESTree.AST_NODE_TYPES.Identifier + ) { + return []; + } + + const name = prop.key.name; + const local = + prop.value.type === TSESTree.AST_NODE_TYPES.Identifier ? prop.value.name : undefined; + + return { + node: prop, + name, + type: + importer.type === TSESTree.AST_NODE_TYPES.ImportExpression || + T.isImport(importer.callee) + ? 'import expression' + : 'require', + id: !local ? undefined : name === local ? name : `${name}: ${local}`, + }; + }), + rules + ), + }; + } + + // get import names from import {} and export {} from + if ( + T.isImportDeclaration(importer) || + importer.type === TSESTree.AST_NODE_TYPES.ImportDeclaration || + T.isExportNamedDeclaration(importer) || + importer.type === TSESTree.AST_NODE_TYPES.ExportNamedDeclaration + ) { + const type = + T.isExportNamedDeclaration(importer) || + importer.type === TSESTree.AST_NODE_TYPES.ExportNamedDeclaration + ? importer.exportKind === 'type' + ? 'export type' + : 'export' + : (T.isImportDeclaration(importer) || + importer.type === TSESTree.AST_NODE_TYPES.ImportDeclaration) && + importer.importKind === 'type' + ? 'import type' + : 'import'; + + return { + importCount: importer.specifiers.length, + allBadImports: getBadImports( + importer.specifiers.flatMap((specifier): Imported | never[] => { + if ( + T.isImportSpecifier(specifier) || + specifier.type === TSESTree.AST_NODE_TYPES.ImportSpecifier + ) { + const name = T.isStringLiteral(specifier.imported) + ? specifier.imported.value + : specifier.imported.name; + const local = specifier.local.name; + return { + node: specifier, + name, + type, + id: name === local ? name : `${name} as ${local}`, + }; + } + + if ( + T.isExportSpecifier(specifier) || + specifier.type === TSESTree.AST_NODE_TYPES.ExportSpecifier + ) { + const name = T.isStringLiteral(specifier.exported) + ? specifier.exported.value + : specifier.exported.name; + const local = specifier.local.name; + return { + node: specifier, + name: local, + type, + id: name === local ? name : `${local} as ${name}`, + }; + } + + return []; + }), + rules + ), + }; + } +} + +export const ExportsMovedPackagesRule: Rule.RuleModule = { + meta: { + fixable: 'code', + schema: [ + { + type: 'array', + items: { + type: 'object', + properties: { + fromPackage: { + type: 'string', + }, + toPackage: { + type: 'string', + }, + exportNames: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + }, + ], + docs: { + url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.md#kbnimportsexports_moved_packages', + }, + }, + + create(context) { + const rules: MovedExportsRule[] = context.options[0]; + const source = context.getSourceCode(); + + // get the range for the entire "import", expanding require()/import() to their + // entire variable declaration and including the trailing newline if we can + // idenitify it + function getRangeWithNewline( + importer: Importer | T.VariableDeclaration | TSESTree.VariableDeclaration + ): AST.Range { + if ( + T.isCallExpression(importer) || + importer.type === TSESTree.AST_NODE_TYPES.CallExpression || + importer.type === TSESTree.AST_NODE_TYPES.ImportExpression + ) { + const declaration = findDeclaration(importer); + if (declaration) { + return getRangeWithNewline(declaration); + } + } + + const text = source.getText(importer as any, 0, 1); + const range = getRange(importer); + return text.endsWith('\n') ? [range[0], range[1] + 1] : range; + } + + function getRange( + nodeA: T.Node | TSESTree.Node | AST.Token, + nodeB: T.Node | TSESTree.Node | AST.Token = nodeA + ): AST.Range { + if (!nodeA.loc) { + throw new Error('unable to use babel AST nodes without locations'); + } + if (!nodeB.loc) { + throw new Error('unable to use babel AST nodes without locations'); + } + return [source.getIndexFromLoc(nodeA.loc.start), source.getIndexFromLoc(nodeB.loc.end)]; + } + + return visitAllImportStatements((req, { importer }) => { + if (!req) { + return; + } + + const rulesForRightPackage = rules.filter((m) => m.fromPackage === req); + if (!rulesForRightPackage.length) { + return; + } + + const { allBadImports, importCount } = inspectImports(importer, rulesForRightPackage) ?? {}; + if (!allBadImports?.length) { + return; + } + + const badImportsByNewPkg = new Map(); + const groupedBadImports = new Map>(); + for (const badProp of allBadImports) { + if (!groupedBadImports.has(badProp.type)) { + groupedBadImports.set(badProp.type, new Map()); + } + const typeGroup = groupedBadImports.get(badProp.type)!; + if (!typeGroup.has(badProp.newPkg)) { + typeGroup.set(badProp.newPkg, []); + } + + typeGroup.get(badProp.newPkg)!.push(badProp); + + const existing = badImportsByNewPkg.get(badProp.newPkg); + if (existing) { + existing.push(badProp); + } else { + badImportsByNewPkg.set(badProp.newPkg, [badProp]); + } + } + + context.report({ + node: importer as any, + message: Array.from(badImportsByNewPkg) + .map( + ([pkg, bad]) => + `Export${bad.length === 1 ? '' : 's'} ${bad.map((b) => `"${b.name}"`).join(', ')} ${ + bad.length === 1 ? 'is' : 'are' + } now in package "${pkg}"` + ) + .join('\n'), + *fix(fixer) { + const importerRange = getRangeWithNewline(importer); + + // insert new require() calls + for (const [type, badProps] of groupedBadImports) { + for (const [pkg, props] of badProps) { + switch (type) { + case 'require': + yield fixer.insertTextAfterRange( + importerRange, + `const { ${props.map((b) => b.id).join(', ')} } = require('${pkg}');\n` + ); + break; + case 'import expression': + yield fixer.insertTextAfterRange( + importerRange, + `const { ${props.map((b) => b.id).join(', ')} } = await import('${pkg}');\n` + ); + break; + case 'export': + yield fixer.insertTextAfterRange( + importerRange, + `export { ${props.map((b) => b.id).join(', ')} } from '${pkg}';\n` + ); + break; + case 'export type': + yield fixer.insertTextAfterRange( + importerRange, + `export type { ${props.map((b) => b.id).join(', ')} } from '${pkg}';\n` + ); + break; + case 'import': + yield fixer.insertTextAfterRange( + importerRange, + `import { ${props.map((b) => b.id).join(', ')} } from '${pkg}';\n` + ); + break; + case 'import type': + yield fixer.insertTextAfterRange( + importerRange, + `import type { ${props.map((b) => b.id).join(', ')} } from '${pkg}';\n` + ); + break; + } + } + } + + if (importCount === allBadImports.length) { + yield fixer.removeRange(importerRange); + } else { + for (const bp of allBadImports) { + const nextToken = source.getTokenAfter(bp.node as any); + if (nextToken?.value === ',') { + yield fixer.removeRange(getRange(bp.node, nextToken)); + } else { + yield fixer.removeRange(getRange(bp.node)); + } + } + } + }, + }); + }); + }, +}; diff --git a/packages/kbn-eslint-plugin-imports/src/rules/no_unresolvable_imports.ts b/packages/kbn-eslint-plugin-imports/src/rules/no_unresolvable_imports.ts index e9ff3e59506f6..e5b324ea0a3e8 100644 --- a/packages/kbn-eslint-plugin-imports/src/rules/no_unresolvable_imports.ts +++ b/packages/kbn-eslint-plugin-imports/src/rules/no_unresolvable_imports.ts @@ -14,6 +14,11 @@ import { getImportResolver } from '../get_import_resolver'; import { visitAllImportStatements } from '../helpers/visit_all_import_statements'; export const NoUnresolvableImportsRule: Rule.RuleModule = { + meta: { + docs: { + url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.md#kbnimportsno_unresolvable_imports', + }, + }, create(context) { const resolver = getImportResolver(context); @@ -25,10 +30,10 @@ export const NoUnresolvableImportsRule: Rule.RuleModule = { throw new Error('unable to determine sourceFilename for file being linted'); } - return visitAllImportStatements((req, importer) => { + return visitAllImportStatements((req, { node }) => { if (req !== null && !resolver.resolve(req, Path.dirname(sourceFilename))) { report(context, { - node: importer, + node, message: `Unable to resolve import [${req}]`, }); } diff --git a/packages/kbn-eslint-plugin-imports/src/rules/uniform_imports.ts b/packages/kbn-eslint-plugin-imports/src/rules/uniform_imports.ts index 5239f68b7749e..75487e62cd613 100644 --- a/packages/kbn-eslint-plugin-imports/src/rules/uniform_imports.ts +++ b/packages/kbn-eslint-plugin-imports/src/rules/uniform_imports.ts @@ -26,6 +26,9 @@ const KBN_PM_SCRIPT = Path.resolve(REPO_ROOT, 'packages/kbn-pm/dist/index.js'); export const UniformImportsRule: Eslint.Rule.RuleModule = { meta: { fixable: 'code', + docs: { + url: 'https://github.com/elastic/kibana/blob/main/packages/kbn-eslint-plugin-imports/README.md#kbnimportsuniform_imports', + }, }, create(context) { @@ -38,7 +41,7 @@ export const UniformImportsRule: Eslint.Rule.RuleModule = { const ownPackageId = resolver.getPackageIdForPath(sourceFilename); - return visitAllImportStatements((req, importer, type) => { + return visitAllImportStatements((req, { node, type }) => { if (!req) { return; } @@ -67,7 +70,7 @@ export const UniformImportsRule: Eslint.Rule.RuleModule = { if (resolver.isBazelPackage(ownPackageId)) { report(context, { - node: importer, + node, message: `Package [${ownPackageId}] can only import other packages`, }); return; @@ -84,7 +87,7 @@ export const UniformImportsRule: Eslint.Rule.RuleModule = { if (req !== correct) { report(context, { - node: importer, + node, message: `Use import request [${correct}]`, correctImport: correct, }); @@ -95,7 +98,7 @@ export const UniformImportsRule: Eslint.Rule.RuleModule = { const packageDir = resolver.getAbsolutePackageDir(packageId); if (!packageDir) { report(context, { - node: importer, + node, message: `Unable to determine location of package [${packageId}]`, }); return; @@ -109,7 +112,7 @@ export const UniformImportsRule: Eslint.Rule.RuleModule = { }); if (req !== correct) { report(context, { - node: importer, + node, message: `Use import request [${correct}]`, correctImport: correct, });