UNPKG

@rnx-kit/eslint-plugin

Version:
464 lines (427 loc) 13.7 kB
// @ts-check "use strict"; /** * @typedef {import("@typescript-eslint/types").TSESTree.Node} Node * @typedef {import("eslint").Rule.RuleContext} ESLintRuleContext * @typedef {import("eslint").Rule.ReportFixer} ESLintReportFixer * @typedef {{ exports: string[]; types: string[]; }} NamedExports * * @typedef {{ * id: ESLintRuleContext["id"]; * options: { * debug: boolean; * expand: "all" | "external-only"; * maxDepth: number; * }; * filename: string; * languageOptions: ESLintRuleContext["languageOptions"]; * parserOptions: ESLintRuleContext["languageOptions"]["parserOptions"]; * parserPath: ESLintRuleContext["parserPath"]; * sourceCode: ESLintRuleContext["sourceCode"]; * }} RuleContext */ const fs = require("fs"); const path = require("path"); const DEFAULT_CONFIG = { ecmaVersion: 9, ecmaFeatures: { globalReturn: false, jsx: true, }, sourceType: "module", loc: true, range: true, raw: true, tokens: true, comment: true, eslintVisitorKeys: true, eslintScopeManager: true, }; const E_EXPORT_ALL = "export-all"; const E_NO_NAMED_EXPORTS = "no-named-exports"; const NODE_ENV = process.env.NODE_ENV; /** * Returns whether there are any named exports. * @param {NamedExports?} namedExports * @returns {namedExports is null} */ function isEmpty(namedExports) { return ( !namedExports || (namedExports.exports.length === 0 && namedExports.types.length === 0) ); } /** * Returns whether a module likely belongs to a project. * @param {(string | string[])?} project * @param {string} modulePath * @returns {boolean} */ function isLikelyInProject(project, modulePath) { if ( !project || (!modulePath.endsWith(".ts") && !modulePath.endsWith(".tsx")) ) { return false; } const tsconfig = typeof project === "string" ? project : project[0]; return !path.relative(path.dirname(tsconfig), modulePath).startsWith(".."); } /** * Creates and returns an ES tree traverser. * @returns {{ traverse: (node: Node, options: {}) => void; }} */ function makeTraverser() { const Traverser = require( path.join( path.dirname(require.resolve("eslint/package.json")), "lib", "shared", "traverser" ) ); return new Traverser(); } /** * Resolves specified `moduleId` starting from `fromDir`. When possible, prefers * `.d.ts` over `.js` as the latter does not contain type information. */ const resolveFrom = /** @type {() => (fromDir: string, moduleId: string) => string} */ ( () => { if (NODE_ENV === "test") { return (_, moduleId) => moduleId; } const resolve = require("enhanced-resolve").create.sync({ extensions: [".ts", ".tsx", ".js", ".jsx"], mainFields: ["module", "main"], // Add `require` to handle packages that are missing `default` // conditional. See // https://github.com/webpack/enhanced-resolve/issues/313 conditionNames: ["typescript", "import", "require", "node"], }); return (fromDir, moduleId) => { try { const m = resolve(fromDir, moduleId); if (!m) { throw new Error( `Module not found: ${moduleId} (start path: ${fromDir})` ); } if (m.endsWith(".js")) { // `.js` files don't contain type information. If we find a `.d.ts` // next to it, we should use that instead. const typedef = m.replace(/\.js$/, ".d.ts"); if (fs.existsSync(typedef)) { return typedef; } } return m; } catch (e) { // If the module id contains the `.js` extension due to ESM, // retry with `.ts` if (moduleId.endsWith(".js")) { const alternatives = [".ts", ".tsx"]; for (const alt of alternatives) { try { return resolve(fromDir, moduleId.replace(/\.js$/, alt)); } catch (_) { // Ignore the exception from the `.ts` file and rethrow // the `.js` one to avoid confusion } } } throw e; } }; } )(); /** * Converts ESLint's `RuleContext` to our `RuleContext`. * @param {ESLintRuleContext} context * @returns {RuleContext} */ function toRuleContext(context) { return { id: context.id, options: { debug: false, expand: "all", maxDepth: 5, ...context.options?.[0], }, filename: context.filename || context.getFilename(), languageOptions: context.languageOptions, parserOptions: context.languageOptions.parserOptions ?? context.parserOptions, parserPath: context.parserPath, sourceCode: context.sourceCode, }; } /** * Parses specified file and returns an AST. * @param {RuleContext} context * @param {string} moduleId * @returns {{ ast: Node; filename: string; } | null} */ function parse(context, moduleId) { const { filename, languageOptions, options, parserPath } = context; const parseForESLint = (() => { // @ts-expect-error This option exists when using flat config const parseForESLint = languageOptions?.parser?.parseForESLint; if (typeof parseForESLint === "function") { return parseForESLint; } // `parserPath` is only set when using legacy config format if (parserPath) { const { parseForESLint } = require(parserPath); return parseForESLint; } return null; })(); if (typeof parseForESLint !== "function") { return null; } try { const parentDir = path.dirname(filename); const modulePath = resolveFrom(parentDir, moduleId); const code = fs.readFileSync(modulePath, { encoding: "utf-8" }); const parserOptions = context.parserOptions ?? {}; const { project } = parserOptions; return { ast: parseForESLint(code, { ...DEFAULT_CONFIG, ...parserOptions, filePath: modulePath, /** * Unset `project` if we're trying to parse files outside the project, * otherwise `@typescript-eslint/parser` will complain: * * Error: "parserOptions.project" has been set for X. * The file does not match your project config: Y. * The file must be included in at least one of the projects provided. */ project: isLikelyInProject(project, modulePath) ? project : undefined, }).ast, filename: modulePath, }; } catch (e) { if (options.debug) { console.error( `[no-export-all] Failed to parse module '${moduleId}': ${e}` ); } } return null; } /** * Extracts exports from specified file. * @param {RuleContext} context * @param {unknown} moduleId * @param {number} depth * @returns {NamedExports | null} */ function extractExports(context, moduleId, depth) { if (depth === 0 || typeof moduleId !== "string") { return null; } const parseResult = parse(context, moduleId); if (!parseResult?.ast) { return null; } const { ast, filename } = parseResult; /** @type {Set<string>} */ const exports = new Set(); /** @type {Set<string>} */ const types = new Set(); makeTraverser().traverse(ast, { /** @type {(node: Node, parent: Node) => void} */ enter: (node, parent) => { switch (node.type) { case "ExportNamedDeclaration": { if (parent.type === "TSModuleBlock") { // The module or namespace is already exported. return; } const declaration = node.declaration; if (declaration) { switch (declaration.type) { case "ClassDeclaration": // fallthrough case "FunctionDeclaration": // fallthrough case "TSDeclareFunction": // fallthrough case "TSImportEqualsDeclaration": { const name = declaration.id && declaration.id.name; if (name) { exports.add(name); } break; } case "TSEnumDeclaration": { const name = declaration.id && declaration.id.name; if (name) { const ex = declaration.const ? types : exports; ex.add(name); } break; } // export namespace N { ... } case "TSModuleDeclaration": { switch (declaration.id.type) { case "Identifier": exports.add(declaration.id.name); break; case "Literal": { const name = declaration.id.value; if (typeof name === "string") { exports.add(name); } break; } } break; } case "TSInterfaceDeclaration": // fallthrough case "TSTypeAliasDeclaration": { const name = declaration.id && declaration.id.name; if (name) { types.add(name); } break; } case "VariableDeclaration": for (const decl of declaration.declarations) { if (decl.id.type === "Identifier") { exports.add(decl.id.name); } } break; } } else { const set = node.exportKind === "type" ? types : exports; for (const spec of node.specifiers) { const exported = spec.exported; const name = exported.type === "Identifier" ? exported.name : exported.value; if (name !== "default") { set.add(name); } } } break; } case "ExportAllDeclaration": { const source = node.source && node.source.value; if (source) { const namedExports = extractExports( { ...context, filename }, source, depth - 1 ); if (namedExports) { for (const name of namedExports.exports) { exports.add(name); } for (const name of namedExports.types) { types.add(name); } } } break; } // Prior to v6, a `TSImportEqualsDeclaration` will have an `isExport` // property instead of being wrapped in an `ExportNamedDeclaration`. // See https://github.com/typescript-eslint/typescript-eslint/issues/4130 case "TSImportEqualsDeclaration": if ("isExport" in node && node.isExport) { // export import foo = require('./foo'); // export import Bar = Foo.Bar; exports.add(node.id.name); } break; } }, }); return { exports: [...exports], types: [...types], }; } /** @type {import("eslint").Rule.RuleModule} */ module.exports = { meta: { type: "problem", docs: { description: "disallow `export *`", category: "Possible Errors", recommended: true, url: require("../../package.json").homepage, }, messages: { [E_EXPORT_ALL]: "Prefer explicit exports over `export *` to avoid name clashes, and improve tree-shakeability.", [E_NO_NAMED_EXPORTS]: "`export *` has no effect as there are no named exports to re-export.", }, fixable: "code", schema: [ { type: "object", properties: { debug: { type: "boolean" }, expand: { enum: ["all", "external-only"] }, maxDepth: { type: "number" }, }, additionalProperties: false, }, ], }, create: (context) => { const ruleContext = toRuleContext(context); const { expand, maxDepth } = ruleContext.options; return { ExportAllDeclaration: (node) => { const source = node.source.value; const isInternal = source && source.toString().startsWith("."); // export * as foo from "foo"; const isExportNamespace = Boolean(node.exported); if ((expand === "external-only" && isInternal) || isExportNamespace) { return; } const result = extractExports(ruleContext, source, maxDepth); const hasNamedExports = !isEmpty(result); context.report({ node, messageId: hasNamedExports ? E_EXPORT_ALL : E_NO_NAMED_EXPORTS, fix: (fixer) => { if (!hasNamedExports) { // We currently cannot fix this automatically as there are side // effects and other cases to consider. For now, just report it. return null; } /** @type {string[]} */ const lines = []; if (result.types.length > 0) { const uniqueTypes = result.types.filter( (type) => !result.exports.includes(type) ); if (uniqueTypes.length > 0) { const types = uniqueTypes.sort().join(", "); lines.push(`export type { ${types} } from ${node.source.raw};`); } } if (result.exports.length > 0) { const names = result.exports.sort().join(", "); lines.push(`export { ${names} } from ${node.source.raw};`); } return fixer.replaceText(node, lines.join("\n")); }, }); }, }; }, };