├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── js-unused-exports.js ├── img └── screenshot.png ├── package-lock.json ├── package.json ├── src ├── cli.js ├── createContext.js ├── extractUnusedExports.js ├── fixExports.js ├── generateReport.js ├── getExports.js ├── getImports.js ├── polyfill.js └── utils.js └── test ├── cli.spec.js ├── extractUnusedExports.spec.js ├── fixExports.spec.js ├── getExports.spec.js ├── getImports.spec.js ├── monorepo-project ├── package.json └── packages │ ├── client-native │ ├── entry.js │ └── package.json │ ├── client-web │ ├── entry.js │ └── package.json │ └── common │ ├── index.js │ ├── native.js │ ├── package.json │ ├── src │ ├── AppState.native.js │ ├── AppState.web.js │ └── logic.js │ └── web.js ├── sample-project ├── package.json └── src │ ├── all-export.js │ ├── exports-sample.js │ └── imports-sample.js └── utils.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": 10 8 | } 9 | } 10 | ] 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["eslint:recommended", "prettier"], 4 | "plugins": ["prettier"], 5 | "rules": { 6 | "no-console": "off", 7 | "prettier/prettier": "error" 8 | }, 9 | "env": { 10 | "es6": true, 11 | "node": true, 12 | "jest": true 13 | }, 14 | "globals": {}, 15 | "settings": {} 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | configs 4 | lib 5 | \.coverage 6 | \.vscode 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | .coverage 3 | lib 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.2.1 (2021-05-28) 4 | 5 | * Update dependencies 6 | 7 | ## v1.2.0 (2020-12-15) 8 | 9 | * Support for monorepo 10 | * Change to enhanced-resolver for handling import paths 11 | 12 | ## v1.1.1 (2020-07-20) 13 | 14 | * Update dependencies 15 | 16 | ## v1.1.0 (2020-06-17) 17 | 18 | * Add option to specify patterns for ignoring exports 19 | 20 | ## v1.0.1 (2020-05-11) 21 | 22 | * Exclude tests from acceptable identifiers 23 | * Update dependencies 24 | 25 | ## v1.0.0 (2020-01-23) 26 | 27 | * Initial release 28 | 29 | ## v0.1.0 (2019-01-12) 30 | 31 | * Initial public release 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unused Exports 2 | 3 | Tool for identifying and reporting unused exports found in ECMAScript/JavaScript code. 4 | 5 | You may think of it as ESLint `no-unused-vars` rule, but for the whole project scope. 6 | 7 | Tool uses [Babel parser](https://babeljs.io/docs/en/babel-parser) to parse source code. You can provide custom parser options using config file. 8 | 9 | ## Instalation 10 | 11 | ```shell 12 | npm install -g js-unused-exports 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```pre 18 | Usage: js-unused-exports [options] 19 | E.g.: js-unused-exports --config "unused-exports-config.json" 20 | 21 | Options: 22 | -v, --version output the version number 23 | -c, --config [path] path to the JSON config file 24 | -o --out-dir [path] path to print scan results as JSON 25 | -f, --fix automatically remove "export" directive where possible 26 | -h, --help output usage information 27 | ``` 28 | 29 |  30 | 31 | ## Configuration 32 | 33 | ```javascript 34 | { 35 | // Root project directory or CWD (default) 36 | projectRoot: '', 37 | 38 | // Source paths relative to project root 39 | sourcePaths: ['src/**/*.js'], 40 | 41 | // Patterns for files that should be ignored 42 | ignorePaths: [], 43 | 44 | // Test file patterns 45 | testPaths: [ 46 | 'src/**/*.spec.js' 47 | ], 48 | 49 | // Import patterns to ignore 50 | ignoreImportPatterns: [ '(png|gif|jpg|jpeg|css|scss)$' ], 51 | 52 | // Export patterns to ignore 53 | ignoreExportPatterns: [ '(stories)' ], 54 | 55 | // If you use alias in you codebase you can specify them here, e.g.: 56 | aliases: { 57 | components: 'src/components' 58 | }, 59 | 60 | // @babel/parser options for parsing source code 61 | // https://babeljs.io/docs/en/babel-parser 62 | parserOptions: { 63 | sourceType: 'module', 64 | plugins: [ 65 | 'objectRestSpread', 66 | 'jsx', 67 | 'flow', 68 | 'classProperties', 69 | 'decorators-legacy', 70 | 'exportDefaultFrom' 71 | ] 72 | } 73 | } 74 | ``` 75 | -------------------------------------------------------------------------------- /bin/js-unused-exports.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const program = require('commander'); 4 | const pkg = require('../package.json'); 5 | const execute = require('../lib/cli').default; 6 | 7 | program 8 | .version(pkg.version, '-v, --version') 9 | .usage('[options]') 10 | .option('-c, --config [path]', 'path to the JSON config file') 11 | .option('-o --out-dir [path]', 'path to print scan results as JSON') 12 | .option('-f, --fix', 'automatically remove "export" directive where possible') 13 | .option('-v, --verbose', 'use verbose logging') 14 | .parse(process.argv); 15 | 16 | execute(program); 17 | -------------------------------------------------------------------------------- /img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devbridge/js-unused-exports/2c8e97cd68ff99f2df015bd1df99bc0b36f9ff02/img/screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "js-unused-exports", 3 | "version": "1.2.1", 4 | "description": "Tool for identifying and reporting unused exports found in ECMAScript/JavaScript code", 5 | "main": "lib/cli.js", 6 | "author": "Devbridge Group", 7 | "license": "MIT", 8 | "bin": { 9 | "js-unused-exports": "./bin/js-unused-exports.js" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/devbridge/js-unused-exports.git" 14 | }, 15 | "scripts": { 16 | "test": "jest test --runInBand", 17 | "lint": "eslint src test", 18 | "prepublish": "babel src --out-dir lib" 19 | }, 20 | "files": [ 21 | "README.md", 22 | "LICENSE", 23 | "bin/", 24 | "lib/" 25 | ], 26 | "dependencies": { 27 | "@babel/code-frame": "^7.14.5", 28 | "@babel/parser": "^7.15.6", 29 | "@babel/traverse": "^7.15.4", 30 | "@babel/types": "^7.15.6", 31 | "chalk": "^4.1.2", 32 | "commander": "^8.2.0", 33 | "enhanced-resolve": "^5.8.2", 34 | "glob": "^7.1.7", 35 | "json5": "^2.2.0" 36 | }, 37 | "devDependencies": { 38 | "@babel/cli": "^7.15.4", 39 | "@babel/preset-env": "^7.15.6", 40 | "babel-eslint": "^10.1.0", 41 | "eslint": "^7.32.0", 42 | "eslint-config-prettier": "^8.3.0", 43 | "eslint-plugin-prettier": "^4.0.0", 44 | "jest": "^27.2.0", 45 | "prettier": "^2.4.1" 46 | }, 47 | "keywords": [ 48 | "linting", 49 | "unused", 50 | "exports", 51 | "javascript" 52 | ], 53 | "jest": { 54 | "collectCoverage": true, 55 | "coverageDirectory": "./.coverage", 56 | "testEnvironment": "node", 57 | "setupFiles": [ 58 | "./src/polyfill.js" 59 | ] 60 | }, 61 | "prettier": { 62 | "tabWidth": 2, 63 | "singleQuote": true 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import chalk from 'chalk'; 4 | import JSON5 from 'json5'; 5 | import './polyfill'; 6 | import { getSourcePaths } from './utils'; 7 | import extractUnusedExports from './extractUnusedExports'; 8 | import fixExports from './fixExports'; 9 | import printReport from './generateReport'; 10 | import createContext from './createContext'; 11 | import getExports from './getExports'; 12 | import getImports from './getImports'; 13 | import { isPlainObject } from './utils'; 14 | 15 | const warn = chalk.yellow; 16 | const info = chalk.green; 17 | 18 | export function checkUnused(ctx) { 19 | const { config } = ctx; 20 | const timeStart = Date.now(); 21 | 22 | const sourceFiles = getSourcePaths(config.sourcePaths, config); 23 | const testFiles = getSourcePaths(config.testPaths, config); 24 | const filteredSourceFiles = sourceFiles.filter( 25 | (sourceFile) => !testFiles.includes(sourceFile) 26 | ); 27 | 28 | const exportedNames = getExports(filteredSourceFiles, ctx); 29 | const importedNames = getImports(filteredSourceFiles, ctx); 30 | const unusedExports = extractUnusedExports( 31 | exportedNames, 32 | importedNames, 33 | testFiles 34 | ); 35 | 36 | const timeEnd = Date.now(); 37 | const timeTook = timeEnd - timeStart; 38 | 39 | const { unknownPackages, failedResolutions } = ctx; 40 | 41 | return { 42 | sourceFileCount: sourceFiles.length, 43 | testFileCount: testFiles.length, 44 | exportedNames, 45 | importedNames, 46 | unusedExports, 47 | unknownPackages, 48 | failedResolutions, 49 | timeTook, 50 | }; 51 | } 52 | 53 | export default function execute(args) { 54 | const userConfig = getConfig(args.config); 55 | const ctx = createContext(userConfig); 56 | const { config } = ctx; 57 | 58 | if (args.verbose) { 59 | printBox(`Current Configuration`); 60 | console.log(JSON.stringify(ctx.config, null, 2)); 61 | } 62 | 63 | const summary = checkUnused(ctx); 64 | const { 65 | unusedExports, 66 | exportedNames, 67 | importedNames, 68 | unknownPackages, 69 | failedResolutions, 70 | } = summary; 71 | 72 | warnForUnknownPackages(unknownPackages); 73 | warnForFailedResolutions(failedResolutions, config.projectRoot); 74 | 75 | if (args.fix) { 76 | fixExports(unusedExports, config); 77 | } else { 78 | printBox(`Report`); 79 | printReport(unusedExports, config.projectRoot); 80 | } 81 | 82 | const { outDir } = args; 83 | 84 | if (outDir) { 85 | printBox('Save Results'); 86 | 87 | const dirPath = path.resolve(outDir); 88 | 89 | if (fs.existsSync(dirPath)) { 90 | writeToFile(exportedNames, dirPath, 'exports.json'); 91 | writeToFile(importedNames, dirPath, 'imports.json'); 92 | writeToFile(unusedExports, dirPath, 'unused.json'); 93 | } else { 94 | printWarning(`WARNING: output dir deas not exist - ${dirPath}`); 95 | } 96 | } 97 | 98 | printSummary(summary); 99 | 100 | return summary; 101 | } 102 | 103 | function getConfig(configPath) { 104 | if (typeof configPath !== 'string') { 105 | return isPlainObject(configPath) ? configPath : {}; 106 | } 107 | 108 | const absolutPath = path.resolve(configPath); 109 | 110 | if (!fs.existsSync(configPath)) { 111 | printWarning('Unable to find config file: ' + absolutPath); 112 | return null; 113 | } 114 | 115 | return JSON5.parse(fs.readFileSync(absolutPath, 'utf8')); 116 | } 117 | 118 | function print(message) { 119 | console.log(info(message)); 120 | } 121 | 122 | function printSummary(summary) { 123 | const { timeTook, sourceFileCount, testFileCount, unusedExports } = summary; 124 | 125 | const unusedExportCount = unusedExports.reduce( 126 | (acc, exp) => acc + exp.unusedExports.length, 127 | 0 128 | ); 129 | 130 | const fileCount = unusedExports.length; 131 | 132 | printBox(`Unused Exports Summary`); 133 | print(` Unused export count: ${unusedExportCount} `); 134 | print(` Affected file count: ${fileCount} `); 135 | print(` Total source files: ${sourceFileCount} `); 136 | print(` Total test files: ${testFileCount} `); 137 | print(` Completed in: ${timeTook} ms `); 138 | } 139 | 140 | function printBox(value) { 141 | const width = 60; 142 | const padding = (width - value.length) / 2; 143 | const startPadding = Math.floor(padding); 144 | const endPaddig = Math.ceil(padding); 145 | 146 | print(`┌${'─'.repeat(width)}┐`); 147 | print(`|${' '.repeat(startPadding)}${value}${' '.repeat(endPaddig)}|`); 148 | print(`└${'─'.repeat(width)}┘`); 149 | } 150 | 151 | function printWarning(message) { 152 | console.log(warn(message)); 153 | } 154 | 155 | function warnForUnknownPackages(unknownPackages) { 156 | const unresolvePackages = unknownPackages; 157 | 158 | if (unresolvePackages.length === 0) { 159 | return; 160 | } 161 | 162 | const message = 163 | 'Unknown packages found. Add package to ' + 164 | 'package.json dependency list or specify an alias'; 165 | 166 | printWarning(message); 167 | 168 | unresolvePackages.forEach((pkg) => { 169 | printWarning(` ${pkg} `); 170 | }); 171 | } 172 | 173 | function warnForFailedResolutions(failedResolutions, projectRoot) { 174 | if (!failedResolutions.length) { 175 | return; 176 | } 177 | 178 | const message = [ 179 | 'Unable to resolve following import paths. Please', 180 | 'specify "alias" if needed or add pattern to', 181 | '"ignoreImportPatterns" in provided config file.', 182 | ].join(' '); 183 | 184 | printWarning(message); 185 | 186 | [...failedResolutions].sort().forEach((importPath) => { 187 | const relativePath = path.relative(projectRoot, importPath); 188 | printWarning(` ${relativePath} `); 189 | }); 190 | } 191 | 192 | function writeToFile(contents, outDir, fileName) { 193 | const resultsPath = path.join(outDir, fileName); 194 | print(resultsPath); 195 | fs.writeFileSync(resultsPath, JSON.stringify(contents, null, 2)); 196 | } 197 | -------------------------------------------------------------------------------- /src/createContext.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import assert from 'assert'; 3 | import resolve from 'enhanced-resolve'; 4 | import { isPlainObject } from './utils'; 5 | 6 | export const defaultParserOptions = { 7 | sourceType: 'module', 8 | plugins: [ 9 | 'objectRestSpread', 10 | 'jsx', 11 | 'flow', 12 | 'classProperties', 13 | 14 | // Required by frontend 15 | 'decorators-legacy', 16 | 'exportDefaultFrom', 17 | ], 18 | }; 19 | 20 | const CONFIG_DEFAULTS = { 21 | sourcePaths: ['src/**/*.js'], 22 | testPaths: ['src/**/*.spec.js'], 23 | ignoreImportPatterns: ['node_modules', '(png|gif|jpg|jpeg|css|scss)$'], 24 | aliases: {}, 25 | parserOptions: defaultParserOptions, 26 | }; 27 | 28 | export default function createContext(userConfig) { 29 | const config = { 30 | ...CONFIG_DEFAULTS, 31 | ...userConfig, 32 | projectRoot: userConfig.projectRoot || process.cwd(), 33 | resolve: 34 | userConfig.resolve || 35 | resolve.create.sync({ 36 | alias: userConfig.aliases, 37 | extensions: ['.js', '.jsx'], 38 | }), 39 | }; 40 | 41 | assertConfig(config); 42 | 43 | return { 44 | config, 45 | unknownPackages: [], 46 | failedResolutions: [], 47 | }; 48 | } 49 | 50 | function assertConfig(config) { 51 | assert( 52 | typeof config.projectRoot === 'string', 53 | 'Conifg "projectRoot" value must be a string' 54 | ); 55 | 56 | assert( 57 | Array.isArray(config.sourcePaths), 58 | 'Conifg "sourcePaths" must be an array' 59 | ); 60 | 61 | assert( 62 | isPlainObject(config.parserOptions), 63 | 'Missing valid "parserOptions" value' 64 | ); 65 | 66 | assert( 67 | fs.existsSync(config.projectRoot), 68 | 'Path "projectRoot" does not exist: ' + config.projectRoot 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/extractUnusedExports.js: -------------------------------------------------------------------------------- 1 | function createLookupTable(importedIdentifiers) { 2 | const table = new Set(); 3 | const getKey = (sourcePath, name) => `${sourcePath}:${name}`; 4 | 5 | // Build a set for quick lookup, where key is combined using path and name 6 | importedIdentifiers.forEach(({ imports }) => 7 | Object.entries(imports).forEach(([importedFrom, names]) => 8 | names.forEach((name) => table.add(getKey(importedFrom, name))) 9 | ) 10 | ); 11 | 12 | return (sourcePath, name) => { 13 | if (Array.isArray(name)) { 14 | return name.some((n) => table.has(getKey(sourcePath, n))); 15 | } 16 | return table.has(getKey(sourcePath, name)); 17 | }; 18 | } 19 | 20 | export default function extractUnusedExports( 21 | exportedNames, 22 | importedIdentifiers 23 | ) { 24 | const isImported = createLookupTable(importedIdentifiers); 25 | 26 | return exportedNames 27 | .map(({ sourcePath, exports }) => { 28 | const unusedExports = exports.filter( 29 | ({ name }) => !isImported(sourcePath, name) 30 | ); 31 | 32 | return { sourcePath, unusedExports }; 33 | }) 34 | .filter(({ unusedExports }) => unusedExports.length); 35 | } 36 | -------------------------------------------------------------------------------- /src/fixExports.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { escapeRegExp } from './utils'; 3 | 4 | export default function fixExports(unusedExports, config) { 5 | Object.values(unusedExports).forEach((unused) => { 6 | const indentifierNames = unused.unusedExports.map((exp) => exp.name); 7 | const sourceBefore = fs.readFileSync(unused.sourcePath, 'utf8'); 8 | 9 | const sourceAfter = removeExportDeclarations( 10 | sourceBefore, 11 | indentifierNames, 12 | config 13 | ); 14 | 15 | fs.writeFileSync(unused.sourcePath, sourceAfter); 16 | }); 17 | } 18 | 19 | export function removeExportDeclarations(source, identifierNames) { 20 | const identifiers = identifierNames.map(escapeRegExp).join('|'); 21 | 22 | const re = new RegExp( 23 | `export\\s+((const|function)\\s+(${identifiers}))\\b`, 24 | 'g' 25 | ); 26 | 27 | return source.replace(re, '$1'); 28 | } 29 | -------------------------------------------------------------------------------- /src/generateReport.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { codeFrameColumns } from '@babel/code-frame'; 4 | 5 | export default function generateReport(unusedExports, projectRoot) { 6 | const frameOptions = { 7 | highlightCode: true, 8 | linesAbove: 0, 9 | linesBelow: 0, 10 | }; 11 | 12 | const maxFilesToDisplay = 50; 13 | const entries = Object.entries(unusedExports); 14 | 15 | entries 16 | .slice(0, maxFilesToDisplay) 17 | .forEach(([key, { sourcePath, unusedExports }]) => { 18 | const relativePath = path.relative(projectRoot, sourcePath); 19 | 20 | console.log(`${key}: ${relativePath}`); 21 | const src = fs.readFileSync(sourcePath, 'utf8'); 22 | 23 | Object.values(unusedExports).forEach((exp) => { 24 | if (exp.loc) { 25 | const loc = { start: exp.loc.start }; 26 | console.log(codeFrameColumns(src, loc, frameOptions)); 27 | } else { 28 | console.log(` - ${exp.name}`); 29 | } 30 | }); 31 | console.log(''); 32 | }); 33 | 34 | if (entries.length > maxFilesToDisplay) { 35 | console.log(''); 36 | console.log( 37 | `Showing ${maxFilesToDisplay} of ${entries.length} affected files` 38 | ); 39 | console.log(''); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/getExports.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { parse } from '@babel/parser'; 4 | import { 5 | isExportDefaultDeclaration, 6 | isExportNamedDeclaration, 7 | isExportAllDeclaration, 8 | isVariableDeclaration, 9 | isFunctionDeclaration, 10 | isClassDeclaration, 11 | isTypeAlias, 12 | isOpaqueType, 13 | isInterfaceDeclaration, 14 | } from '@babel/types'; 15 | 16 | function createIsNotIgnoredFile(ignoreExportPatterns = []) { 17 | const ignores = ignoreExportPatterns.map((pattern) => new RegExp(pattern)); 18 | return (sourcePath) => !ignores.some((regExp) => regExp.test(sourcePath)); 19 | } 20 | 21 | export default function getExports(sourcePaths, ctx) { 22 | const { ignoreExportPatterns = [] } = ctx.config; 23 | const isNotIgnored = createIsNotIgnoredFile(ignoreExportPatterns); 24 | 25 | return sourcePaths.filter(isNotIgnored).flatMap((sourcePath) => { 26 | const source = fs.readFileSync(sourcePath, 'utf8'); 27 | return getExportData(source, sourcePath, ctx); 28 | }); 29 | } 30 | 31 | export function getExportData(source, sourcePath, ctx) { 32 | const exports = getExportedIdentifiers(source, sourcePath, ctx); 33 | 34 | return { 35 | sourcePath, 36 | exports, 37 | }; 38 | } 39 | 40 | /** 41 | * Checks if node is export declaration. 42 | * 43 | * @param {AstNode} node 44 | */ 45 | function isExportDeclaration(node) { 46 | return ( 47 | isExportNamedDeclaration(node) || 48 | isExportDefaultDeclaration(node) || 49 | isExportAllDeclaration(node) 50 | ); 51 | } 52 | 53 | /** 54 | * Returns an array of exported identifiers 55 | * with their name and location information. 56 | * 57 | * @param {string} source Source code as string 58 | * @param {Object} parserOptions Parser options 59 | */ 60 | export function getExportedIdentifiers(source, sourcePath, ctx) { 61 | const { parserOptions } = ctx.config; 62 | 63 | const ast = parse(source, parserOptions); 64 | return ast.program.body 65 | .filter(isExportDeclaration) 66 | .flatMap((node) => getExportName(node, sourcePath, ctx)); 67 | } 68 | 69 | /** 70 | * Extracts exported identifier name and location. 71 | * May return single value or array of values. 72 | * 73 | * @param {AstNode} node 74 | */ 75 | export function getExportName(node, sourcePath, ctx) { 76 | const { loc, declaration } = node; 77 | if (isExportDefaultDeclaration(node)) { 78 | return { 79 | name: 'default', 80 | loc, 81 | }; 82 | } 83 | 84 | if (isExportAllDeclaration(node)) { 85 | const { resolve } = ctx.config; 86 | const { value: sourceValue } = node.source; 87 | 88 | let resolvedSourcePaths; 89 | try { 90 | const sourcePaths = resolve(path.dirname(sourcePath), sourceValue); 91 | resolvedSourcePaths = Array.isArray(sourcePaths) 92 | ? sourcePaths 93 | : [sourcePaths]; 94 | } catch (error) { 95 | return []; 96 | } 97 | 98 | const names = getExportNamesFromImport(resolvedSourcePaths, ctx); 99 | return { loc, name: names }; 100 | } 101 | 102 | if (isVariableDeclaration(declaration)) { 103 | return declaration.declarations.map((declaration) => ({ 104 | name: declaration.id.name, 105 | loc, 106 | })); 107 | } 108 | 109 | if ( 110 | isFunctionDeclaration(declaration) || 111 | isClassDeclaration(declaration) || 112 | isTypeAlias(declaration) || 113 | isOpaqueType(declaration) || 114 | isInterfaceDeclaration(declaration) 115 | ) { 116 | return { 117 | name: declaration.id.name, 118 | loc, 119 | }; 120 | } 121 | 122 | if (!declaration && node.specifiers) { 123 | return node.specifiers.map((specifier) => ({ 124 | name: specifier.exported.name, 125 | loc: specifier.exported.loc, 126 | })); 127 | } 128 | 129 | const { type } = declaration || node; 130 | throw new Error(`Unknow declaration type: ${type}`); 131 | } 132 | 133 | function getExportNamesFromImport(sourcePaths, ctx) { 134 | return sourcePaths 135 | .flatMap((sourcePath) => { 136 | const source = fs.readFileSync(sourcePath, 'utf8'); 137 | return getExportedIdentifiers(source, sourcePath, ctx); 138 | }) 139 | .flatMap(({ name }) => name) 140 | .filter((name, index, arr) => !arr.includes(name, index + 1)); 141 | } 142 | -------------------------------------------------------------------------------- /src/getImports.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { getType, PathType } from 'enhanced-resolve/lib/util/path'; 4 | import { parse } from '@babel/parser'; 5 | import traverse from '@babel/traverse'; 6 | import { 7 | isImportNamespaceSpecifier, 8 | isImportDefaultSpecifier, 9 | isMemberExpression, 10 | isIdentifier, 11 | isImportDeclaration, 12 | isExportAllDeclaration, 13 | isExportNamedDeclaration, 14 | isExportSpecifier, 15 | isImportSpecifier, 16 | } from '@babel/types'; 17 | 18 | export default function getImports(sourcePaths, ctx) { 19 | return sourcePaths 20 | .map((sourcePath) => { 21 | const source = fs.readFileSync(sourcePath, 'utf8'); 22 | const importData = getImportData(source, sourcePath, ctx); 23 | const imports = groupImports(importData); 24 | 25 | return { 26 | sourcePath, 27 | imports, 28 | }; 29 | }) 30 | .filter(({ imports }) => Object.keys(imports).length); 31 | } 32 | 33 | function groupImports(importData) { 34 | return importData.reduce((acc, { from, specifiers }) => { 35 | if (!acc[from]) { 36 | acc[from] = specifiers; 37 | } else { 38 | // Duplicate import from the same path 39 | acc[from] = acc[from].concat(specifiers); 40 | } 41 | 42 | acc[from].sort(); 43 | 44 | return acc; 45 | }, {}); 46 | } 47 | 48 | function getImportData(source, srcPath, ctx) { 49 | const { config } = ctx; 50 | const ast = parse(source, config.parserOptions); 51 | const nodes = ast.program.body.filter(isImport); 52 | 53 | return nodes 54 | .flatMap((node) => getImportDetails(node, srcPath, ast, ctx)) 55 | .filter( 56 | ({ specifiers, from }) => specifiers.length && !isIgnoredImport(from, ctx) 57 | ); 58 | } 59 | 60 | function isIgnoredImport(importPath, ctx) { 61 | const { ignoreImportPatterns = [] } = ctx.config; 62 | return ignoreImportPatterns.some((pattern) => 63 | new RegExp(pattern).test(importPath) 64 | ); 65 | } 66 | 67 | function isImport(node) { 68 | return ( 69 | (isImportDeclaration(node) || 70 | // When import is used together with export: 71 | // export { default } from './my-file'; 72 | isExportNamedDeclaration(node) || 73 | // export * from './my-file'; 74 | isExportAllDeclaration(node)) && 75 | node.source && 76 | node.source.value 77 | ); 78 | } 79 | 80 | function getNamespaceSpecifierNames(node, ast) { 81 | if (!node.specifiers) return []; 82 | 83 | // Get namespace imports: 84 | // import * as something from './somewhere' 85 | const specifierNodes = node.specifiers.filter(isImportNamespaceSpecifier); 86 | 87 | // Get identifiers that are used from this namespace 88 | // May not be acurate 89 | if (!specifierNodes.length) return []; 90 | 91 | const namespaceSpecifierNames = []; 92 | const localSpecifierNames = specifierNodes.map( 93 | (specifierNode) => specifierNode.local.name 94 | ); 95 | 96 | traverse(ast, { 97 | enter({ node: currentNode }) { 98 | if ( 99 | isMemberExpression(currentNode) && 100 | isIdentifier(currentNode.object) && 101 | localSpecifierNames.includes(currentNode.object.name) 102 | ) { 103 | namespaceSpecifierNames.push(currentNode.property.name); 104 | } 105 | }, 106 | }); 107 | 108 | return namespaceSpecifierNames; 109 | } 110 | 111 | function getDefaultSpecifierNames(node) { 112 | if (!node.specifiers) return []; 113 | if (node.specifiers.some(isImportDefaultSpecifier)) { 114 | return ['default']; 115 | } 116 | return []; 117 | } 118 | 119 | export function getImportDetails(node, srcPath, ast, ctx) { 120 | if (!node.source || !node.source.value) { 121 | return { specifiers: [] }; 122 | } 123 | const { 124 | source: { value: sourceValue }, 125 | specifiers: nodeSpecifiers = [], 126 | } = node; 127 | const from = resolvePath(sourceValue, srcPath, ctx); 128 | 129 | if (!from) { 130 | return { specifiers: [] }; 131 | } 132 | 133 | const flattenDetails = Array.isArray(from) 134 | ? (specifiers) => from.map((f) => ({ from: f, specifiers })) 135 | : (specifiers) => ({ from, specifiers }); 136 | 137 | // Case: import sampleFile, { firstName } from './sample-file'; 138 | if (isImportDeclaration(node)) { 139 | const specifiers = nodeSpecifiers 140 | .filter(isImportSpecifier) 141 | .map((specifier) => specifier.imported && specifier.imported.name) 142 | .filter(Boolean); 143 | 144 | return flattenDetails([ 145 | ...getDefaultSpecifierNames(node, ast), 146 | ...specifiers, 147 | ...getNamespaceSpecifierNames(node, ast), 148 | ]); 149 | } 150 | 151 | // Case: export { default as sampleFile, firstName } from './sample-file'; 152 | if (isExportNamedDeclaration(node)) { 153 | const specifiers = nodeSpecifiers 154 | .filter(isExportSpecifier) 155 | .map((specifier) => specifier.local.name); 156 | 157 | return flattenDetails(specifiers); 158 | } 159 | 160 | return flattenDetails([]); 161 | } 162 | 163 | export function isPackage(importValue) { 164 | return getType(importValue) !== PathType.Relative; 165 | } 166 | 167 | export function resolvePath(importValue, currentPath, ctx) { 168 | const { resolve } = ctx.config; 169 | const currentDir = path.dirname(currentPath); 170 | try { 171 | return resolve(currentDir, importValue); 172 | } catch (error) { 173 | if (isPackage(importValue)) { 174 | ctx.unknownPackages.push(importValue); 175 | } else { 176 | const filePath = path.resolve(currentDir, importValue); 177 | ctx.failedResolutions.push(filePath); 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/polyfill.js: -------------------------------------------------------------------------------- 1 | // Array.prototype.flatMap 2 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flatMap 3 | 4 | if (!Array.prototype.flatMap) { 5 | Object.defineProperty(Array.prototype, 'flatMap', { 6 | configurable: true, 7 | value: function flatMap(mapCallback, thisArg) { 8 | return this.reduce((acc, currentValue, index, array) => { 9 | const mapped = mapCallback.call(thisArg, currentValue, index, array); 10 | if (Array.isArray(mapped)) { 11 | acc.push(...mapped); 12 | } else { 13 | acc.push(mapped); 14 | } 15 | 16 | return acc; 17 | }, []); 18 | }, 19 | writable: true, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import glob from 'glob'; 3 | 4 | /** 5 | * 6 | * @param {string[]} globPatterns Array of glob patterns 7 | * @param {Object} config Glob options 8 | */ 9 | export function getSourcePaths(globPatterns, config) { 10 | const options = { 11 | cwd: config.projectRoot, 12 | ignore: config.ignorePaths, 13 | absolute: true, 14 | }; 15 | 16 | return globPatterns 17 | .flatMap((globPattern) => glob.sync(globPattern, options)) 18 | .map((sourcePath) => path.normalize(sourcePath)); 19 | } 20 | 21 | // Lifted from lodash 22 | function isObjectLike(value) { 23 | return typeof value === 'object' && value !== null; 24 | } 25 | 26 | export function isPlainObject(value) { 27 | if (!isObjectLike(value) || String(value) !== '[object Object]') { 28 | return false; 29 | } 30 | if (Object.getPrototypeOf(value) === null) { 31 | return true; 32 | } 33 | let proto = value; 34 | while (Object.getPrototypeOf(proto) !== null) { 35 | proto = Object.getPrototypeOf(proto); 36 | } 37 | return Object.getPrototypeOf(value) === proto; 38 | } 39 | 40 | const reRegExpChar = /[\\^$.*+?()[\]{}|]/g; 41 | const reHasRegExpChar = RegExp(reRegExpChar.source); 42 | 43 | export function escapeRegExp(string) { 44 | return string && reHasRegExpChar.test(string) 45 | ? string.replace(reRegExpChar, '\\$&') 46 | : string || ''; 47 | } 48 | -------------------------------------------------------------------------------- /test/cli.spec.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import resolve from 'enhanced-resolve'; 3 | import { checkUnused } from '../src/cli'; 4 | import createContext from '../src/createContext'; 5 | 6 | function testUnusedExports(unusedExports, expectations) { 7 | Object.entries(expectations).forEach( 8 | ([relativePath, expectedUnusedNames]) => { 9 | const unusedExport = unusedExports.find(({ sourcePath }) => 10 | sourcePath.endsWith(path.normalize(relativePath)) 11 | ); 12 | 13 | if (!unusedExport) { 14 | throw new Error(`No unused export with path ${relativePath}`); 15 | } 16 | const unusedNames = unusedExport.unusedExports.map(({ name }) => name); 17 | expect(unusedNames).toHaveLength(expectedUnusedNames.length); 18 | expectedUnusedNames.forEach((name) => 19 | expect(unusedNames).toContain(name) 20 | ); 21 | } 22 | ); 23 | } 24 | 25 | describe('checkUnused()', () => { 26 | it('sample-project', () => { 27 | const ctx = createContext({ 28 | projectRoot: path.join(__dirname, 'sample-project'), 29 | sourcePaths: ['src/**/*.js'], 30 | }); 31 | const { unusedExports, unknownPackages, failedResolutions } = checkUnused( 32 | ctx 33 | ); 34 | 35 | expect(unknownPackages).toEqual(['not-found']); 36 | expect(failedResolutions).toEqual([ 37 | path.join(__dirname, 'sample-project/src/file-not-found'), 38 | ]); 39 | 40 | testUnusedExports(unusedExports, { 41 | 'sample-project/src/imports-sample.js': ['fakeFunction'], 42 | }); 43 | }); 44 | 45 | it('monorepo-project', () => { 46 | const projectRoot = path.join(__dirname, 'monorepo-project'); 47 | 48 | const ctx = createContext({ 49 | projectRoot, 50 | sourcePaths: ['packages/*/**/*.js'], 51 | aliases: { 52 | '@monorepo/common': path.join(projectRoot, 'packages/common'), 53 | }, 54 | }); 55 | const { unusedExports, unknownPackages, failedResolutions } = checkUnused( 56 | ctx 57 | ); 58 | 59 | expect(unknownPackages).toEqual(['not-found']); 60 | expect(failedResolutions).toEqual([ 61 | path.join(__dirname, 'monorepo-project/packages/common/file-not-found'), 62 | path.join(__dirname, 'monorepo-project/packages/common/src/AppState'), 63 | ]); 64 | 65 | testUnusedExports(unusedExports, { 66 | 'monorepo-project/packages/common/src/AppState.native.js': [ 67 | 'OnlyNative', 68 | 'Platform', 69 | 'default', 70 | ], 71 | 'monorepo-project/packages/common/src/AppState.web.js': [ 72 | 'OnlyWeb', 73 | 'Platform', 74 | 'default', 75 | ], 76 | 'monorepo-project/packages/common/src/logic.js': ['DEFAULTS', 'logic'], 77 | }); 78 | }); 79 | 80 | it('monorepo-project with custom resolver', () => { 81 | const createResolve = (alias) => { 82 | const standardResolve = resolve.create.sync({ 83 | extensions: ['.js'], 84 | alias, 85 | }); 86 | const resolveWeb = resolve.create.sync({ 87 | extensions: ['.web.js'], 88 | alias, 89 | }); 90 | const resolveNative = resolve.create.sync({ 91 | extensions: ['.native.js', '.ios.js', '.android.js'], 92 | alias, 93 | }); 94 | 95 | return (...args) => { 96 | try { 97 | return standardResolve(...args); 98 | } catch (error) { 99 | // do nothing 100 | } 101 | 102 | return [resolveWeb(...args), resolveNative(...args)]; 103 | }; 104 | }; 105 | 106 | const projectRoot = path.join(__dirname, 'monorepo-project'); 107 | 108 | const ctx = createContext({ 109 | projectRoot, 110 | sourcePaths: ['packages/*/**/*.js'], 111 | resolve: createResolve({ 112 | '@monorepo/common': path.join(projectRoot, 'packages/common'), 113 | }), 114 | }); 115 | const { unusedExports, unknownPackages, failedResolutions } = checkUnused( 116 | ctx 117 | ); 118 | 119 | expect(unknownPackages).toEqual(['not-found']); 120 | expect(failedResolutions).toEqual([ 121 | path.join(__dirname, 'monorepo-project/packages/common/file-not-found'), 122 | ]); 123 | 124 | testUnusedExports(unusedExports, { 125 | 'monorepo-project/packages/common/src/AppState.native.js': [ 126 | 'OnlyNative', 127 | 'default', 128 | ], 129 | 'monorepo-project/packages/common/src/AppState.web.js': [ 130 | 'OnlyWeb', 131 | 'default', 132 | ], 133 | 'monorepo-project/packages/common/src/logic.js': ['DEFAULTS', 'logic'], 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /test/extractUnusedExports.spec.js: -------------------------------------------------------------------------------- 1 | import extractUnusedExports from '../src/extractUnusedExports'; 2 | 3 | describe('extractUnusedExports', () => { 4 | describe('extractUnusedExports()', () => { 5 | it('gets exported identifiers from source file', () => { 6 | const sourcePath = '/project/src/Arrows'; 7 | 8 | const exportedNames = [ 9 | { 10 | sourcePath, 11 | exports: [ 12 | { name: 'ArrowLeft' }, 13 | { name: 'ArrowCenter' }, 14 | { name: 'ArrowRight' }, 15 | ], 16 | }, 17 | ]; 18 | 19 | const importedNames = [ 20 | { 21 | sourcePath: '/project/src/fileA', 22 | imports: { [sourcePath]: ['ArrowLeft'] }, 23 | }, 24 | { 25 | sourcePath: '/project/src/fileB', 26 | imports: { [sourcePath]: ['ArrowRight'] }, 27 | }, 28 | ]; 29 | 30 | const result = extractUnusedExports(exportedNames, importedNames, []); 31 | 32 | expect(result).toEqual([ 33 | { 34 | sourcePath, 35 | unusedExports: [{ name: 'ArrowCenter' }], 36 | }, 37 | ]); 38 | }); 39 | 40 | it('only one match is needed when exports.name is an array', () => { 41 | const sourcePath = '/project/src/Arrows'; 42 | 43 | const exportedNames = [ 44 | { 45 | sourcePath, 46 | exports: [ 47 | { name: ['ArrowLeft', 'ArrowRight'] }, 48 | { name: 'ArrowCenter' }, 49 | ], 50 | }, 51 | ]; 52 | 53 | const importedNames = [ 54 | { 55 | sourcePath: '/project/src/fileA', 56 | imports: { [sourcePath]: ['ArrowLeft'] }, 57 | }, 58 | { 59 | sourcePath: '/project/src/fileB', 60 | imports: { [sourcePath]: ['ArrowRight'] }, 61 | }, 62 | ]; 63 | 64 | const result = extractUnusedExports(exportedNames, importedNames, []); 65 | 66 | expect(result).toEqual([ 67 | { 68 | sourcePath, 69 | unusedExports: [{ name: 'ArrowCenter' }], 70 | }, 71 | ]); 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /test/fixExports.spec.js: -------------------------------------------------------------------------------- 1 | import fixExports, { removeExportDeclarations } from '../src/fixExports'; 2 | import fs from 'fs'; 3 | 4 | jest.mock('fs', () => ({ 5 | readFileSync: jest.fn(), 6 | writeFileSync: jest.fn(), 7 | })); 8 | 9 | describe('fixExports', () => { 10 | describe('fixExports()', () => { 11 | it('replaces file content with updated content', () => { 12 | fs.readFileSync.mockImplementation(() => 'export const a = 1;'); 13 | 14 | const unusedExports = [ 15 | { 16 | sourcePath: 'fake-path', 17 | unusedExports: ['a'], 18 | }, 19 | ]; 20 | 21 | fixExports(unusedExports); 22 | 23 | expect(fs.writeFileSync).toBeCalledWith('fake-path', 'const a = 1;'); 24 | }); 25 | }); 26 | 27 | describe('removeExportDeclarations()', () => { 28 | it('removes export declarations', () => { 29 | const identifierNames = ['constA', 'funcA']; 30 | 31 | const source = ` 32 | export const constA = 25; 33 | export function funcA () {}; 34 | export const constB = 25; 35 | export function funcB () {}; 36 | `; 37 | 38 | const result = removeExportDeclarations(source, identifierNames); 39 | 40 | const expected = ` 41 | const constA = 25; 42 | function funcA () {}; 43 | export const constB = 25; 44 | export function funcB () {}; 45 | `; 46 | 47 | expect(result).toBe(expected); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/getExports.spec.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import { parse } from '@babel/parser'; 4 | import resolve from 'enhanced-resolve'; 5 | 6 | import getExports, { 7 | getExportData, 8 | getExportedIdentifiers, 9 | getExportName, 10 | } from '../src/getExports'; 11 | import createContext, { defaultParserOptions } from '../src/createContext'; 12 | 13 | describe('getExports', () => { 14 | const projectRoot = path.join(__dirname, 'sample-project'); 15 | 16 | describe('getExports()', () => { 17 | const testGetExports = (sourcePaths, ignoreExportPatterns = []) => { 18 | const ctx = createContext({ 19 | projectRoot, 20 | sourcePaths, 21 | ignoreExportPatterns, 22 | }); 23 | const results = getExports(sourcePaths, ctx); 24 | 25 | expect(results).toHaveLength( 26 | sourcePaths.length - ignoreExportPatterns.length 27 | ); 28 | 29 | results.forEach(({ sourcePath }) => 30 | expect(sourcePaths).toContain(sourcePath) 31 | ); 32 | }; 33 | 34 | it('from source file', () => { 35 | testGetExports([path.join(projectRoot, 'src/exports-sample.js')]); 36 | }); 37 | 38 | it('from multiple source files', () => { 39 | testGetExports([ 40 | path.join(projectRoot, 'src/exports-sample.js'), 41 | path.join(projectRoot, 'src/all-export.js'), 42 | ]); 43 | }); 44 | 45 | it('from multiple source files, with ignore pattern', () => { 46 | testGetExports( 47 | [ 48 | path.join(projectRoot, 'src/exports-sample.js'), 49 | path.join(projectRoot, 'src/all-export.js'), 50 | path.join(projectRoot, 'src/dummy.js'), 51 | ], 52 | ['dummy.js$'] 53 | ); 54 | }); 55 | }); 56 | 57 | describe('getExportData()', () => { 58 | const testExportData = (source, exportNames) => { 59 | const sourcePath = path.join(projectRoot, 'index.js'); 60 | 61 | const ctx = createContext({ projectRoot, sourcePaths: [sourcePath] }); 62 | const { exports: results } = getExportData(source, sourcePath, ctx); 63 | 64 | expect(results).toHaveLength(exportNames.length); 65 | results.forEach(({ name }, index) => { 66 | if (Array.isArray(name)) { 67 | name.forEach((n) => expect(exportNames[index]).toContain(n)); 68 | } else { 69 | expect(exportNames).toContain(name); 70 | } 71 | }); 72 | }; 73 | 74 | it('nothing', () => { 75 | testExportData(``, []); 76 | }); 77 | 78 | it('default', () => { 79 | testExportData( 80 | ` 81 | const A = 123; 82 | export default A;`, 83 | ['default'] 84 | ); 85 | }); 86 | 87 | it('named exports', () => { 88 | testExportData( 89 | ` 90 | export const A = 123; 91 | export const B = 456; 92 | export const C = 789;`, 93 | ['A', 'B', 'C'] 94 | ); 95 | }); 96 | 97 | it('named export from', () => { 98 | testExportData( 99 | `export { firstName, lastName, getName } from './src/imports-sample.js';`, 100 | ['firstName', 'lastName', 'getName'] 101 | ); 102 | }); 103 | 104 | it('named export from with renaming', () => { 105 | testExportData( 106 | `export { firstName as givenName, lastName as familyName } from './src/imports-sample.js';`, 107 | ['givenName', 'familyName'] 108 | ); 109 | }); 110 | 111 | it('all export from', () => { 112 | testExportData(`export * from './src/all-export.js';`, [ 113 | [ 114 | 'firstName', 115 | 'lastName', 116 | 'getFullName', 117 | 'getName', 118 | 'Family', 119 | 'default', 120 | ], 121 | ]); 122 | }); 123 | }); 124 | 125 | describe('getExportData() with custom resolver', () => { 126 | const projectRoot = path.join( 127 | __dirname, 128 | 'monorepo-project/packages/common' 129 | ); 130 | 131 | const createResolver = () => { 132 | const resolveWeb = resolve.create.sync({ extensions: ['.web.js'] }); 133 | const resolveNative = resolve.create.sync({ extensions: ['.native.js'] }); 134 | 135 | return (...args) => [resolveWeb(...args), resolveNative(...args)]; 136 | }; 137 | 138 | const testExportData = (source, exportNames) => { 139 | const sourcePath = path.join(projectRoot, 'index.js'); 140 | 141 | const ctx = createContext({ projectRoot, resolve: createResolver() }); 142 | const { exports: results } = getExportData(source, sourcePath, ctx); 143 | 144 | expect(results).toHaveLength(exportNames.length); 145 | results.forEach(({ name }, index) => { 146 | name.forEach((n) => expect(exportNames[index]).toContain(n)); 147 | }); 148 | }; 149 | 150 | it('ExportAllDeclaration', () => { 151 | testExportData(`export * from './src/AppState'`, [ 152 | ['default', 'Platform', 'OnlyWeb', 'OnlyNative'], 153 | ]); 154 | }); 155 | }); 156 | 157 | describe('getExportedIdentifiers()', () => { 158 | it('gets exported identifiers from source file', () => { 159 | const sourcePath = path.join(projectRoot, 'src/exports-sample.js'); 160 | const ctx = createContext({ projectRoot, sourcePaths: [sourcePath] }); 161 | const source = fs.readFileSync(sourcePath, 'utf8'); 162 | 163 | const result = getExportedIdentifiers(source, sourcePath, ctx); 164 | const identifiers = result.map((item) => item.name); 165 | 166 | expect(identifiers).toEqual([ 167 | 'firstName', 168 | 'lastName', 169 | 'getFullName', 170 | 'getName', 171 | 'Family', 172 | 'default', 173 | ]); 174 | }); 175 | }); 176 | 177 | describe('getExportName()', () => { 178 | const testGetExportName = (source, expectedName) => { 179 | const ast = parse(source, defaultParserOptions); 180 | const node = ast.program.body[0]; 181 | const exportName = getExportName(node); 182 | const expected = Array.isArray(expectedName) 183 | ? expectedName 184 | : [expectedName]; 185 | 186 | const check = ({ name }) => expect(expected).toContain(name); 187 | 188 | if (Array.isArray(exportName)) { 189 | exportName.forEach(check); 190 | } else { 191 | check(exportName); 192 | } 193 | }; 194 | 195 | it('VariableDeclaration', () => { 196 | testGetExportName('export const A = 123', 'A'); 197 | }); 198 | 199 | it('ExportDefaultDeclaration', () => { 200 | testGetExportName('export default 123', 'default'); 201 | }); 202 | 203 | it('FunctionDeclaration', () => { 204 | testGetExportName('export function B() {}', 'B'); 205 | }); 206 | 207 | it('ExportNamedDeclaration', () => { 208 | testGetExportName( 209 | `export { firstName, lastName, getName } from './src/imports-sample.js';`, 210 | ['firstName', 'lastName', 'getName'] 211 | ); 212 | }); 213 | 214 | it('ClassDeclaration', () => { 215 | testGetExportName('export class C {};', 'C'); 216 | }); 217 | 218 | it('TypeAlias', () => { 219 | testGetExportName('export type D = number', 'D'); 220 | }); 221 | 222 | it('OpaqueType', () => { 223 | testGetExportName('export opaque type E = string;', 'E'); 224 | }); 225 | 226 | it('InterfaceDeclaration', () => { 227 | testGetExportName('export interface F { serialize(): string };', 'F'); 228 | }); 229 | 230 | it('Unknown', () => { 231 | const ast = parse('{}', defaultParserOptions); 232 | const node = ast.program.body[0]; 233 | expect(() => getExportName(node)).toThrow(); 234 | }); 235 | }); 236 | }); 237 | -------------------------------------------------------------------------------- /test/getImports.spec.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { parse } from '@babel/parser'; 4 | import resolve from 'enhanced-resolve'; 5 | 6 | import getImports, { 7 | getImportDetails, 8 | isPackage, 9 | resolvePath, 10 | } from '../src/getImports'; 11 | import createContext from '../src/createContext'; 12 | 13 | describe('getImports', () => { 14 | describe('getImports()', () => { 15 | it('extracts imports from file', () => { 16 | const projectRoot = path.join(__dirname, 'sample-project'); 17 | const sourceFile = path.join(projectRoot, 'src/imports-sample.js'); 18 | const ctx = createContext({ projectRoot }); 19 | 20 | const result = getImports([sourceFile], ctx); 21 | const exportAllSrcPath = path.join(projectRoot, 'src/all-export.js'); 22 | const exportSrcPath = path.join(projectRoot, 'src/exports-sample.js'); 23 | 24 | const expected = [ 25 | { 26 | imports: { 27 | [exportSrcPath]: [ 28 | 'Family', 29 | 'default', 30 | 'firstName', 31 | 'getName', 32 | 'lastName', 33 | ], 34 | [exportAllSrcPath]: ['firstName', 'lastName'], 35 | }, 36 | sourcePath: sourceFile, 37 | }, 38 | ]; 39 | 40 | expect(result).toMatchObject(expected); 41 | }); 42 | }); 43 | 44 | describe('getExportName()', () => { 45 | const projectRoot = path.join(__dirname, 'sample-project'); 46 | const ctx = createContext({ projectRoot }); 47 | 48 | const testGetImportDetails = (source, expectations) => { 49 | const ast = parse(source, ctx.config.parserOptions); 50 | const node = ast.program.body[0]; 51 | const sourcePath = path.join(projectRoot, 'index.js'); 52 | const { specifiers } = getImportDetails(node, sourcePath, ast, ctx); 53 | 54 | expect(specifiers).toHaveLength(expectations.length); 55 | specifiers.forEach((specifier) => 56 | expect(expectations).toContain(specifier) 57 | ); 58 | }; 59 | 60 | it('ImportDefaultSpecifier', () => { 61 | testGetImportDetails(`import exportSample from './src/exports-sample'`, [ 62 | 'default', 63 | ]); 64 | }); 65 | 66 | it('ImportDeclaration', () => { 67 | testGetImportDetails(`import { firstName } from './src/exports-sample'`, [ 68 | 'firstName', 69 | ]); 70 | }); 71 | 72 | it('ImportDeclaration multiple', () => { 73 | testGetImportDetails( 74 | `import { firstName, lastName } from './src/exports-sample'`, 75 | ['firstName', 'lastName'] 76 | ); 77 | }); 78 | 79 | it('ImportDeclaration default with rename', () => { 80 | testGetImportDetails( 81 | `import { default as exportSample } from './src/exports-sample'`, 82 | ['default'] 83 | ); 84 | }); 85 | 86 | it('ImportDeclaration default with rename', () => { 87 | testGetImportDetails( 88 | `import { firstName as givenName } from './src/exports-sample'`, 89 | ['firstName'] 90 | ); 91 | }); 92 | 93 | it('ImportNamespaceSpecifier', () => { 94 | testGetImportDetails( 95 | ` 96 | import * as something from './src/exports-sample'; 97 | something.firstName; 98 | something.lastName; 99 | `, 100 | ['firstName', 'lastName'] 101 | ); 102 | }); 103 | 104 | it('ImportDefaultSpecifier with ImportNamespaceSpecifier', () => { 105 | testGetImportDetails( 106 | ` 107 | import exportSample, * as something from './src/exports-sample'; 108 | something.firstName; 109 | something.lastName; 110 | `, 111 | ['default', 'firstName', 'lastName'] 112 | ); 113 | }); 114 | 115 | it('ImportDefaultSpecifier with ImportSpecifier', () => { 116 | testGetImportDetails( 117 | `import exportSample, { firstName, lastName } from './src/exports-sample';`, 118 | ['default', 'firstName', 'lastName'] 119 | ); 120 | }); 121 | 122 | it('ImportDeclaration as something jsx', () => { 123 | testGetImportDetails( 124 | ` 125 | import * as something from './src/exports-sample'; 126 | 127 |