kibana/packages/kbn-eslint-plugin-eslint/helpers/exports.js
Spencer fecdba7eba
[eslint] add rule to prevent export* in plugin index files (#109357)
* [eslint] add rule to prevent export* in plugin index files

* deduplicate export names for types/instances with the same name

* attempt to auto-fix duplicate exports too

* capture exported enums too

* enforce no_export_all for core too

* disable rule by default, allow opting-in for help fixing

* update tests

* reduce yarn.lock duplication

* add rule but no fixes

* disable all existing violations

* update api docs with new line numbers

* revert unnecessary changes to yarn.lock which only had drawbacks

* remove unnecessary eslint-disable

* rework codegen to split type exports and use babel to generate valid code

* check for "export types" deeply

* improve test by using fixtures

* add comments to some helper functions

* disable fix for namespace exports including types

* label all eslint-disable comments with related team-specific issue

* ensure that child exports of `export type` are always tracked as types

Co-authored-by: spalger <spalger@users.noreply.github.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
2021-09-01 18:05:45 -07:00

206 lines
5.6 KiB
JavaScript

/*
* 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.
*/
const Fs = require('fs');
const Path = require('path');
const ts = require('typescript');
const { REPO_ROOT } = require('@kbn/dev-utils');
const { ExportSet } = require('./export_set');
/** @typedef {import("@typescript-eslint/types").TSESTree.ExportAllDeclaration} ExportAllDeclaration */
/** @typedef {import("estree").Node} Node */
/** @typedef {(path: string) => ts.SourceFile} Parser */
/** @typedef {ts.Identifier|ts.BindingName} ExportNameNode */
const RESOLUTION_EXTENSIONS = ['.js', '.json', '.ts', '.tsx', '.d.ts'];
/** @param {ts.Statement} node */
const hasExportMod = (node) => node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
/** @param {string} path */
const safeStat = (path) => {
try {
return Fs.statSync(path);
} catch (error) {
if (error.code === 'ENOENT') {
return undefined;
}
throw error;
}
};
/**
* @param {string} dir
* @param {string} specifier
* @returns {string|undefined}
*/
const normalizeRelativeSpecifier = (dir, specifier) => {
if (specifier.startsWith('src/') || specifier.startsWith('x-pack/')) {
return Path.resolve(REPO_ROOT, specifier);
}
if (specifier.startsWith('.')) {
return Path.resolve(dir, specifier);
}
};
/**
* @param {string} basePath
* @returns {string | undefined}
*/
const checkExtensions = (basePath) => {
for (const ext of RESOLUTION_EXTENSIONS) {
const withExt = `${basePath}${ext}`;
const stats = safeStat(withExt);
if (stats?.isFile()) {
return withExt;
}
}
};
/**
* @param {string} dir
* @param {string} specifier
* @returns {string|undefined}
*/
const getImportPath = (dir, specifier) => {
const base = normalizeRelativeSpecifier(dir, specifier);
if (!specifier) {
return undefined;
}
const noExt = safeStat(base);
if (noExt && noExt.isFile()) {
return base;
}
if (noExt && noExt.isDirectory()) {
return checkExtensions(Path.resolve(base, 'index'));
}
if (Path.extname(base) !== '') {
return;
}
return checkExtensions(base);
};
/**
* Recursively traverse from a file path to collect all the exported values/types
* from the file. Returns an ExportSet which groups the exports by type, either
* "value" or "type" exports.
*
* @param {Parser} parser
* @param {string} from
* @param {ts.ExportDeclaration} exportFrom
* @param {ExportSet | undefined} exportSet only passed when called recursively
* @param {boolean | undefined} assumeAllTypes only passed when called recursively
* @returns {ExportSet | undefined}
*/
const getExportNamesDeep = (
parser,
from,
exportFrom,
exportSet = new ExportSet(),
assumeAllTypes = false
) => {
const specifier = ts.isStringLiteral(exportFrom.moduleSpecifier)
? exportFrom.moduleSpecifier.text
: undefined;
if (!specifier) {
return undefined;
}
const importPath = getImportPath(Path.dirname(from), specifier);
if (!importPath) {
return undefined;
}
const sourceFile = parser(importPath);
for (const statement of sourceFile.statements) {
// export function xyz() ...
if (ts.isFunctionDeclaration(statement) && statement.name && hasExportMod(statement)) {
exportSet.add(assumeAllTypes ? 'type' : 'value', statement.name.getText());
continue;
}
// export const/let foo = ...
if (ts.isVariableStatement(statement) && hasExportMod(statement)) {
for (const dec of statement.declarationList.declarations) {
exportSet.add(assumeAllTypes ? 'type' : 'value', dec.name.getText());
}
continue;
}
// export class xyc
if (ts.isClassDeclaration(statement) && statement.name && hasExportMod(statement)) {
exportSet.add(assumeAllTypes ? 'type' : 'value', statement.name.getText());
continue;
}
// export interface Foo {...}
if (ts.isInterfaceDeclaration(statement) && hasExportMod(statement)) {
exportSet.add('type', statement.name.getText());
continue;
}
// export type Foo = ...
if (ts.isTypeAliasDeclaration(statement) && hasExportMod(statement)) {
exportSet.add('type', statement.name.getText());
continue;
}
// export enum ...
if (ts.isEnumDeclaration(statement) && hasExportMod(statement)) {
exportSet.add(assumeAllTypes ? 'type' : 'value', statement.name.getText());
continue;
}
if (ts.isExportDeclaration(statement)) {
const clause = statement.exportClause;
const types = assumeAllTypes || statement.isTypeOnly;
// export * from '../foo';
if (!clause) {
const childTypes = getExportNamesDeep(
parser,
sourceFile.fileName,
statement,
exportSet,
types
);
if (!childTypes) {
// abort if we can't get all the exported names
return undefined;
}
continue;
}
// export * as foo from './foo'
if (ts.isNamespaceExport(clause)) {
exportSet.add(types ? 'type' : 'value', clause.name.getText());
continue;
}
// export { foo }
// export { foo as x } from 'other'
// export { default as foo } from 'other'
for (const e of clause.elements) {
exportSet.add(types ? 'type' : 'value', e.name.getText());
}
continue;
}
}
return exportSet;
};
module.exports = { getExportNamesDeep };