mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
Add eslint rule to support breaking up packages (#130483)
This commit is contained in:
parent
34dfeeb7ec
commit
20f05e2b15
10 changed files with 786 additions and 31 deletions
|
@ -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",
|
||||
]
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.
|
||||
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.
|
13
packages/kbn-eslint-plugin-imports/jest.config.js
Normal file
13
packages/kbn-eslint-plugin-imports/jest.config.js
Normal file
|
@ -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: ['<rootDir>/packages/kbn-eslint-plugin-imports'],
|
||||
};
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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<string, typeof allBadImports>();
|
||||
const groupedBadImports = new Map<BadImport['type'], Map<string, typeof allBadImports>>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
|
@ -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}]`,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue