├── .husky ├── .gitignore └── pre-commit ├── .github ├── FUNDING.yml ├── logo.svg └── workflows │ └── ci.yml ├── typings └── global.d.ts ├── bin └── unimported.js ├── .np-config.json ├── backers.md ├── docs ├── screenshot.png ├── unimported.png └── screenshot-react.png ├── src ├── __mocks__ │ └── term-size.ts ├── ensureArray.ts ├── log.ts ├── presets │ ├── index.ts │ ├── vue.ts │ ├── next.ts │ ├── meteor.ts │ ├── node.ts │ └── react-native.ts ├── __tests__ │ ├── ensureArray.ts │ ├── help.ts │ ├── delete.ts │ ├── presets.ts │ ├── process.ts │ ├── print.ts │ ├── fs.ts │ └── cli.ts ├── __utils__ │ └── index.ts ├── delete.ts ├── process.ts ├── fs.ts ├── cache.ts ├── meta.ts ├── print.ts ├── config.ts ├── traverse.ts └── index.ts ├── .gitignore ├── nodemon.json ├── .prettierrc.js ├── .lintstagedrc.js ├── logo.svg ├── .eslintrc.js ├── tsconfig.json ├── jest.config.js ├── LICENSE ├── patches └── flow-remove-types+2.156.0.patch ├── package.json ├── .all-contributorsrc └── README.md /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [smeijer] 2 | -------------------------------------------------------------------------------- /typings/global.d.ts: -------------------------------------------------------------------------------- 1 | import 'jest-partial'; 2 | -------------------------------------------------------------------------------- /bin/unimported.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | require('../dist/index.js'); 3 | -------------------------------------------------------------------------------- /.np-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "release: cut the %s release" 3 | } 4 | 5 | -------------------------------------------------------------------------------- /backers.md: -------------------------------------------------------------------------------- 1 | This project is backed by: 2 | 3 | [@aprillion](http://github.com/aprillion) 4 | -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/unimported/HEAD/docs/screenshot.png -------------------------------------------------------------------------------- /docs/unimported.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/unimported/HEAD/docs/unimported.png -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | 6 | -------------------------------------------------------------------------------- /docs/screenshot-react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smeijer/unimported/HEAD/docs/screenshot-react.png -------------------------------------------------------------------------------- /src/__mocks__/term-size.ts: -------------------------------------------------------------------------------- 1 | const result = { 2 | columns: 80, 3 | }; 4 | 5 | export default jest.fn(() => result); 6 | -------------------------------------------------------------------------------- /src/ensureArray.ts: -------------------------------------------------------------------------------- 1 | export function ensureArray(value: T | T[]): T[] { 2 | return Array.isArray(value) ? value : [value]; 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .rts2_cache* 2 | .idea 3 | dist/ 4 | .test-space/ 5 | coverage/ 6 | node_modules/ 7 | .DS_Store 8 | 9 | yarn.lock 10 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ext": "ts", 4 | "ignore": ["src/**/*.spec.ts"], 5 | "exec": "ts-node ./src/index.ts" 6 | } -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 80, 6 | tabWidth: 2, 7 | }; -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | 3 | const log = { 4 | debug: Debug('unimported:debug'), 5 | info: Debug('unimported'), 6 | enabled: () => Debug.enabled('unimported'), 7 | }; 8 | 9 | export { log }; 10 | -------------------------------------------------------------------------------- /src/presets/index.ts: -------------------------------------------------------------------------------- 1 | import meteor from './meteor'; 2 | import next from './next'; 3 | import node from './node'; 4 | import reactNative from './react-native'; 5 | import vue from './vue'; 6 | import { Preset } from '../config'; 7 | 8 | export const presets: Preset[] = [next, reactNative, vue, meteor, node]; 9 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // not possible to run tests that use child_process.exec() inside husky, 3 | // error: "Opening `/dev/tty` failed (6): Device not configured" 4 | '**/*.ts': (files) => [`npm run lint`, `npm run test`], 5 | '**/*.md': (files) => [`prettier --write ${files.join(' ')}`], 6 | }; 7 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | extends: [ 4 | 'plugin:@typescript-eslint/recommended', 5 | 'plugin:prettier/recommended', 6 | ], 7 | parserOptions: { 8 | ecmaVersion: 2020, 9 | sourceType: 'module', 10 | }, 11 | rules: { 12 | '@typescript-eslint/no-explicit-any': 'off', 13 | curly: ['error', 'all'], 14 | } 15 | }; -------------------------------------------------------------------------------- /src/presets/vue.ts: -------------------------------------------------------------------------------- 1 | import { Preset } from '../config'; 2 | import nodePreset from './node'; 3 | 4 | const preset: Preset = { 5 | name: 'vue', 6 | isMatch: ({ hasPackage }) => hasPackage('vue'), 7 | getConfig: async (options) => { 8 | const base = await nodePreset.getConfig(options); 9 | const extensions = base.extensions as string[]; 10 | 11 | return { 12 | ...base, 13 | extensions: [...extensions, '.vue'], 14 | ignoreUnused: [...base.ignoreUnused, 'vue'], 15 | }; 16 | }, 17 | }; 18 | 19 | export default preset; 20 | -------------------------------------------------------------------------------- /.github/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "lib": [ 5 | "ESNext", 6 | ], 7 | "rootDir": "src", 8 | "target": "ES2015", 9 | "module": "nodenext", 10 | "moduleResolution": "nodenext", 11 | "outDir": "./dist", 12 | "isolatedModules": true, 13 | "strict": true, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "noImplicitAny": false, 17 | "declaration": true 18 | }, 19 | "include": ["src", "typings"], 20 | "exclude": ["node_modules", "**/__tests__/**", "**/__mocks__/**"] 21 | } 22 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | collectCoverage: true, 5 | coverageDirectory: 'coverage', 6 | roots: ['/src'], 7 | modulePathIgnorePatterns: ['/.test-space/*'], 8 | testPathIgnorePatterns: ['/__fixtures__/', '/__utils__/'], 9 | collectCoverageFrom: ['/**/*.ts', '!/node_modules/'], 10 | coverageThreshold: { 11 | global: { 12 | branches: 85, 13 | functions: 90, 14 | lines: 90, 15 | statements: 90, 16 | }, 17 | }, 18 | setupFilesAfterEnv: ['jest-partial'], 19 | }; 20 | -------------------------------------------------------------------------------- /src/presets/next.ts: -------------------------------------------------------------------------------- 1 | import { Preset } from '../config'; 2 | import nodePreset from './node'; 3 | 4 | const preset: Preset = { 5 | name: 'next', 6 | isMatch: ({ hasPackage }) => hasPackage('next'), 7 | getConfig: async (options) => { 8 | const base = await nodePreset.getConfig(options); 9 | 10 | const entry = [ 11 | './pages/**/*.{js,jsx,ts,tsx}', 12 | './src/pages/**/*.{js,jsx,ts,tsx}', 13 | ]; 14 | 15 | return { 16 | ...base, 17 | entry, 18 | ignoreUnused: [ 19 | ...(base.ignoreUnused as string[]), 20 | 'next', 21 | 'react', 22 | 'react-dom', 23 | ], 24 | }; 25 | }, 26 | }; 27 | 28 | export default preset; 29 | -------------------------------------------------------------------------------- /src/presets/meteor.ts: -------------------------------------------------------------------------------- 1 | import { Preset } from '../config'; 2 | import nodePreset from './node'; 3 | import { typedBoolean } from '../meta'; 4 | 5 | const preset: Preset = { 6 | name: 'meteor', 7 | isMatch: ({ packageJson }) => Boolean(packageJson.meteor?.mainModule), 8 | getConfig: async (options) => { 9 | const base = await nodePreset.getConfig(options); 10 | const mainModule = options.packageJson.meteor?.mainModule; 11 | const entry = [mainModule?.client, mainModule?.server].filter(typedBoolean); 12 | 13 | return { 14 | ...base, 15 | entry, 16 | ignorePatterns: [ 17 | ...(base.ignorePatterns as string[]), 18 | 'packages/**', 19 | 'public/**', 20 | 'private/**', 21 | 'tests/**', 22 | ], 23 | ignoreUnused: [ 24 | ...base.ignoreUnused, 25 | '@babel/runtime', 26 | 'meteor-node-stubs', 27 | ], 28 | }; 29 | }, 30 | }; 31 | 32 | export default preset; 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stephan Meijer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/__tests__/ensureArray.ts: -------------------------------------------------------------------------------- 1 | import { ensureArray } from '../ensureArray'; 2 | 3 | it('should return the given value if it is an array', () => { 4 | const fourtyTwoArray = [42]; 5 | const getFourtyTwoArray = [(): number => 42]; 6 | const objFourtyTwoArray = [{ num: 42 }]; 7 | 8 | const ensureArrayFourtyTwoArray = ensureArray(fourtyTwoArray); 9 | const ensureArrayGetFourtyTwoArray = ensureArray(getFourtyTwoArray); 10 | const ensureArrayObjFourtyTwoArray = ensureArray(objFourtyTwoArray); 11 | 12 | expect(ensureArrayFourtyTwoArray).toBe(fourtyTwoArray); 13 | expect(ensureArrayGetFourtyTwoArray).toBe(getFourtyTwoArray); 14 | expect(ensureArrayObjFourtyTwoArray).toBe(objFourtyTwoArray); 15 | }); 16 | 17 | it('should return an array with the given value', () => { 18 | const fourtyTwo = 42; 19 | const getFourtyTwo = (): number => 42; 20 | const objFourtyTwo = { num: 42 }; 21 | 22 | const fourtyTwoArray = ensureArray(fourtyTwo); 23 | const getFourtyTwoArray = ensureArray(getFourtyTwo); 24 | const objFourtyTwoArray = ensureArray(objFourtyTwo); 25 | 26 | expect(fourtyTwoArray).toEqual([fourtyTwo]); 27 | expect(getFourtyTwoArray).toEqual([getFourtyTwo]); 28 | expect(objFourtyTwoArray).toEqual([objFourtyTwo]); 29 | }); 30 | -------------------------------------------------------------------------------- /src/presets/node.ts: -------------------------------------------------------------------------------- 1 | import { Preset } from '../config'; 2 | import { typedBoolean } from '../meta'; 3 | import { resolveFilesSync } from '../fs'; 4 | 5 | const preset: Preset = { 6 | name: 'node', 7 | isMatch: ({ packageJson }) => Boolean(packageJson), 8 | getConfig: ({ packageJson, hasPackage }) => { 9 | const hasFlow = hasPackage('flow-bin'); 10 | const extensions = ['.js', '.jsx', '.ts', '.tsx']; 11 | const sourceFiles = Array.isArray(packageJson.source) 12 | ? packageJson.source 13 | : [packageJson.source]; 14 | 15 | const entry = Array.from( 16 | new Set( 17 | resolveFilesSync( 18 | [ 19 | ...sourceFiles, 20 | './src/index', 21 | './src/main', 22 | './index', 23 | './main', 24 | packageJson.main, 25 | ], 26 | extensions, 27 | ), 28 | ), 29 | ).filter(typedBoolean); 30 | 31 | return { 32 | entry, 33 | extensions, 34 | flow: hasFlow, 35 | ignorePatterns: [ 36 | '**/node_modules/**', 37 | `**/*.tests.{js,jsx,ts,tsx}`, 38 | `**/*.test.{js,jsx,ts,tsx}`, 39 | `**/*.spec.{js,jsx,ts,tsx}`, 40 | `**/tests/**`, 41 | `**/__tests__/**`, 42 | `**/*.d.ts`, 43 | ].filter(typedBoolean), 44 | ignoreUnimported: [], 45 | ignoreUnresolved: [], 46 | ignoreUnused: [], 47 | }; 48 | }, 49 | }; 50 | 51 | export default preset; 52 | -------------------------------------------------------------------------------- /src/presets/react-native.ts: -------------------------------------------------------------------------------- 1 | import { Preset } from '../config'; 2 | import nodePreset from './node'; 3 | import { resolveFilesSync } from '../fs'; 4 | import { typedBoolean } from '../meta'; 5 | 6 | function getEntry(target: string, rootExtensions: string[]) { 7 | const extensions = [ 8 | ...rootExtensions.map((e) => `.${target}${e}`), 9 | ...rootExtensions.map((e) => `.native${e}`), 10 | ...rootExtensions, 11 | ]; 12 | 13 | const [file] = resolveFilesSync(['./index'], extensions); 14 | 15 | if (!file) { 16 | return; 17 | } 18 | 19 | return { 20 | file, 21 | label: target, 22 | extend: { 23 | extensions: extensions, 24 | }, 25 | }; 26 | } 27 | 28 | function getExpo(options, rootExtensions: string[]) { 29 | const expoEntry = options.packageJson.main; 30 | 31 | if (!expoEntry) { 32 | return; 33 | } 34 | 35 | const [file] = resolveFilesSync([expoEntry], rootExtensions); 36 | 37 | if (!file) { 38 | return; 39 | } 40 | return { 41 | file, 42 | label: 'expo', 43 | extend: { 44 | extensions: rootExtensions, 45 | }, 46 | }; 47 | } 48 | const preset: Preset = { 49 | name: 'react-native', 50 | isMatch: ({ hasPackage }) => hasPackage('react-native'), 51 | getConfig: async (options) => { 52 | const base = await nodePreset.getConfig(options); 53 | const extensions = base.extensions as string[]; 54 | 55 | const hasExpo = options.hasPackage('expo'); 56 | 57 | const entry = [ 58 | getEntry('android', extensions), 59 | getEntry('ios', extensions), 60 | hasExpo ? getExpo(options, extensions) : undefined, 61 | ].filter(typedBoolean); 62 | 63 | return { 64 | ...base, 65 | entry, 66 | ignoreUnused: [ 67 | ...base.ignoreUnused, 68 | 'react', 69 | 'react-dom', 70 | 'react-native', 71 | ], 72 | }; 73 | }, 74 | }; 75 | 76 | export default preset; 77 | -------------------------------------------------------------------------------- /src/__utils__/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import { mkdir, writeFile } from 'fs/promises'; 4 | 5 | export async function createTestProject( 6 | files: Array<{ name: string; content: string }> | Record, 7 | baseDir = '.', 8 | name?: string, 9 | ): Promise { 10 | const randomId = Math.floor(Math.random() * 1000000); 11 | 12 | const testSpaceDir = path.join('.test-space', randomId.toString()); 13 | 14 | await mkdir(testSpaceDir, { recursive: true }); 15 | 16 | if (name) { 17 | fs.writeFileSync(path.join(testSpaceDir, '.scenario'), name); 18 | } 19 | 20 | const fileArray = Array.isArray(files) 21 | ? files 22 | : Object.keys(files).map((file) => ({ 23 | name: file, 24 | content: 25 | typeof files[file] === 'string' 26 | ? (files[file] as string) 27 | : JSON.stringify(files[file]), 28 | })); 29 | 30 | await Promise.all( 31 | fileArray.map((file) => 32 | mkdir(path.join(testSpaceDir, path.dirname(file.name)), { 33 | recursive: true, 34 | }), 35 | ), 36 | ); 37 | 38 | await Promise.all( 39 | fileArray.map((file) => 40 | writeFile(path.join(testSpaceDir, file.name), file.content), 41 | ), 42 | ); 43 | 44 | return path.join(testSpaceDir, baseDir); 45 | } 46 | 47 | type UnboxPromise> = T extends Promise 48 | ? U 49 | : never; 50 | 51 | export async function runWithFiles any>( 52 | files: Record, 53 | cb: T, 54 | ): Promise>> { 55 | const originalCwd = process.cwd(); 56 | let testPath; 57 | 58 | try { 59 | testPath = await createTestProject(files); 60 | process.chdir(testPath); 61 | return await cb(); 62 | } finally { 63 | process.chdir(originalCwd); 64 | fs.rmSync(testPath, { recursive: true, force: true }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/delete.ts: -------------------------------------------------------------------------------- 1 | import { ProcessedResult } from './process'; 2 | import { Context, PackageJson } from './index'; 3 | import { deleteFile } from './fs'; 4 | import * as fs from './fs'; 5 | 6 | export type DeleteResult = { 7 | deletedFiles: string[]; 8 | removedDeps: string[]; 9 | error?: string; 10 | }; 11 | 12 | export async function removeUnused( 13 | result: ProcessedResult, 14 | context: Context, 15 | ): Promise { 16 | const deleteIsSafe = result.unresolved.length === 0; 17 | if (!deleteIsSafe) { 18 | return { 19 | removedDeps: [], 20 | deletedFiles: [], 21 | error: 22 | 'Unable to safely remove files and packages while there are unresolved imports.', 23 | }; 24 | } 25 | 26 | const { removedDeps, error: depsError } = await removeUnusedDeps( 27 | result, 28 | context, 29 | ); 30 | if (depsError) { 31 | return { removedDeps, deletedFiles: [], error: depsError }; 32 | } 33 | const { deletedFiles, error: fileError } = await removeUnusedFiles( 34 | result, 35 | context, 36 | ); 37 | return { deletedFiles, removedDeps, error: fileError || depsError }; 38 | } 39 | 40 | async function removeUnusedFiles( 41 | result: ProcessedResult, 42 | context: Context, 43 | ): Promise<{ deletedFiles: string[]; error?: string }> { 44 | await Promise.all( 45 | result.unimported.map((file) => deleteFile(file, context.cwd)), 46 | ); 47 | return { deletedFiles: result.unimported }; 48 | } 49 | 50 | async function removeUnusedDeps( 51 | result: ProcessedResult, 52 | context: Context, 53 | ): Promise<{ removedDeps: string[]; error?: string }> { 54 | const packageJson = await fs.readJson( 55 | 'package.json', 56 | context.cwd, 57 | ); 58 | if (!packageJson) { 59 | return { error: 'Unable to read package.json', removedDeps: [] }; 60 | } 61 | if (!packageJson.dependencies) { 62 | return { removedDeps: [] }; 63 | } 64 | const updatedDependencies = Object.fromEntries( 65 | Object.entries(packageJson.dependencies).filter( 66 | ([key]) => !result.unused.includes(key), 67 | ), 68 | ); 69 | const updatedPackageJson: PackageJson = { 70 | ...packageJson, 71 | dependencies: updatedDependencies, 72 | }; 73 | await fs.writeJson('package.json', updatedPackageJson, context.cwd); 74 | return { removedDeps: result.unused }; 75 | } 76 | -------------------------------------------------------------------------------- /src/process.ts: -------------------------------------------------------------------------------- 1 | import ignore from 'ignore'; 2 | import { TraverseResult } from './traverse'; 3 | import { Context } from './index'; 4 | import { ensureArray } from './ensureArray'; 5 | import { exists, readText } from './fs'; 6 | 7 | export interface ProcessedResult { 8 | unresolved: [string, string[]][]; 9 | unimported: string[]; 10 | unused: string[]; 11 | clean: boolean; 12 | } 13 | 14 | type FormatTypes = keyof Pick< 15 | Context, 16 | 'showUnusedFiles' | 'showUnusedDeps' | 'showUnresolvedImports' 17 | >; 18 | 19 | function index(array: string | string[]): { [key: string]: boolean } { 20 | return ensureArray(array).reduce((acc, str) => { 21 | acc[str] = true; 22 | return acc; 23 | }, {}); 24 | } 25 | 26 | export async function processResults( 27 | files: string[], 28 | traverseResult: TraverseResult, 29 | context: Context, 30 | ): Promise { 31 | const ignoreUnresolvedIdx = index(context.config.ignoreUnresolved); 32 | const ignoreUnusedIdx = index(context.config.ignoreUnused); 33 | const ignoreUnimportedIdx = index(context.config.ignoreUnimported); 34 | 35 | const unresolved = Array.from(traverseResult.unresolved).filter( 36 | (entry) => !ignoreUnresolvedIdx[entry.toString()], 37 | ); 38 | 39 | const unused = Object.keys(context.dependencies).filter( 40 | (x) => 41 | !traverseResult.modules.has(x) && 42 | !context.peerDependencies[x] && 43 | !ignoreUnusedIdx[x], 44 | ); 45 | 46 | let unimported = files 47 | .filter((x) => !traverseResult.files.has(x)) 48 | .map((x) => x.replace(context.cwd + '/', '')) 49 | .filter((x) => !ignoreUnimportedIdx[x]); 50 | 51 | if (context.config.respectGitignore && (await exists('.gitignore'))) { 52 | const gitignore = (await readText('.gitignore')).split('\n'); 53 | const ig = ignore().add(gitignore); 54 | unimported = unimported.filter((x) => !ig.ignores(x)); 55 | } 56 | 57 | const formatTypeResultMap: { [P in FormatTypes]: boolean } = { 58 | showUnusedFiles: !unimported.length, 59 | showUnusedDeps: !unused.length, 60 | showUnresolvedImports: !unresolved.length, 61 | }; 62 | 63 | const isClean = Object.keys(formatTypeResultMap).some((key) => context[key]) 64 | ? Object.keys(formatTypeResultMap).every((key) => 65 | context[key] ? formatTypeResultMap[key] : true, 66 | ) 67 | : Object.values(formatTypeResultMap).every((v) => v); 68 | 69 | return { 70 | unresolved, 71 | unused, 72 | unimported, 73 | clean: isClean, 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /patches/flow-remove-types+2.156.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/flow-remove-types/index.js b/node_modules/flow-remove-types/index.js 2 | index f28c303..c595c79 100644 3 | --- a/node_modules/flow-remove-types/index.js 4 | +++ b/node_modules/flow-remove-types/index.js 5 | @@ -148,6 +148,25 @@ function resultPrinter(options, source, removedNodes) { 6 | 7 | var LINE_RX = /(\r\n?|\n|\u2028|\u2029)/; 8 | 9 | +function removeImportKind(context, node, offset) { 10 | + var ast = context.ast; 11 | + 12 | + // Flow quirk: Remove importKind which is outside the node 13 | + var idxStart = findTokenIndex(ast.tokens, startOf(node)); 14 | + 15 | + // offset +1 for imports like `import type x from 16 | + // offset -1 for imports like `import { type x } from 17 | + var maybeImportKind = ast.tokens[idxStart + offset]; 18 | + var maybeImportKindLabel = getLabel(maybeImportKind); 19 | + 20 | + if ( 21 | + maybeImportKindLabel === 'type' || 22 | + maybeImportKindLabel === 'typeof' 23 | + ) { 24 | + removeNode(context, maybeImportKind); 25 | + } 26 | +} 27 | + 28 | // A collection of methods for each AST type names which contain Flow types to 29 | // be removed. 30 | var removeFlowVisitor = { 31 | @@ -209,41 +228,20 @@ var removeFlowVisitor = { 32 | } 33 | }, 34 | 35 | + // We've patched this function so that `import type x from` statements aren't 36 | + // stripped out, instead only the 'type' or 'typeof' modifier is removed. 37 | + // type imports could have been handled by typescript, but typeof not. 38 | ImportDeclaration: function(context, node) { 39 | if (node.importKind === 'type' || node.importKind === 'typeof') { 40 | - return removeNode(context, node); 41 | + // import type from '.'; 42 | + removeImportKind(context, node, + 1); 43 | } 44 | }, 45 | 46 | ImportSpecifier: function(context, node) { 47 | if (node.importKind === 'type' || node.importKind === 'typeof') { 48 | - var ast = context.ast; 49 | - 50 | - // Flow quirk: Remove importKind which is outside the node 51 | - var idxStart = findTokenIndex(ast.tokens, startOf(node)); 52 | - var maybeImportKind = ast.tokens[idxStart - 1]; 53 | - var maybeImportKindLabel = getLabel(maybeImportKind); 54 | - if ( 55 | - maybeImportKindLabel === 'type' || 56 | - maybeImportKindLabel === 'typeof' 57 | - ) { 58 | - removeNode(context, maybeImportKind); 59 | - } 60 | - 61 | - // Remove the node itself 62 | - removeNode(context, node); 63 | - 64 | - // Remove trailing comma 65 | - var idx = findTokenIndex(ast.tokens, endOf(node)); 66 | - 67 | - while (isComment(ast.tokens[idx])) { 68 | - // NOTE: ast.tokens has no comments in Flow 69 | - idx++; 70 | - } 71 | - if (getLabel(ast.tokens[idx]) === ',') { 72 | - removeNode(context, ast.tokens[idx]); 73 | - } 74 | - return false; 75 | + // import { type x, y } from '.'; 76 | + removeImportKind(context, node, - 1); 77 | } 78 | }, 79 | 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unimported", 3 | "version": "1.20.2", 4 | "description": "Scans your nodejs project folder and shows obsolete files and modules", 5 | "main": "./dist/index.js", 6 | "bin": { 7 | "unimported": "bin/unimported.js" 8 | }, 9 | "source": "./src/index.ts", 10 | "license": "MIT", 11 | "author": "Stephan Meijer ", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/smeijer/unimported.git" 15 | }, 16 | "scripts": { 17 | "build": "rimraf ./dist && tsc", 18 | "lint": "tsc --noEmit && eslint 'src/**/*.ts' --quiet --fix", 19 | "prepare": "npm run build", 20 | "run": "npm run build && node ./dist/index.js", 21 | "test": "jest", 22 | "ci:lint": "eslint -c ./.eslintrc.js", 23 | "ci:tsc": "tsc --noEmit --project ./tsconfig.json", 24 | "bump:patch": "npm version patch -m 'release: cut the %s release'", 25 | "bump:minor": "npm version minor -m 'release: cut the %s release'", 26 | "bump:major": "npm version major -m 'release: cut the %s release'", 27 | "postinstall": "husky install && patch-package", 28 | "prepublishOnly": "pinst --disable", 29 | "postpublish": "pinst --enable" 30 | }, 31 | "files": [ 32 | "dist", 33 | "bin" 34 | ], 35 | "keywords": [ 36 | "nodejs", 37 | "cli", 38 | "analysis" 39 | ], 40 | "dependencies": { 41 | "@typescript-eslint/parser": "^6.7.3", 42 | "@typescript-eslint/typescript-estree": "^6.7.3", 43 | "chalk": "^4.1.0", 44 | "cosmiconfig": "^8.3.6", 45 | "debug": "^4.3.2", 46 | "file-entry-cache": "^6.0.1", 47 | "flow-remove-types": "2.156.0", 48 | "glob": "^7.1.6", 49 | "ignore": "^5.2.4", 50 | "json5": "^2.2.0", 51 | "ora": "^5.3.0", 52 | "read-pkg-up": "^7.0.1", 53 | "resolve": "^1.20.0", 54 | "simple-git": "^3.18.0", 55 | "term-size": "^2.2.1", 56 | "typescript": "^5.2.2", 57 | "yargs": "^16.2.0" 58 | }, 59 | "devDependencies": { 60 | "@types/file-entry-cache": "^5.0.1", 61 | "@types/flat-cache": "^2.0.0", 62 | "@types/glob": "^7.1.3", 63 | "@types/jest": "^26.0.20", 64 | "@types/jest-in-case": "^1.0.3", 65 | "@types/node": "^14.14.31", 66 | "@types/resolve": "^1.20.1", 67 | "@types/yargs": "^16.0.0", 68 | "@typescript-eslint/eslint-plugin": "^6.7.3", 69 | "console-testing-library": "0.6.0", 70 | "eslint": "^7.20.0", 71 | "eslint-config-prettier": "^8.1.0", 72 | "eslint-plugin-prettier": "^3.3.1", 73 | "husky": "^5.1.1", 74 | "jest": "^29.1.1", 75 | "jest-in-case": "^1.0.2", 76 | "jest-partial": "^1.0.1", 77 | "lint-staged": "^13.2.1", 78 | "nodemon": "^3.0.1", 79 | "patch-package": "^6.4.7", 80 | "pinst": "^2.1.6", 81 | "prettier": "^2.2.1", 82 | "rimraf": "^3.0.2", 83 | "ts-jest": "^29.1.1", 84 | "ts-node": "^10.9.1" 85 | }, 86 | "husky": { 87 | "hooks": { 88 | "pre-commit": "lint-staged" 89 | } 90 | }, 91 | "engines": { 92 | "node": ">=16.0.0" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/__tests__/help.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util'; 2 | import { exec } from 'child_process'; 3 | 4 | const execAsync = promisify(exec); 5 | 6 | test('npx unimported --help', async () => { 7 | // note about `./` path: jest executes the tests from the root directory 8 | let execResults; 9 | if (process.platform === 'win32') { 10 | // Windows won't understand LC_ALL='en_US.utf8' 11 | execResults = await execAsync( 12 | `set LC_All='en-US' && set NODE_ENV='production' && ts-node src/index.ts --help`, 13 | ); 14 | } else { 15 | execResults = await execAsync( 16 | `LC_ALL='en_US.utf8' NODE_ENV='production' ts-node src/index.ts --help`, 17 | ); 18 | } 19 | 20 | const { stdout, stderr } = execResults; 21 | 22 | expect(stderr).toBe(''); 23 | 24 | expect(stdout.trim()).toMatchInlineSnapshot(` 25 | "unimported [cwd] 26 | 27 | scan your project for dead files 28 | 29 | Positionals: 30 | cwd The root directory that unimported should run from. [string] 31 | 32 | Options: 33 | --version Show version number [boolean] 34 | --help Show help [boolean] 35 | --cache Whether to use the cache. Disable the cache 36 | using --no-cache. [boolean] [default: true] 37 | --fix Removes unused files and dependencies. This is 38 | a destructive operation, use with caution. 39 | [boolean] [default: false] 40 | --clear-cache Clears the cache file and then exits. [boolean] 41 | -f, --flow Whether to strip flow types, regardless of 42 | @flow pragma. [boolean] 43 | --ignore-untracked Ignore files that are not currently tracked by 44 | git. [boolean] 45 | -i, --init Dump default settings to .unimportedrc.json. 46 | [boolean] 47 | --show-config Show config and then exists. [boolean] 48 | --show-preset Show preset and then exists. [string] 49 | -u, --update Update the ignore-lists stored in 50 | .unimportedrc.json. [boolean] 51 | --config The path to the config file. [string] 52 | --show-unused-files formats and only prints unimported files 53 | [boolean] 54 | --show-unused-deps formats and only prints unused dependencies 55 | [boolean] 56 | --show-unresolved-imports formats and only prints unresolved imports 57 | [boolean]" 58 | `); 59 | // the async call below can take longer than the default 5 seconds, 60 | // so increase as necessary. 61 | }, 10000); 62 | -------------------------------------------------------------------------------- /src/fs.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { join, relative } from 'path'; 3 | import glob, { IOptions as GlobOptions } from 'glob'; 4 | import util from 'util'; 5 | import json5 from 'json5'; 6 | import resolve from 'resolve'; 7 | 8 | const globAsync = util.promisify(glob); 9 | const readFileAsync = util.promisify(fs.readFile); 10 | const writeFileAsync = util.promisify(fs.writeFile); 11 | const existsAsync = util.promisify(fs.exists); 12 | 13 | export async function exists(path: string, cwd = ''): Promise { 14 | return await existsAsync(join(cwd, path)); 15 | } 16 | 17 | export async function deleteFile(path: string, cwd = ''): Promise { 18 | return util.promisify(fs.rm)(join(cwd, path)); 19 | } 20 | 21 | export async function readText(path: string, cwd = ''): Promise { 22 | try { 23 | return await readFileAsync(join(cwd, path), { encoding: 'utf8' }); 24 | } catch (e) { 25 | return ''; 26 | } 27 | } 28 | 29 | export async function writeText( 30 | path: string, 31 | data: string, 32 | cwd = '', 33 | ): Promise { 34 | try { 35 | return await writeFileAsync(join(cwd, path), data, { encoding: 'utf8' }); 36 | } catch (e) { 37 | return; 38 | } 39 | } 40 | 41 | export async function readJson( 42 | path: string, 43 | cwd = '.', 44 | ): Promise { 45 | try { 46 | const text = await readText(path, cwd); 47 | return text ? json5.parse(text) : undefined; 48 | } catch (e) { 49 | console.error('\nfile does not contain valid json:', path, 'error: ', e); 50 | return undefined; 51 | } 52 | } 53 | 54 | export async function writeJson( 55 | path: string, 56 | data: Record, 57 | cwd = '.', 58 | ): Promise { 59 | const text = JSON.stringify(data, null, ' '); 60 | return await writeText(path, text, cwd); 61 | } 62 | 63 | type ListOptions = GlobOptions & { 64 | extensions?: string[]; 65 | }; 66 | 67 | export async function list( 68 | pattern: string, 69 | cwd: string, 70 | options: ListOptions = {}, 71 | ): Promise { 72 | const { extensions, ...globOptions } = options; 73 | 74 | // transform: 75 | // - ['.js', '.tsx'] to **/*.{js,tsx} 76 | // -['.js'] to **/*.js 77 | const normalizedExtensions = extensions?.map((x) => x.replace(/^\./, '')); 78 | const wrappedExtensions = 79 | extensions?.length === 1 80 | ? normalizedExtensions 81 | : `{${normalizedExtensions}}`; 82 | 83 | const fullPattern = Array.isArray(extensions) 84 | ? `${pattern}.${wrappedExtensions}` 85 | : pattern; 86 | 87 | return await globAsync(fullPattern, { 88 | cwd, 89 | realpath: true, 90 | ...globOptions, 91 | }); 92 | } 93 | 94 | export function resolveFilesSync( 95 | options: Array, 96 | extensions: string[], 97 | ): Array { 98 | const basedir = process.cwd(); 99 | 100 | return options 101 | .map((file) => { 102 | try { 103 | if (!file) { 104 | return; 105 | } 106 | 107 | file = file.startsWith('./') ? file : `./${file}`; 108 | 109 | return ( 110 | file && 111 | resolve 112 | .sync(file, { 113 | basedir, 114 | extensions, 115 | }) 116 | .replace(/\\/g, '/') 117 | ); 118 | } catch {} 119 | }) 120 | .map((file) => file && relative(basedir, file)); 121 | } 122 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import fileEntryCache, { 2 | FileDescriptor, 3 | FileEntryCache, 4 | } from 'file-entry-cache'; 5 | import { Cache } from 'flat-cache'; 6 | import { log } from './log'; 7 | import path from 'path'; 8 | import { readFileSync, rmSync } from 'fs'; 9 | import { EntryConfig } from './config'; 10 | 11 | type CacheMeta = FileDescriptor['meta'] & { data: T }; 12 | 13 | // we keep cache groups per entry file, to keep the cache free from override conflicts 14 | const caches: Record = {}; 15 | const packageVersion = JSON.parse( 16 | readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'), 17 | ).version; 18 | 19 | export function getCacheIdentity(entry: EntryConfig): string { 20 | // don't use just the file name, the entry file can be the same, while the 21 | // overrides make it build target specific. 22 | const value = JSON.stringify({ 23 | ...entry, 24 | filepath: path.resolve(entry.file), 25 | packageVersion, 26 | }); 27 | 28 | return hash(value); 29 | } 30 | 31 | function getCache(identity: string) { 32 | if (caches[identity]) { 33 | return caches[identity]; 34 | } 35 | 36 | caches[identity] = fileEntryCache.create( 37 | identity, 38 | path.resolve(process.cwd(), './node_modules/.cache/unimported'), 39 | true, 40 | ); 41 | 42 | return caches[identity]; 43 | } 44 | 45 | // Create short hashes for file names 46 | function hash(path: string): string { 47 | let h; 48 | let i; 49 | 50 | for (h = 0, i = 0; i < path.length; h &= h) { 51 | h = 31 * h + path.charCodeAt(i++); 52 | } 53 | 54 | return Math.abs(h).toString(16); 55 | } 56 | 57 | export class InvalidCacheError extends Error { 58 | path: string; 59 | 60 | constructor(message: string, path: string) { 61 | super(message); 62 | this.name = 'InvalidCacheError'; 63 | this.path = path; 64 | } 65 | 66 | static wrap(e: Error, path: string): InvalidCacheError { 67 | return new InvalidCacheError(e.message, path); 68 | } 69 | } 70 | 71 | export async function resolveEntry( 72 | path: string, 73 | generator: () => Promise, 74 | cacheIdentity = '*', 75 | ): Promise { 76 | const cacheEntry = getCache(cacheIdentity).getFileDescriptor(path); 77 | const meta: CacheMeta = cacheEntry.meta as CacheMeta; 78 | 79 | if (!meta) { 80 | // Something else referenced a now deleted file. Force error and let upstream handle 81 | throw new InvalidCacheError(`${path} was deleted`, path); 82 | } 83 | 84 | if (cacheEntry.changed || !meta.data) { 85 | meta.data = await generator(); 86 | } 87 | 88 | return meta.data; 89 | } 90 | 91 | export function invalidateEntry(path: string): void { 92 | for (const cache of Object.values(caches)) { 93 | cache.removeEntry(path); 94 | } 95 | } 96 | 97 | export function invalidateEntries(shouldRemove: (meta: T) => boolean): void { 98 | for (const cache of Object.values(caches)) { 99 | Object.values((cache.cache as Cache).all()).forEach((cacheEntry) => { 100 | if (shouldRemove(cacheEntry.data as T)) { 101 | cache.removeEntry(cacheEntry.data.path); 102 | } 103 | }); 104 | } 105 | } 106 | 107 | export function storeCache(): void { 108 | log.info('store cache'); 109 | 110 | for (const key of Object.keys(caches)) { 111 | caches[key].reconcile(); 112 | } 113 | } 114 | 115 | export function purgeCache(): void { 116 | log.info('purge cache'); 117 | 118 | rmSync(path.resolve(process.cwd(), './node_modules/.cache/unimported'), { 119 | recursive: true, 120 | force: true, 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /src/__tests__/delete.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { removeUnused } from '../delete'; 3 | import { ProcessedResult } from '../process'; 4 | import { Context, PackageJson } from '../index'; 5 | import { readJson, writeText } from '../fs'; 6 | import path from 'path'; 7 | 8 | const testSpaceDir = '.test-space/delete-test'; 9 | 10 | beforeEach(() => { 11 | fs.mkdirSync(testSpaceDir, { recursive: true }); 12 | }); 13 | 14 | afterEach(() => { 15 | fs.rmSync(testSpaceDir, { recursive: true }); 16 | }); 17 | 18 | describe('removeUnused', () => { 19 | const testFileName1 = 'testFile1.txt'; 20 | const testFileName2 = 'testFile2.txt'; 21 | const unusedFiles = [testFileName1, testFileName2]; 22 | const packageJson: PackageJson = { 23 | name: '', 24 | version: '', 25 | dependencies: { 26 | 'unused-package': '1.0.0', 27 | 'used-package': '1.0.0', 28 | }, 29 | }; 30 | const result: ProcessedResult = { 31 | clean: false, 32 | unimported: unusedFiles, 33 | unresolved: [], 34 | unused: ['unused-package'], 35 | }; 36 | const context = { 37 | cwd: testSpaceDir, 38 | } as Context; 39 | beforeEach(async () => { 40 | await writeText('package.json', JSON.stringify(packageJson), testSpaceDir); 41 | await writeText(testFileName1, '', testSpaceDir); 42 | await writeText(testFileName2, '', testSpaceDir); 43 | }); 44 | afterEach(() => { 45 | jest.clearAllMocks(); 46 | }); 47 | it('should remove unused packages from package.json', async () => { 48 | const { removedDeps, error } = await removeUnused(result, context); 49 | expect(error).toBeUndefined(); 50 | const updatedPackageJson = await readJson( 51 | 'package.json', 52 | testSpaceDir, 53 | ); 54 | expect(removedDeps).toEqual(result.unused); 55 | expect(updatedPackageJson?.dependencies).toEqual({ 56 | 'used-package': '1.0.0', 57 | }); 58 | }); 59 | it('should remove unused files', async () => { 60 | const rm = jest.spyOn(fs, 'rm'); 61 | 62 | const { deletedFiles } = await removeUnused(result, context); 63 | 64 | expect(rm).toHaveBeenCalledTimes(unusedFiles.length); 65 | unusedFiles.forEach((file) => { 66 | expect(rm).toHaveBeenCalledWith( 67 | path.join(testSpaceDir, file), 68 | expect.any(Function), 69 | ); 70 | }); 71 | expect(deletedFiles).toEqual(unusedFiles); 72 | }); 73 | it('should not remove anything if there are unresolved imports', async () => { 74 | const rm = jest.spyOn(fs, 'rm'); 75 | 76 | const { removedDeps, deletedFiles, error } = await removeUnused( 77 | { ...result, unresolved: [['unused-package', []]] }, 78 | context, 79 | ); 80 | 81 | expect(error).toContain('Unable to safely'); 82 | expect(removedDeps).toEqual([]); 83 | expect(deletedFiles).toEqual([]); 84 | expect(rm).toHaveBeenCalledTimes(0); 85 | const updatedPackageJson = await readJson( 86 | 'package.json', 87 | testSpaceDir, 88 | ); 89 | expect(updatedPackageJson?.dependencies).toEqual({ 90 | 'unused-package': '1.0.0', 91 | 'used-package': '1.0.0', 92 | }); 93 | }); 94 | it('should not remove anything if package.json is missing', async () => { 95 | fs.rmSync(path.join(testSpaceDir, 'package.json')); 96 | const rm = jest.spyOn(fs, 'rm'); 97 | 98 | const { removedDeps, deletedFiles, error } = await removeUnused( 99 | result, 100 | context, 101 | ); 102 | 103 | expect(error).toContain('Unable to read'); 104 | expect(removedDeps).toEqual([]); 105 | expect(deletedFiles).toEqual([]); 106 | expect(rm).toHaveBeenCalledTimes(0); 107 | }); 108 | it('should handle package.json without dependencies array', async () => { 109 | const packageJson = { name: '', version: '' }; 110 | await writeText('package.json', JSON.stringify(packageJson), testSpaceDir); 111 | const rm = jest.spyOn(fs, 'rm'); 112 | 113 | const { removedDeps, deletedFiles, error } = await removeUnused( 114 | result, 115 | context, 116 | ); 117 | 118 | expect(error).toBe(undefined); 119 | expect(removedDeps).toEqual([]); 120 | expect(deletedFiles).toEqual(['testFile1.txt', 'testFile2.txt']); 121 | expect(rm).toHaveBeenCalledTimes(2); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /src/__tests__/presets.ts: -------------------------------------------------------------------------------- 1 | import { __clearCachedConfig, getConfig } from '../config'; 2 | import { runWithFiles } from '../__utils__'; 3 | import { purgeCache } from '../cache'; 4 | 5 | beforeEach(() => { 6 | __clearCachedConfig(); 7 | purgeCache(); 8 | }); 9 | 10 | test('can identify nextjs projects', async () => { 11 | const config = await runWithFiles( 12 | { 13 | 'package.json': { dependencies: { next: '1' } }, 14 | 'pages/index.js': '', 15 | }, 16 | getConfig, 17 | ); 18 | 19 | expect(config.preset).toEqual('next'); 20 | expect(config.entryFiles).toMatchPartial([{ file: './pages/index.js' }]); 21 | }); 22 | 23 | test('can identify nextjs projects with src folder', async () => { 24 | const config = await runWithFiles( 25 | { 26 | 'package.json': { dependencies: { next: '1' } }, 27 | 'src/pages/index.js': '', 28 | }, 29 | getConfig, 30 | ); 31 | 32 | expect(config.preset).toEqual('next'); 33 | expect(config.entryFiles).toMatchPartial([{ file: './src/pages/index.js' }]); 34 | }); 35 | 36 | test('can identify react-native projects', async () => { 37 | const config = await runWithFiles( 38 | { 39 | 'package.json': { dependencies: { 'react-native': '1' } }, 40 | 'index.js': '', 41 | }, 42 | getConfig, 43 | ); 44 | 45 | expect(config.preset).toEqual('react-native'); 46 | expect(config.entryFiles).toMatchPartial([ 47 | { file: 'index.js', label: 'android' }, 48 | { file: 'index.js', label: 'ios' }, 49 | ]); 50 | }); 51 | 52 | test('can identify react-native platform specific entry points', async () => { 53 | const config = await runWithFiles( 54 | { 55 | 'package.json': { dependencies: { 'react-native': '1' } }, 56 | 'index.ios.js': '', 57 | 'index.android.js': '', 58 | }, 59 | getConfig, 60 | ); 61 | 62 | expect(config.preset).toEqual('react-native'); 63 | expect(config.entryFiles).toMatchPartial([ 64 | { file: 'index.android.js', label: 'android' }, 65 | { file: 'index.ios.js', label: 'ios' }, 66 | ]); 67 | }); 68 | 69 | test('can identify react-native with single build target', async () => { 70 | const config = await runWithFiles( 71 | { 72 | 'package.json': { dependencies: { 'react-native': '1' } }, 73 | 'index.android.js': '', 74 | }, 75 | getConfig, 76 | ); 77 | 78 | expect(config.preset).toEqual('react-native'); 79 | expect(config.entryFiles).toMatchPartial([ 80 | { file: 'index.android.js', label: 'android' }, 81 | ]); 82 | }); 83 | 84 | test('can identify meteor projects', async () => { 85 | const config = await runWithFiles( 86 | { 87 | 'package.json': { 88 | meteor: { 89 | mainModule: { 90 | client: './client/index.js', 91 | server: './server/index.js', 92 | }, 93 | }, 94 | }, 95 | 'client/index.js': '', 96 | 'server/index.js': '', 97 | }, 98 | getConfig, 99 | ); 100 | 101 | expect(config.preset).toEqual('meteor'); 102 | expect(config.entryFiles).toMatchPartial([ 103 | { file: './client/index.js' }, 104 | { file: './server/index.js' }, 105 | ]); 106 | }); 107 | 108 | test('can identify meteor projects with single entry point', async () => { 109 | const config = await runWithFiles( 110 | { 111 | 'package.json': { 112 | meteor: { 113 | mainModule: { 114 | client: './client/index.js', 115 | }, 116 | }, 117 | }, 118 | 'client/index.js': '', 119 | }, 120 | getConfig, 121 | ); 122 | 123 | expect(config.preset).toEqual('meteor'); 124 | expect(config.entryFiles).toMatchPartial([{ file: './client/index.js' }]); 125 | }); 126 | 127 | test('can identify vue projects', async () => { 128 | const config = await runWithFiles( 129 | { 130 | 'package.json': { dependencies: { vue: '1' } }, 131 | 'index.js': '', 132 | }, 133 | getConfig, 134 | ); 135 | 136 | expect(config.preset).toEqual('vue'); 137 | expect(config.extensions).toEqual(expect.arrayContaining(['.vue'])); 138 | }); 139 | 140 | test('can identify node projects', async () => { 141 | const config = await runWithFiles( 142 | { 143 | 'package.json': {}, 144 | 'index.js': '', 145 | }, 146 | getConfig, 147 | ); 148 | expect(config.preset).toEqual('node'); 149 | }); 150 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | # Match SemVer major release branches, e.g. "2.x" or ".x" 7 | - '[0-9]+.x' 8 | - 'main' 9 | - 'next' 10 | - 'alpha' 11 | - 'beta' 12 | - '!all-contributors/**' 13 | pull_request: 14 | types: [opened, synchronize, reopened] 15 | 16 | env: 17 | DEFAULT_NODE_VERSION: 18.x 18 | 19 | jobs: 20 | setup: 21 | name: Setup 22 | strategy: 23 | matrix: 24 | node_version: [18.x, 20.x] 25 | os: [ ubuntu-latest, windows-latest ] 26 | runs-on: ${{ matrix.os }} 27 | timeout-minutes: 5 28 | steps: 29 | - uses: actions/checkout@v2 30 | 31 | - uses: actions/setup-node@v2 32 | with: 33 | node-version: '${{ matrix.node_version }}' 34 | 35 | - name: Cache node modules 36 | id: cache 37 | uses: actions/cache@v2 38 | with: 39 | path: node_modules 40 | key: ${{ runner.os }}-${{matrix.node_version}}-node_modules-${{ hashFiles('package-lock.json') }} 41 | 42 | - name: Install Dependencies 43 | if: steps.cache.outputs.cache-hit != 'true' 44 | run: npm ci --ignore-scripts && npm run postinstall 45 | 46 | eslint: 47 | name: Eslint 48 | runs-on: ubuntu-latest 49 | strategy: 50 | matrix: 51 | node_version: [18.x, 20.x] 52 | needs: [setup] 53 | timeout-minutes: 5 54 | steps: 55 | - uses: actions/checkout@v2 56 | with: 57 | fetch-depth: 0 58 | 59 | - uses: actions/setup-node@v2 60 | with: 61 | node-version: '${{ matrix.node_version }}' 62 | 63 | - name: Fetch all branches 64 | run: | 65 | git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* 66 | 67 | - name: Cache node modules 68 | uses: actions/cache@v2 69 | with: 70 | path: node_modules 71 | key: ${{ runner.os }}-${{matrix.node_version}}-node_modules-${{ hashFiles('package-lock.json') }} 72 | 73 | - name: ESLint 74 | run: npm run ci:lint -- $(git diff --diff-filter d --name-only origin/${{ github.base_ref }}...HEAD -- '*.js' '*.ts' '*.tsx') 75 | 76 | typescript: 77 | name: Typescript 78 | runs-on: ubuntu-latest 79 | strategy: 80 | matrix: 81 | node_version: [18.x, 20.x] 82 | needs: [setup] 83 | timeout-minutes: 5 84 | steps: 85 | - uses: actions/checkout@v2 86 | 87 | - uses: actions/setup-node@v2 88 | with: 89 | node-version: '${{ matrix.node_version }}' 90 | 91 | - name: Cache node modules 92 | uses: actions/cache@v2 93 | with: 94 | path: node_modules 95 | key: ${{ runner.os }}-${{matrix.node_version}}-node_modules-${{ hashFiles('package-lock.json') }} 96 | 97 | - name: Typescript 98 | run: npm run ci:tsc 99 | 100 | test: 101 | name: Test 102 | strategy: 103 | matrix: 104 | node_version: [18.x, 20.x] 105 | os: [ubuntu-latest, windows-latest] 106 | runs-on: ${{ matrix.os }} 107 | needs: [setup] 108 | timeout-minutes: 5 109 | steps: 110 | - uses: actions/checkout@v2 111 | 112 | - uses: actions/setup-node@v2 113 | with: 114 | node-version: '${{ matrix.node_version }}' 115 | 116 | - name: Cache node modules 117 | uses: actions/cache@v2 118 | with: 119 | path: node_modules 120 | key: ${{ runner.os }}-${{matrix.node_version}}-node_modules-${{ hashFiles('package-lock.json') }} 121 | 122 | - name: Test 123 | run: npm run test 124 | 125 | release: 126 | needs: [test] 127 | if: github.event_name == 'push' && github.repository == 'smeijer/unimported' 128 | runs-on: ubuntu-latest 129 | environment: npm 130 | steps: 131 | - name: Cancel previous runs 132 | uses: styfle/cancel-workflow-action@0.9.0 133 | 134 | - uses: actions/checkout@v2 135 | 136 | - uses: actions/setup-node@v2 137 | with: 138 | node-version: '${{ env.DEFAULT_NODE_VERSION }}' 139 | 140 | - name: Cache node modules 141 | uses: actions/cache@v2 142 | with: 143 | path: node_modules 144 | key: ${{ runner.os }}-${{env.DEFAULT_NODE_VERSION}}-node_modules-${{ hashFiles('package-lock.json') }} 145 | 146 | - name: Build 147 | run: npm run build 148 | 149 | - name: Release 150 | uses: cycjimmy/semantic-release-action@v2 151 | with: 152 | semantic_version: 17 153 | branches: | 154 | [ 155 | '+([0-9])?(.{+([0-9]),x}).x', 156 | 'main', 157 | 'next', 158 | { name: 'alpha', prerelease: true }, 159 | { name: 'beta', prerelease: true } 160 | ] 161 | env: 162 | GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} 163 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 164 | -------------------------------------------------------------------------------- /src/meta.ts: -------------------------------------------------------------------------------- 1 | import path, { join } from 'path'; 2 | import { 3 | findConfigFile, 4 | MapLike, 5 | parseJsonConfigFileContent, 6 | readConfigFile, 7 | sys, 8 | } from 'typescript'; 9 | import { EntryConfig, getConfig } from './config'; 10 | import { ensureArray } from './ensureArray'; 11 | import * as fs from './fs'; 12 | import { Context, JsConfig, PackageJson } from './index'; 13 | import { log } from './log'; 14 | 15 | interface Aliases { 16 | [index: string]: string[]; 17 | } 18 | 19 | export function hasPackage(packageJson: PackageJson, name: string): boolean { 20 | return Boolean( 21 | packageJson.dependencies?.[name] || 22 | packageJson.devDependencies?.[name] || 23 | packageJson.peerDependencies?.[name], 24 | ); 25 | } 26 | 27 | export function typedBoolean( 28 | value: T, 29 | ): value is Exclude { 30 | return Boolean(value); 31 | } 32 | 33 | export const readTsconfig = () => { 34 | const resolvedPathname = findConfigFile( 35 | process.cwd(), 36 | sys.fileExists, 37 | 'tsconfig.json', 38 | ); 39 | 40 | if (!resolvedPathname) { 41 | return undefined; 42 | } 43 | 44 | const { config } = readConfigFile(resolvedPathname, sys.readFile); 45 | 46 | if (!config) { 47 | return undefined; 48 | } 49 | 50 | const { options } = parseJsonConfigFileContent(config, sys, process.cwd()); 51 | 52 | return { 53 | compilerOptions: options, 54 | }; 55 | }; 56 | 57 | export async function getAliases( 58 | entryFile: EntryConfig, 59 | ): Promise> { 60 | const [packageJson, jsconfig] = await Promise.all([ 61 | fs.readJson('package.json'), 62 | fs.readJson('jsconfig.json'), 63 | ]); 64 | 65 | const tsconfig = readTsconfig(); 66 | const config = await getConfig(); 67 | 68 | let aliases: Aliases = {}; 69 | 70 | let baseUrl = 71 | config?.rootDir ?? 72 | tsconfig?.compilerOptions?.baseUrl ?? 73 | jsconfig?.compilerOptions?.baseUrl ?? 74 | '.'; 75 | 76 | // '/' doesn't resolve 77 | if (baseUrl === '/') { 78 | baseUrl = '.'; 79 | } 80 | 81 | const root = path.resolve(baseUrl); 82 | 83 | // add support for root slash import 84 | aliases['/'] = [`${root}/`]; 85 | 86 | // add support for mono-repos 87 | if (packageJson?.repository?.directory) { 88 | const root = path.resolve('../'); 89 | const packages = await fs.list('*/', root, { realpath: false }); 90 | for (const alias of packages) { 91 | aliases[alias] = [join(root, alias)]; 92 | } 93 | } 94 | 95 | // add support for typescript path aliases 96 | if (tsconfig?.compilerOptions?.paths) { 97 | const root = path.resolve(tsconfig.compilerOptions.baseUrl || '.'); 98 | aliases = Object.assign( 99 | aliases, 100 | normalizeAliases(root, tsconfig.compilerOptions.paths), 101 | ); 102 | } 103 | 104 | // add support for jsconfig path aliases 105 | if (jsconfig?.compilerOptions?.paths) { 106 | const root = path.resolve(jsconfig.compilerOptions.baseUrl || '.'); 107 | aliases = Object.assign( 108 | aliases, 109 | normalizeAliases(root, jsconfig.compilerOptions.paths), 110 | ); 111 | } 112 | 113 | // add support for additional path aliases (in typescript compiler path like setup) 114 | if (entryFile.aliases) { 115 | aliases = Object.assign(aliases, normalizeAliases(root, entryFile.aliases)); 116 | } 117 | 118 | log.info(`aliases for %s %O`, entryFile ?? '*', aliases); 119 | return aliases; 120 | } 121 | 122 | // normalize the aliases. The keys maintain trailing '/' to ease path comparison, 123 | // in: { '@components/*': ['src/components/*'] } 124 | // out: { '@components/': ['src/components/'] } 125 | function normalizeAliases(root: string, paths: MapLike): Aliases { 126 | const aliases: Aliases = {}; 127 | 128 | for (const key of Object.keys(paths)) { 129 | const alias = key.replace(/\*$/, ''); 130 | 131 | aliases[alias] = ensureArray(paths[key]).map((x) => { 132 | const path = join(root, x.replace(/\*$/, '')); 133 | return alias.endsWith('/') && !path.endsWith('/') ? `${path}/` : path; 134 | }); 135 | 136 | // only keep uniqs 137 | aliases[alias] = Array.from(new Set(aliases[alias])); 138 | } 139 | 140 | return aliases; 141 | } 142 | 143 | export async function getDependencies( 144 | projectPath: string, 145 | ): Promise { 146 | const packageJson = await fs.readJson( 147 | 'package.json', 148 | projectPath, 149 | ); 150 | 151 | if (!packageJson) { 152 | return {}; 153 | } 154 | 155 | return packageJson!.dependencies || {}; 156 | } 157 | 158 | export async function getPeerDependencies( 159 | projectPath: string, 160 | ): Promise { 161 | const packageJson = await fs.readJson( 162 | 'package.json', 163 | projectPath, 164 | ); 165 | 166 | if (!packageJson) { 167 | return {}; 168 | } 169 | 170 | const peerDependencies = {}; 171 | 172 | for (const dep of Object.keys(packageJson.dependencies || {})) { 173 | const json = await fs.readJson( 174 | join('node_modules', dep, 'package.json'), 175 | projectPath, 176 | ); 177 | Object.assign(peerDependencies, json?.peerDependencies); 178 | } 179 | 180 | return peerDependencies; 181 | } 182 | -------------------------------------------------------------------------------- /src/__tests__/process.ts: -------------------------------------------------------------------------------- 1 | import { processResults, ProcessedResult } from '../process'; 2 | import { Context } from '../index'; 3 | import { FileStats, TraverseResult } from '../traverse'; 4 | 5 | describe('processResults', () => { 6 | it('returns clean result when all format types are disabled', async () => { 7 | const files = ['src/index.ts']; 8 | const traverseResult: TraverseResult = { 9 | unresolved: new Map([['foo', ['bar']]]), 10 | modules: new Set(), 11 | files: new Map(), 12 | }; 13 | const context: Context = { 14 | cwd: 'cwd/string', 15 | moduleDirectory: [], 16 | dependencies: {}, 17 | peerDependencies: {}, 18 | config: { 19 | version: '1.0.0', 20 | entryFiles: [{ file: 'src/main.js', aliases: {}, extensions: [] }], 21 | extensions: [], 22 | assetExtensions: [], 23 | ignorePatterns: [], 24 | ignoreUnimported: [], 25 | ignoreUnused: [], 26 | ignoreUnresolved: [], 27 | respectGitignore: false, 28 | }, 29 | showUnusedFiles: false, 30 | showUnusedDeps: false, 31 | showUnresolvedImports: false, 32 | }; 33 | 34 | const result = await processResults(files, traverseResult, context); 35 | 36 | const expected: ProcessedResult = { 37 | unresolved: [['foo', ['bar']]], 38 | unused: [], 39 | unimported: ['src/index.ts'], 40 | clean: false, 41 | }; 42 | 43 | expect(result).toEqual(expected); 44 | }); 45 | it('returns unresolved imports when showUnresolvedImports is true', async () => { 46 | const files = ['src/index.ts']; 47 | const traverseResult: TraverseResult = { 48 | unresolved: new Map(), 49 | modules: new Set(), 50 | files: new Map(), 51 | }; 52 | const context: Context = { 53 | cwd: 'cwd/string', 54 | moduleDirectory: [], 55 | dependencies: {}, 56 | peerDependencies: {}, 57 | config: { 58 | version: '1.0.0', 59 | entryFiles: [ 60 | { file: 'src/client/main.js', aliases: {}, extensions: [] }, 61 | ], 62 | extensions: [], 63 | assetExtensions: [], 64 | ignorePatterns: [], 65 | ignoreUnimported: [], 66 | ignoreUnused: [], 67 | ignoreUnresolved: [], 68 | respectGitignore: false, 69 | }, 70 | showUnusedFiles: false, 71 | showUnusedDeps: false, 72 | showUnresolvedImports: true, 73 | }; 74 | 75 | const result = await processResults(files, traverseResult, context); 76 | 77 | const expected: ProcessedResult = { 78 | unresolved: [], 79 | unused: [], 80 | unimported: ['src/index.ts'], 81 | clean: true, 82 | }; 83 | 84 | expect(result).toEqual(expected); 85 | }); 86 | it('returns unused dependencies when showUnusedDeps is true', async () => { 87 | const files = ['src/index.ts']; 88 | const traverseResult: TraverseResult = { 89 | unresolved: new Map(), 90 | modules: new Set(), 91 | files: new Map(), 92 | }; 93 | const context: Context = { 94 | cwd: 'cwd/string', 95 | moduleDirectory: [], 96 | dependencies: {}, 97 | peerDependencies: {}, 98 | config: { 99 | version: '1.0.0', 100 | entryFiles: [ 101 | { file: 'src/client/main.js', aliases: {}, extensions: [] }, 102 | ], 103 | extensions: [], 104 | assetExtensions: [], 105 | ignorePatterns: [], 106 | ignoreUnimported: [], 107 | ignoreUnused: [], 108 | ignoreUnresolved: [], 109 | respectGitignore: false, 110 | }, 111 | showUnusedFiles: false, 112 | showUnusedDeps: true, 113 | showUnresolvedImports: false, 114 | }; 115 | 116 | const result = await processResults(files, traverseResult, context); 117 | 118 | const expected: ProcessedResult = { 119 | unresolved: [], 120 | unused: [], 121 | unimported: ['src/index.ts'], 122 | clean: true, 123 | }; 124 | 125 | expect(result).toEqual(expected); 126 | }); 127 | it('returns unimported files when showUnusedFiles is true', async () => { 128 | const files = ['src/index.ts']; 129 | const traverseResult: TraverseResult = { 130 | unresolved: new Map([['foo', ['bar']]]), 131 | modules: new Set(), 132 | files: new Map([ 133 | [ 134 | 'src/index.ts', 135 | { 136 | path: '', 137 | extname: '', 138 | dirname: '', 139 | imports: [], 140 | }, 141 | ], 142 | ]), 143 | }; 144 | const context: Context = { 145 | cwd: 'cwd/string', 146 | moduleDirectory: [], 147 | dependencies: {}, 148 | peerDependencies: {}, 149 | config: { 150 | version: '1.0.0', 151 | entryFiles: [ 152 | { file: 'src/client/main.js', aliases: {}, extensions: [] }, 153 | ], 154 | extensions: [], 155 | assetExtensions: [], 156 | ignorePatterns: [], 157 | ignoreUnimported: [], 158 | ignoreUnused: [], 159 | ignoreUnresolved: [], 160 | respectGitignore: false, 161 | }, 162 | showUnusedFiles: true, 163 | showUnusedDeps: false, 164 | showUnresolvedImports: false, 165 | }; 166 | 167 | const result = await processResults(files, traverseResult, context); 168 | 169 | const expected: ProcessedResult = { 170 | unresolved: [['foo', ['bar']]], 171 | unused: [], 172 | unimported: [], 173 | clean: true, 174 | }; 175 | 176 | expect(result).toEqual(expected); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /src/print.ts: -------------------------------------------------------------------------------- 1 | import termSize from 'term-size'; 2 | import chalk from 'chalk'; 3 | import { Context } from './index'; 4 | import { ProcessedResult } from './process'; 5 | import { DeleteResult } from './delete'; 6 | 7 | const { columns } = termSize(); 8 | 9 | export function formatList(caption: string, records: string[]): string { 10 | const gutterWidth = Math.max(4, `${records.length + 1}`.length + 1); 11 | const colWidth = columns - gutterWidth - 2; 12 | 13 | const lines = [ 14 | chalk.grey('─'.repeat(gutterWidth + 1) + '┬' + '─'.repeat(colWidth)), 15 | chalk.grey(' '.repeat(gutterWidth + 1) + '│ ') + caption, 16 | chalk.grey('─'.repeat(gutterWidth + 1) + '┼' + '─'.repeat(colWidth)), 17 | ...records.map( 18 | (file, idx) => 19 | chalk.grey(`${idx + 1}`.padStart(gutterWidth, ' ') + ' │ ') + file, 20 | ), 21 | chalk.grey('─'.repeat(gutterWidth + 1) + '┴' + '─'.repeat(colWidth)), 22 | ]; 23 | 24 | return `\n${lines.join('\n')}\n`; 25 | } 26 | 27 | export function formatMetaTable( 28 | caption: string, 29 | data: { 30 | unresolved: [string, string[]][]; 31 | unimported: string[]; 32 | unused: string[]; 33 | }, 34 | context: Context, 35 | ): string { 36 | const entryFiles = context.config.entryFiles; 37 | const records = [ 38 | ...entryFiles.map<[string, string]>((entry, idx) => [ 39 | `entry file ${entryFiles.length > 1 ? idx + 1 : ''}`, 40 | entry.label ? `${entry.file} — ${entry.label}` : entry.file, 41 | ]), 42 | ['', ''], 43 | ['unresolved imports', data.unresolved.length], 44 | ['unused dependencies', data.unused.length], 45 | ['unimported files', data.unimported.length], 46 | ] as [string, string][]; 47 | 48 | const space = ' '.repeat(6); 49 | const width = records.reduce((max, next) => Math.max(max, next[0].length), 0); 50 | 51 | const divider = chalk.grey('─'.repeat(columns)); 52 | const { preset, version } = context.config; 53 | 54 | const lines = [ 55 | `${space} ${caption} ${chalk.grey( 56 | 'unimported v' + version + (preset ? ` (${preset})` : ''), 57 | )}`, 58 | divider, 59 | ...records.reduce((acc, [label, value]) => { 60 | acc.push(label ? `${space} ${label.padEnd(width, ' ')} : ${value}` : ''); 61 | return acc; 62 | }, []), 63 | ]; 64 | 65 | return `\n${lines.join('\n')}\n`; 66 | } 67 | 68 | export function printDeleteResult({ 69 | removedDeps, 70 | deletedFiles, 71 | }: DeleteResult): void { 72 | if (removedDeps.length === 0 && deletedFiles.length === 0) { 73 | console.log( 74 | chalk.greenBright(`✓`) + ' There are no unused files or dependencies.', 75 | ); 76 | return; 77 | } 78 | if (removedDeps.length === 0) { 79 | console.log(chalk.greenBright(`✓`) + ' There are no unused dependencies.'); 80 | console.log( 81 | formatList( 82 | chalk.redBright(`${deletedFiles.length} unused files removed`), 83 | deletedFiles, 84 | ), 85 | ); 86 | return; 87 | } 88 | if (deletedFiles.length === 0) { 89 | console.log(chalk.greenBright(`✓`) + ' There are no unused files.'); 90 | console.log( 91 | formatList( 92 | chalk.redBright(`${removedDeps.length} unused dependencies removed`), 93 | removedDeps, 94 | ), 95 | ); 96 | return; 97 | } 98 | console.log( 99 | formatList( 100 | chalk.redBright(`${removedDeps.length} unused dependencies removed`), 101 | removedDeps, 102 | ), 103 | ); 104 | console.log( 105 | formatList( 106 | chalk.redBright(`${deletedFiles.length} unused files removed`), 107 | deletedFiles, 108 | ), 109 | ); 110 | } 111 | 112 | export function printResults(result: ProcessedResult, context: Context): void { 113 | if (result.clean) { 114 | console.log( 115 | chalk.greenBright(`✓`) + " There don't seem to be any unimported files.", 116 | ); 117 | return; 118 | } 119 | 120 | const { showUnresolved, showUnused, showUnimported } = chooseResults(context); 121 | const { unresolved, unused, unimported } = result; 122 | 123 | // render 124 | console.log( 125 | formatMetaTable( 126 | chalk.greenBright(`summary`), 127 | { unresolved, unused, unimported }, 128 | context, 129 | ), 130 | ); 131 | 132 | if (showUnresolved && unresolved.length > 0) { 133 | console.log( 134 | formatList( 135 | chalk.redBright(`${unresolved.length} unresolved imports`), 136 | unresolved.map(([item, sources]) => { 137 | return `${item} ${chalk.gray(`at ${sources.join(', ')}`)}`; 138 | }), 139 | ), 140 | ); 141 | } 142 | 143 | if (showUnused && unused.length > 0) { 144 | console.log( 145 | formatList( 146 | chalk.blueBright(`${unused.length} unused dependencies`), 147 | unused, 148 | ), 149 | ); 150 | } 151 | 152 | if (showUnimported && unimported.length > 0) { 153 | console.log( 154 | formatList( 155 | chalk.cyanBright(`${unimported.length} unimported files`), 156 | unimported, 157 | ), 158 | ); 159 | } 160 | 161 | console.log( 162 | `\n Inspect the results and run ${chalk.greenBright( 163 | 'npx unimported -u', 164 | )} to update ignore lists`, 165 | ); 166 | } 167 | 168 | function chooseResults(context: Context) { 169 | const { showUnresolvedImports, showUnusedDeps, showUnusedFiles } = context; 170 | const showAllResults = 171 | // when all three flags are used 172 | (showUnresolvedImports && showUnusedDeps && showUnusedFiles) || 173 | // when none flag is used 174 | (!showUnresolvedImports && !showUnusedDeps && !showUnusedFiles); 175 | 176 | const showUnresolved = showUnresolvedImports || showAllResults; 177 | const showUnused = showUnusedDeps || showAllResults; 178 | const showUnimported = showUnusedFiles || showAllResults; 179 | 180 | return { 181 | showAllResults, 182 | showUnresolved, 183 | showUnused, 184 | showUnimported, 185 | }; 186 | } 187 | -------------------------------------------------------------------------------- /src/__tests__/print.ts: -------------------------------------------------------------------------------- 1 | import { createConsole, getLog, mockConsole } from 'console-testing-library'; 2 | import { Context } from '../index'; 3 | import { printDeleteResult, printResults } from '../print'; 4 | 5 | describe('printResults', () => { 6 | const expectedContext = { 7 | cwd: 'cwd/string', 8 | moduleDirectory: [], 9 | dependencies: {}, 10 | peerDependencies: {}, 11 | config: { 12 | version: '1.0.0', 13 | entryFiles: [{ file: 'src/client/main.js', aliases: {}, extensions: [] }], 14 | extensions: [], 15 | assetExtensions: [], 16 | ignorePatterns: [], 17 | ignoreUnimported: [], 18 | ignoreUnused: [], 19 | ignoreUnresolved: [], 20 | respectGitignore: false, 21 | }, 22 | showUnresolvedImports: false, 23 | showUnusedDeps: false, 24 | showUnusedFiles: false, 25 | } as Context; 26 | 27 | let restore: any; 28 | beforeEach(() => { 29 | const strippingConsole = createConsole({ stripAnsi: true }); 30 | restore = mockConsole(strippingConsole); 31 | }); 32 | afterEach(() => { 33 | jest.clearAllMocks(); 34 | restore(); 35 | }); 36 | 37 | it('should print once to console if results are clean', () => { 38 | const expectedProcessedResult = { 39 | unresolved: [], 40 | unimported: [], 41 | unused: [], 42 | clean: true, 43 | }; 44 | printResults(expectedProcessedResult, expectedContext); 45 | 46 | expect(getLog().log).toMatchInlineSnapshot( 47 | `"✓ There don't seem to be any unimported files."`, 48 | ); 49 | }); 50 | 51 | it('should print summary and unresolved, unimported, and unused tables populated', () => { 52 | const expectedProcessedResult = { 53 | unresolved: <[string, string[]][]>[['string', ['string']]], 54 | unimported: ['string', 'string', 'string', 'string'], 55 | unused: ['string', 'string', 'string'], 56 | clean: false, 57 | }; 58 | printResults(expectedProcessedResult, expectedContext); 59 | 60 | expect(getLog().log).toMatchInlineSnapshot(` 61 | " 62 | summary unimported v1.0.0 63 | ──────────────────────────────────────────────────────────────────────────────── 64 | entry file : src/client/main.js 65 | 66 | unresolved imports : 1 67 | unused dependencies : 3 68 | unimported files : 4 69 | 70 | 71 | ─────┬────────────────────────────────────────────────────────────────────────── 72 | │ 1 unresolved imports 73 | ─────┼────────────────────────────────────────────────────────────────────────── 74 | 1 │ string at string 75 | ─────┴────────────────────────────────────────────────────────────────────────── 76 | 77 | 78 | ─────┬────────────────────────────────────────────────────────────────────────── 79 | │ 3 unused dependencies 80 | ─────┼────────────────────────────────────────────────────────────────────────── 81 | 1 │ string 82 | 2 │ string 83 | 3 │ string 84 | ─────┴────────────────────────────────────────────────────────────────────────── 85 | 86 | 87 | ─────┬────────────────────────────────────────────────────────────────────────── 88 | │ 4 unimported files 89 | ─────┼────────────────────────────────────────────────────────────────────────── 90 | 1 │ string 91 | 2 │ string 92 | 3 │ string 93 | 4 │ string 94 | ─────┴────────────────────────────────────────────────────────────────────────── 95 | 96 | 97 | Inspect the results and run npx unimported -u to update ignore lists" 98 | `); 99 | }); 100 | describe('printDeleteResult', () => { 101 | it('should print summary of removed files and packages', () => { 102 | printDeleteResult({ 103 | removedDeps: ['unused-package'], 104 | deletedFiles: ['unused-file.txt'], 105 | }); 106 | expect(getLog().log).toMatchInlineSnapshot(` 107 | " 108 | ─────┬────────────────────────────────────────────────────────────────────────── 109 | │ 1 unused dependencies removed 110 | ─────┼────────────────────────────────────────────────────────────────────────── 111 | 1 │ unused-package 112 | ─────┴────────────────────────────────────────────────────────────────────────── 113 | 114 | 115 | ─────┬────────────────────────────────────────────────────────────────────────── 116 | │ 1 unused files removed 117 | ─────┼────────────────────────────────────────────────────────────────────────── 118 | 1 │ unused-file.txt 119 | ─────┴────────────────────────────────────────────────────────────────────────── 120 | " 121 | `); 122 | }); 123 | it('should print summary of removed packages', () => { 124 | printDeleteResult({ 125 | removedDeps: ['unused-package'], 126 | deletedFiles: [], 127 | }); 128 | expect(getLog().log).toMatchInlineSnapshot(` 129 | "✓ There are no unused files. 130 | 131 | ─────┬────────────────────────────────────────────────────────────────────────── 132 | │ 1 unused dependencies removed 133 | ─────┼────────────────────────────────────────────────────────────────────────── 134 | 1 │ unused-package 135 | ─────┴────────────────────────────────────────────────────────────────────────── 136 | " 137 | `); 138 | }); 139 | it('should print summary of removed files', () => { 140 | printDeleteResult({ 141 | removedDeps: [], 142 | deletedFiles: ['unused-file.txt'], 143 | }); 144 | expect(getLog().log).toMatchInlineSnapshot(` 145 | "✓ There are no unused dependencies. 146 | 147 | ─────┬────────────────────────────────────────────────────────────────────────── 148 | │ 1 unused files removed 149 | ─────┼────────────────────────────────────────────────────────────────────────── 150 | 1 │ unused-file.txt 151 | ─────┴────────────────────────────────────────────────────────────────────────── 152 | " 153 | `); 154 | }); 155 | it('should print summary when nothing is removed', () => { 156 | printDeleteResult({ 157 | removedDeps: [], 158 | deletedFiles: [], 159 | }); 160 | expect(getLog().log).toMatchInlineSnapshot( 161 | `"✓ There are no unused files or dependencies."`, 162 | ); 163 | }); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /src/__tests__/fs.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { 4 | exists, 5 | readText, 6 | writeText, 7 | readJson, 8 | writeJson, 9 | list, 10 | deleteFile, 11 | } from '../fs'; 12 | 13 | const testSpaceDir = '.test-space/fs-test'; 14 | 15 | beforeEach(() => { 16 | fs.mkdirSync(testSpaceDir, { recursive: true }); 17 | }); 18 | 19 | afterEach(() => { 20 | fs.rmSync(testSpaceDir, { recursive: true }); 21 | }); 22 | 23 | test('should check if file exists', async () => { 24 | const testFileName = path.join(testSpaceDir, 'testFile.txt'); 25 | await writeText(testFileName, ''); 26 | 27 | const fileExists = await exists(testFileName); 28 | 29 | expect(fileExists).toBeTruthy(); 30 | }); 31 | 32 | test('should check if file exists in cwd', async () => { 33 | const testFileName = 'testFile.txt'; 34 | await writeText(testFileName, '', testSpaceDir); 35 | 36 | const fileExists = await exists(testFileName, testSpaceDir); 37 | 38 | expect(fileExists).toBeTruthy(); 39 | }); 40 | 41 | test('should check if file does not exists', async () => { 42 | const testFileName = path.join(testSpaceDir, 'missing', 'testFile.txt'); 43 | 44 | const fileExists = await exists(testFileName); 45 | 46 | expect(fileExists).toBeFalsy(); 47 | }); 48 | 49 | test('should read text from file', async () => { 50 | const testFileName = path.join(testSpaceDir, 'testFile.txt'); 51 | const expectedText = 'READ ME!'; 52 | await writeText(testFileName, expectedText); 53 | 54 | const actualText = await readText(testFileName); 55 | 56 | expect(actualText).toBe(expectedText); 57 | }); 58 | 59 | test('should read text from file in cwd', async () => { 60 | const testFileName = 'testFile.txt'; 61 | const expectedText = 'READ ME!'; 62 | await writeText(testFileName, expectedText, testSpaceDir); 63 | 64 | const actualText = await readText(testFileName, testSpaceDir); 65 | 66 | expect(actualText).toBe(expectedText); 67 | }); 68 | 69 | test('should gracefully fail to read text from file', async () => { 70 | const testFileName = path.join(testSpaceDir, 'missing', 'testFile.txt'); 71 | const expectedText = ''; 72 | 73 | const actualText = await readText(testFileName, testSpaceDir); 74 | 75 | expect(actualText).toBe(expectedText); 76 | }); 77 | 78 | test('should gracefully fail to write text to file', async () => { 79 | const testFileName = path.join(testSpaceDir, 'missing', 'testFile.txt'); 80 | const expectedText = 'READ ME!'; 81 | 82 | await expect(writeText(testFileName, expectedText)).resolves.toBe(undefined); 83 | }); 84 | 85 | test('should read json from file', async () => { 86 | const testFileName = path.join(testSpaceDir, 'testFile.txt'); 87 | const expectedJson = { text: 'READ ME!' }; 88 | await writeJson(testFileName, expectedJson); 89 | 90 | const actualJson = await readJson(testFileName); 91 | 92 | expect(actualJson).toEqual(expectedJson); 93 | }); 94 | 95 | test('should read json from file in cwd', async () => { 96 | const testFileName = 'testFile.txt'; 97 | const expectedJson = { text: 'READ ME!' }; 98 | await writeJson(testFileName, expectedJson, testSpaceDir); 99 | 100 | const actualJson = await readJson(testFileName, testSpaceDir); 101 | 102 | expect(actualJson).toEqual(expectedJson); 103 | }); 104 | 105 | test('should handle comments, unquoted props and trailing commas in json files', async () => { 106 | const testFileName = 'testFile.txt'; 107 | const expectedJson = { json: 'prop' }; 108 | await writeText( 109 | testFileName, 110 | ` 111 | { 112 | // note the trailing comma? 113 | json: 'prop', 114 | }`, 115 | testSpaceDir, 116 | ); 117 | 118 | const actualJson = await readJson(testFileName, testSpaceDir); 119 | expect(actualJson).toEqual(expectedJson); 120 | }); 121 | 122 | test('should gracefully fail to read json from file', async () => { 123 | const testFileName = path.join(testSpaceDir, 'missing', 'testFile.txt'); 124 | const expectedJson = undefined; 125 | 126 | const actualJson = await readJson(testFileName, testSpaceDir); 127 | 128 | expect(actualJson).toEqual(expectedJson); 129 | }); 130 | 131 | test('should gracefully fail to write json to file', async () => { 132 | const testFileName = path.join(testSpaceDir, 'missing', 'testFile.txt'); 133 | const expectedJson = { text: 'READ ME!' }; 134 | 135 | await expect(writeJson(testFileName, expectedJson)).resolves.toBe(undefined); 136 | }); 137 | 138 | test('should list files', async () => { 139 | await writeText(path.join(testSpaceDir, 'testFile1.txt'), ''); 140 | await writeText(path.join(testSpaceDir, 'testFile2.txt'), ''); 141 | await writeText(path.join(testSpaceDir, 'testFile3.txt'), ''); 142 | 143 | const files = await list('**/*', testSpaceDir); 144 | 145 | const cleanedFiles = files.map((file) => 146 | file.replace(path.resolve(testSpaceDir), '.').replace(/\\/g, '/'), 147 | ); 148 | 149 | expect(cleanedFiles).toEqual([ 150 | './testFile1.txt', 151 | './testFile2.txt', 152 | './testFile3.txt', 153 | ]); 154 | }); 155 | 156 | test('should list files matching pattern', async () => { 157 | await writeText(path.join(testSpaceDir, 'testFile1.txt'), ''); 158 | await writeText(path.join(testSpaceDir, 'ignored.txt'), ''); 159 | await writeText(path.join(testSpaceDir, 'testFile2.txt'), ''); 160 | 161 | const files = await list('**/testFile*', testSpaceDir); 162 | 163 | const cleanedFiles = files.map((file) => 164 | file.replace(path.resolve(testSpaceDir), '.').replace(/\\/g, '/'), 165 | ); 166 | 167 | expect(cleanedFiles).toEqual(['./testFile1.txt', './testFile2.txt']); 168 | }); 169 | 170 | test('should list files matching extensions', async () => { 171 | await writeText(path.join(testSpaceDir, 'testFile1.txt'), ''); 172 | await writeText(path.join(testSpaceDir, 'ignored.sh'), ''); 173 | await writeText(path.join(testSpaceDir, 'testFile2.js'), ''); 174 | 175 | const files = await list('**/*', testSpaceDir, { extensions: ['txt', 'js'] }); 176 | 177 | const cleanedFiles = files.map((file) => 178 | file.replace(path.resolve(testSpaceDir), '.').replace(/\\/g, '/'), 179 | ); 180 | 181 | expect(cleanedFiles).toEqual(['./testFile1.txt', './testFile2.js']); 182 | }); 183 | 184 | test('should list files matching single extension', async () => { 185 | await writeText(path.join(testSpaceDir, 'testFile1.txt'), ''); 186 | await writeText(path.join(testSpaceDir, 'ignored.sh'), ''); 187 | await writeText(path.join(testSpaceDir, 'testFile2.js'), ''); 188 | 189 | const files = await list('**/*', testSpaceDir, { extensions: ['js'] }); 190 | 191 | const cleanedFiles = files.map((file) => 192 | file.replace(path.resolve(testSpaceDir), '.').replace(/\\/g, '/'), 193 | ); 194 | 195 | expect(cleanedFiles).toEqual(['./testFile2.js']); 196 | }); 197 | 198 | describe('deleteFile', () => { 199 | it('should call fs.rm with correct arguments', async () => { 200 | const rm = jest.spyOn(fs, 'rm'); 201 | const testFileName = 'testFile.txt'; 202 | await writeText(testFileName, '', testSpaceDir); 203 | 204 | await deleteFile(testFileName, testSpaceDir); 205 | 206 | expect(rm).toHaveBeenCalledTimes(1); 207 | expect(rm).toHaveBeenCalledWith( 208 | path.join(testSpaceDir, testFileName), 209 | expect.any(Function), 210 | ); 211 | const files = await list('**/*', testSpaceDir, { extensions: ['js'] }); 212 | expect(files.length).toBe(0); 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { cosmiconfigSync } from 'cosmiconfig'; 2 | import { ProcessedResult } from './process'; 3 | import { readJson, writeJson } from './fs'; 4 | import { CliArguments, Context, PackageJson } from './index'; 5 | import glob from 'glob'; 6 | import { promisify } from 'util'; 7 | import { ensureArray } from './ensureArray'; 8 | import { MapLike } from 'typescript'; 9 | import { hasPackage } from './meta'; 10 | import { presets } from './presets'; 11 | import readPkgUp from 'read-pkg-up'; 12 | 13 | const globAsync = promisify(glob); 14 | 15 | const CONFIG_FILE = '.unimportedrc.json'; 16 | 17 | export interface EntryConfig { 18 | file: string; 19 | label?: string; 20 | aliases: MapLike; 21 | extensions: string[]; 22 | } 23 | 24 | export interface UnimportedConfig { 25 | preset?: string; 26 | flow?: boolean; 27 | entry?: ( 28 | | string 29 | | { 30 | file: string; 31 | ignore?: string | string[]; 32 | label?: string; 33 | aliases?: MapLike; 34 | extensions?: string[]; 35 | extend?: { aliases?: MapLike; extensions?: string[] }; 36 | } 37 | )[]; 38 | ignorePatterns?: string[]; 39 | ignoreUnresolved: string[]; 40 | ignoreUnimported: string[]; 41 | ignoreUnused: string[]; 42 | respectGitignore?: boolean; 43 | moduleDirectory?: string[]; 44 | rootDir?: string; 45 | extensions?: string[]; 46 | assetExtensions?: string[]; 47 | aliases?: MapLike; 48 | pathTransforms?: MapLike; 49 | scannedDirs?: string[]; 50 | } 51 | 52 | type PresetParams = { 53 | packageJson: PackageJson; 54 | hasPackage: (name: string) => boolean; 55 | }; 56 | 57 | export type Preset = { 58 | name: string; 59 | isMatch: (options: PresetParams) => Promise | boolean; 60 | getConfig: ( 61 | options: PresetParams, 62 | ) => Promise | UnimportedConfig; 63 | }; 64 | 65 | export interface Config { 66 | version: string; 67 | preset?: string; 68 | flow?: boolean; 69 | entryFiles: EntryConfig[]; 70 | ignorePatterns: string[]; 71 | ignoreUnresolved: string[]; 72 | ignoreUnimported: string[]; 73 | ignoreUnused: string[]; 74 | respectGitignore: boolean; 75 | moduleDirectory?: string[]; 76 | rootDir?: string; 77 | extensions: string[]; 78 | assetExtensions: string[]; 79 | pathTransforms?: MapLike; 80 | scannedDirs?: string[]; 81 | } 82 | 83 | export async function expandGlob( 84 | patterns: string | string[], 85 | options?: { 86 | ignore?: string | string[]; 87 | }, 88 | ): Promise { 89 | const set = new Set(); 90 | 91 | for (const pattern of ensureArray(patterns)) { 92 | const paths = await globAsync(pattern, { 93 | realpath: false, 94 | ignore: options?.ignore, 95 | }); 96 | 97 | for (const path of paths) { 98 | set.add(path); 99 | } 100 | } 101 | 102 | return Array.from(set); 103 | } 104 | 105 | let cachedConfig; 106 | 107 | export function __clearCachedConfig() { 108 | cachedConfig = undefined; 109 | } 110 | 111 | export async function getPreset( 112 | name?: string, 113 | ): Promise { 114 | const packageJson = 115 | (await readJson('package.json')) || ({} as PackageJson); 116 | 117 | const options = { 118 | packageJson, 119 | hasPackage: (name: string) => hasPackage(packageJson, name), 120 | }; 121 | 122 | const preset = presets.find( 123 | (preset) => preset.name === name || preset.isMatch(options), 124 | ); 125 | 126 | if (!preset) { 127 | return; 128 | } 129 | 130 | const config = await preset.getConfig(options); 131 | 132 | return { 133 | preset: preset.name, 134 | ...config, 135 | }; 136 | } 137 | 138 | export async function getConfig(args?: CliArguments): Promise { 139 | if (cachedConfig) { 140 | return cachedConfig; 141 | } 142 | 143 | const cosmiconfigResult = cosmiconfigSync('unimported').search(); 144 | const configFile = cosmiconfigResult?.config as Partial; 145 | 146 | const unimportedPkg = await readPkgUp({ cwd: __dirname }); 147 | 148 | const preset = await getPreset(configFile?.preset); 149 | 150 | const config: Config = { 151 | version: unimportedPkg?.packageJson.version || 'unknown', 152 | preset: preset?.preset, 153 | flow: args?.flow ?? configFile?.flow ?? preset?.flow ?? false, 154 | rootDir: configFile?.rootDir ?? preset?.rootDir, 155 | ignoreUnresolved: 156 | configFile?.ignoreUnresolved ?? preset?.ignoreUnresolved ?? [], 157 | ignoreUnimported: await expandGlob( 158 | configFile?.ignoreUnimported ?? preset?.ignoreUnimported ?? [], 159 | ), 160 | ignoreUnused: configFile?.ignoreUnused ?? preset?.ignoreUnused ?? [], 161 | ignorePatterns: configFile?.ignorePatterns ?? preset?.ignorePatterns ?? [], 162 | respectGitignore: configFile?.respectGitignore ?? true, 163 | moduleDirectory: configFile?.moduleDirectory ?? preset?.moduleDirectory, 164 | entryFiles: [], 165 | extensions: [], 166 | assetExtensions: configFile?.assetExtensions ?? [], 167 | pathTransforms: configFile?.pathTransforms ?? preset?.pathTransforms, 168 | scannedDirs: configFile?.scannedDirs ?? preset?.scannedDirs ?? [], 169 | }; 170 | 171 | const aliases = configFile?.aliases ?? preset?.aliases ?? {}; 172 | const extensions = configFile?.extensions ?? preset?.extensions ?? []; 173 | const entryFiles = configFile?.entry ?? preset?.entry ?? []; 174 | 175 | // throw if no entry point could be found 176 | if (entryFiles.length === 0) { 177 | throw new Error( 178 | `Unable to locate entry points for this ${ 179 | preset?.preset ?? '' 180 | } project. Please declare them in package.json or .unimportedrc.json`, 181 | ); 182 | } 183 | 184 | for (const entry of entryFiles) { 185 | if (typeof entry === 'string') { 186 | for (const file of await expandGlob(entry)) { 187 | config.entryFiles.push({ 188 | file, 189 | aliases, 190 | extensions, 191 | }); 192 | } 193 | } else { 194 | const entryAliases = entry.aliases 195 | ? entry.aliases 196 | : entry.extend?.aliases 197 | ? { ...aliases, ...entry.extend.aliases } 198 | : aliases; 199 | 200 | const entryExtensions = entry.extensions 201 | ? entry.extensions 202 | : entry.extend?.extensions 203 | ? [...entry.extend.extensions, ...extensions] 204 | : extensions; 205 | 206 | for (const file of await expandGlob(entry.file, { 207 | ignore: entry.ignore, 208 | })) { 209 | config.entryFiles.push({ 210 | file, 211 | label: entry.label, 212 | aliases: entryAliases, 213 | extensions: entryExtensions, 214 | }); 215 | } 216 | } 217 | } 218 | 219 | // collect _all_ extensions for file listing 220 | const uniqExtensions = new Set(extensions); 221 | for (const entryFile of config.entryFiles) { 222 | for (const extension of entryFile.extensions) { 223 | // pop the last part, so that .server.js merges with .js 224 | uniqExtensions.add('.' + extension.split('.').pop()); 225 | } 226 | } 227 | 228 | config.extensions = Array.from(uniqExtensions); 229 | 230 | cachedConfig = config; 231 | return config; 232 | } 233 | 234 | function sort(arr) { 235 | const sorted = [...arr]; 236 | sorted.sort(); 237 | return sorted; 238 | } 239 | 240 | function merge(left, right) { 241 | return sort(Array.from(new Set([...left, ...right]))); 242 | } 243 | 244 | export async function writeConfig(config: Partial) { 245 | const current = await readJson(CONFIG_FILE); 246 | const next = Object.assign({}, current, config); 247 | return await writeJson(CONFIG_FILE, next); 248 | } 249 | 250 | export async function updateAllowLists( 251 | files: ProcessedResult, 252 | context: Context, 253 | ) { 254 | const cfg = context.config; 255 | 256 | await writeConfig({ 257 | ignoreUnresolved: merge(cfg.ignoreUnresolved, files.unresolved), 258 | ignoreUnused: merge(cfg.ignoreUnused, files.unused), 259 | ignoreUnimported: merge(cfg.ignoreUnimported, files.unimported), 260 | }); 261 | } 262 | -------------------------------------------------------------------------------- /src/traverse.ts: -------------------------------------------------------------------------------- 1 | import { dirname, extname, relative } from 'path'; 2 | 3 | import { 4 | AST_NODE_TYPES, 5 | parse as parseEstree, 6 | simpleTraverse, 7 | } from '@typescript-eslint/typescript-estree'; 8 | import * as fs from './fs'; 9 | import resolve from 'resolve'; 10 | import removeFlowTypes from 'flow-remove-types'; 11 | import { 12 | invalidateEntries, 13 | invalidateEntry, 14 | InvalidCacheError, 15 | resolveEntry, 16 | } from './cache'; 17 | import { log } from './log'; 18 | import { MapLike } from 'typescript'; 19 | import type { TSESTree } from '@typescript-eslint/types'; 20 | 21 | export interface FileStats { 22 | path: string; 23 | extname: string; 24 | dirname: string; 25 | imports: ResolvedResult[]; 26 | } 27 | 28 | export interface TraverseResult { 29 | unresolved: Map; 30 | files: Map; 31 | modules: Set; 32 | } 33 | 34 | function getDependencyName( 35 | path: string, 36 | config: TraverseConfig, 37 | ): string | null { 38 | if (config.preset === 'meteor' && path.startsWith('meteor/')) { 39 | return path; 40 | } 41 | 42 | const [namespace, module] = path.split('/'); 43 | const name = path[0] === '@' ? `${namespace}/${module}` : namespace; 44 | 45 | if (config.dependencies[name]) { 46 | return name; 47 | } 48 | 49 | if (config.dependencies[`@types/${name}`]) { 50 | return `@types/${name}`; 51 | } 52 | 53 | return null; 54 | } 55 | 56 | function transformPath(rawPath: string, config: TraverseConfig): string { 57 | let path = rawPath; 58 | if (config.pathTransforms) { 59 | for (const [search, replace] of Object.entries(config.pathTransforms)) { 60 | path = path.replace(new RegExp(search, 'g'), replace); 61 | } 62 | } 63 | return path; 64 | } 65 | 66 | export type ResolvedResult = 67 | | { 68 | type: 'node_module'; 69 | name: string; 70 | path: string; 71 | } 72 | | { 73 | type: 'source_file'; 74 | path: string; 75 | } 76 | | { 77 | type: 'unresolved'; 78 | path: string; 79 | }; 80 | 81 | export function resolveImport( 82 | rawPath: string, 83 | cwd: string, 84 | config: TraverseConfig, 85 | ): ResolvedResult { 86 | let path = transformPath(rawPath, config); 87 | const dependencyName = getDependencyName(path, config); 88 | 89 | if (dependencyName) { 90 | return { 91 | type: 'node_module', 92 | name: dependencyName, 93 | path, 94 | }; 95 | } 96 | 97 | try { 98 | return { 99 | type: 'source_file', 100 | path: resolve 101 | .sync(path, { 102 | basedir: cwd, 103 | extensions: config.extensions, 104 | moduleDirectory: config.moduleDirectory, 105 | }) 106 | .replace(/\\/g, '/'), 107 | }; 108 | } catch (e) {} 109 | 110 | // import { random } from '@helpers' 111 | if (config.aliases[`${path}/`]) { 112 | // append a slash to the path so that the resolve logic below recognizes this as an /index import 113 | path = `${path}/`; 114 | } 115 | 116 | // import random from '@helpers/random' > '@helpers/random'.startsWith('@helpers/') 117 | const aliases = Object.keys(config.aliases).filter((alias) => 118 | path.startsWith(alias), 119 | ); 120 | 121 | for (const alias of aliases) { 122 | for (const alt of config.aliases[alias]) { 123 | try { 124 | return { 125 | type: 'source_file', 126 | path: resolve 127 | .sync(path.replace(alias, alt), { 128 | basedir: cwd, 129 | extensions: config.extensions, 130 | moduleDirectory: config.moduleDirectory, 131 | }) 132 | .replace(/\\/g, '/'), 133 | }; 134 | } catch (e) {} 135 | } 136 | } 137 | 138 | // last attempt, try prefix the path with ./, `import 'index' to `import './index'` 139 | // can be useful for the entry files 140 | try { 141 | return { 142 | type: 'source_file', 143 | path: resolve 144 | .sync(`./${path}`, { 145 | basedir: cwd, 146 | extensions: config.extensions, 147 | moduleDirectory: config.moduleDirectory, 148 | }) 149 | .replace(/\\/g, '/'), 150 | }; 151 | } catch (e) {} 152 | 153 | // if nothing else works out :( 154 | return { 155 | type: 'unresolved', 156 | path: path, 157 | }; 158 | } 159 | 160 | const VueScriptRegExp = new RegExp( 161 | 'lang|setup|src))(?:=[\'"](?.+)[\'"])?)*\\s?\\/?>', 162 | 'i', 163 | ); 164 | 165 | function extractFromScriptTag(code: string) { 166 | const lines = code.split('\n'); 167 | const start: number[] = []; 168 | const end: number[] = []; 169 | 170 | // walk the code from start to end to find the first tag on it's own line 189 | for (let idx = lines.length - 1; idx >= 0; idx--) { 190 | if (lines[idx].trim() === '') { 191 | end.push(idx); 192 | } 193 | if (end.length === 2) { 194 | break; 195 | } 196 | } 197 | 198 | let str = ''; 199 | 200 | if (start.length > 0 && end.length > 0) { 201 | const endReversed = end.reverse(); 202 | start.forEach((value, index) => { 203 | str += lines.slice(value + 1, endReversed[index]).join('\n'); 204 | }); 205 | } 206 | 207 | return str; 208 | } 209 | 210 | // removeFlowTypes checks for pragmas, we use app arguments to override and 211 | // strip flow annotations from all files, regardless if it contains the pragma. 212 | function handleFlowType(code: string, config: TraverseConfig) { 213 | // note that we've patched `flow-remove-types` to strip import kinds, 214 | // but not the statements 215 | return removeFlowTypes(code, { all: config.flow }).toString(); 216 | } 217 | 218 | async function parse(path: string, config: TraverseConfig): Promise { 219 | log.info('parse %s', path); 220 | 221 | const stats: FileStats = { 222 | path, 223 | extname: extname(path), 224 | dirname: dirname(path), 225 | imports: [], 226 | }; 227 | 228 | let code = await fs.readText(path); 229 | 230 | // Let's just assume that nobody is going to write flow in .ts files. 231 | if (stats.extname !== '.ts' && stats.extname !== '.tsx') { 232 | code = handleFlowType(code, config); 233 | } 234 | 235 | if (stats.extname === '.vue') { 236 | code = extractFromScriptTag(code); 237 | } 238 | 239 | // this jsx check isn't bullet proof, but I have no idea how we can deal with 240 | // this better. The parser will fail on generics like in jsx files, if we 241 | // don't specify those as being jsx. 242 | const ast = parseEstree(code, { 243 | comment: false, 244 | jsx: stats.extname !== '.ts', 245 | }); 246 | 247 | simpleTraverse(ast, { 248 | enter(node) { 249 | let target: string | undefined; 250 | 251 | switch (node.type) { 252 | // import x from './x'; 253 | case AST_NODE_TYPES.ImportDeclaration: 254 | if (!node.source.value) { 255 | break; 256 | } 257 | target = node.source.value; 258 | break; 259 | 260 | // export { x } from './x'; 261 | case AST_NODE_TYPES.ExportNamedDeclaration: 262 | if (!node.source?.value) { 263 | break; 264 | } 265 | target = node.source.value; 266 | break; 267 | 268 | // export * from './x'; 269 | case AST_NODE_TYPES.ExportAllDeclaration: 270 | if (!node.source) { 271 | break; 272 | } 273 | 274 | target = node.source.value; 275 | break; 276 | 277 | // import('.x') || await import('.x') 278 | case AST_NODE_TYPES.ImportExpression: 279 | const { source } = node; 280 | if (!source) { 281 | break; 282 | } 283 | 284 | if (source.type === 'TemplateLiteral') { 285 | // Allow for constant template literals, import(`.x`) 286 | if (source.expressions.length === 0 && source.quasis.length === 1) { 287 | target = source.quasis[0].value.cooked; 288 | } 289 | } else { 290 | target = (source as TSESTree.Literal).value as string; 291 | } 292 | break; 293 | 294 | // require('./x') || await require('./x') 295 | case AST_NODE_TYPES.CallExpression: { 296 | if ((node.callee as TSESTree.Identifier)?.name !== 'require') { 297 | break; 298 | } 299 | 300 | const [argument] = node.arguments; 301 | 302 | if (argument.type === 'TemplateLiteral') { 303 | // Allow for constant template literals, require(`.x`) 304 | if ( 305 | argument.expressions.length === 0 && 306 | argument.quasis.length === 1 307 | ) { 308 | target = argument.quasis[0].value.cooked; 309 | } 310 | } else { 311 | target = (argument as TSESTree.Literal).value as string; 312 | } 313 | break; 314 | } 315 | } 316 | 317 | if (target) { 318 | const resolved = resolveImport(target, stats.dirname, config); 319 | stats.imports.push(resolved); 320 | } 321 | }, 322 | }); 323 | 324 | return stats; 325 | } 326 | 327 | export const getResultObject = () => ({ 328 | unresolved: new Map(), 329 | modules: new Set(), 330 | files: new Map(), 331 | }); 332 | 333 | export interface TraverseConfig { 334 | aliases: MapLike; 335 | extensions: string[]; 336 | assetExtensions: string[]; 337 | moduleDirectory: string[]; 338 | cacheId?: string; 339 | flow?: boolean; 340 | preset?: string; 341 | dependencies: MapLike; 342 | pathTransforms?: MapLike; 343 | root: string; 344 | } 345 | 346 | export async function traverse( 347 | path: string | string[], 348 | config: TraverseConfig, 349 | result = getResultObject(), 350 | ): Promise { 351 | if (Array.isArray(path)) { 352 | await Promise.all(path.map((x) => traverse(x, config, result))); 353 | return result; 354 | } 355 | 356 | path = path.replace(/\\/g, '/'); 357 | 358 | // be sure to only process each file once, and not end up in recursion troubles 359 | if (result.files.has(path)) { 360 | return result; 361 | } 362 | 363 | // only process code files, no json or css 364 | const ext = extname(path); 365 | if (!config.extensions.includes(ext)) { 366 | if (config.assetExtensions.includes(ext)) { 367 | result.files.set(path, { 368 | path, 369 | extname: extname(path), 370 | dirname: dirname(path), 371 | imports: [], 372 | }); 373 | } 374 | return result; 375 | } 376 | 377 | let parseResult: FileStats; 378 | try { 379 | const generator = () => parse(String(path), config); 380 | 381 | parseResult = config.cacheId 382 | ? await resolveEntry(path, generator, config.cacheId) 383 | : await parse(path, config); 384 | result.files.set(path, parseResult); 385 | 386 | for (const file of parseResult.imports) { 387 | switch (file.type) { 388 | case 'node_module': 389 | result.modules.add(file.name); 390 | break; 391 | case 'unresolved': 392 | const current = result.unresolved.get(file.path) || []; 393 | const path = relative(config.root, parseResult.path); 394 | const next = current.includes(path) ? current : [...current, path]; 395 | result.unresolved.set(file.path, next); 396 | break; 397 | case 'source_file': 398 | if (result.files.has(file.path)) { 399 | break; 400 | } 401 | await traverse(file.path, config, result); 402 | break; 403 | } 404 | } 405 | } catch (error) { 406 | if (config.cacheId) { 407 | invalidateEntry(path); 408 | invalidateEntries((meta) => { 409 | // Invalidate anyone referencing this file 410 | return !!meta.imports.find((x) => x.path === path); 411 | }); 412 | } 413 | 414 | if (error instanceof Error && !(error instanceof InvalidCacheError)) { 415 | throw InvalidCacheError.wrap(error, path); 416 | } 417 | 418 | throw error; 419 | } 420 | 421 | return result; 422 | } 423 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import simpleGit from 'simple-git'; 2 | 3 | import * as fs from './fs'; 4 | import readPkgUp from 'read-pkg-up'; 5 | 6 | import path, { join } from 'path'; 7 | import ora from 'ora'; 8 | import { printDeleteResult, printResults } from './print'; 9 | import * as meta from './meta'; 10 | import { getResultObject, traverse, TraverseConfig } from './traverse'; 11 | import chalk from 'chalk'; 12 | import yargs, { Arguments } from 'yargs'; 13 | import { CompilerOptions } from 'typescript'; 14 | import { processResults } from './process'; 15 | import { 16 | getConfig, 17 | Config, 18 | updateAllowLists, 19 | writeConfig, 20 | getPreset, 21 | } from './config'; 22 | import { 23 | getCacheIdentity, 24 | InvalidCacheError, 25 | purgeCache, 26 | storeCache, 27 | } from './cache'; 28 | import { log } from './log'; 29 | import { presets } from './presets'; 30 | import { removeUnused } from './delete'; 31 | 32 | export interface TsConfig { 33 | compilerOptions: CompilerOptions; 34 | } 35 | 36 | export interface JsConfig { 37 | compilerOptions: CompilerOptions; 38 | } 39 | 40 | export interface PackageJson { 41 | name: string; 42 | version: string; 43 | main?: string; 44 | source?: string | string[]; 45 | dependencies?: { [name: string]: string }; 46 | optionalDependencies?: { [name: string]: string }; 47 | devDependencies?: { [name: string]: string }; 48 | bundleDependencies?: { [name: string]: string }; 49 | peerDependencies?: { [name: string]: string }; 50 | meteor?: { 51 | mainModule?: { 52 | client: string; 53 | server: string; 54 | }; 55 | }; 56 | repository?: { 57 | directory: string; 58 | }; 59 | } 60 | 61 | export interface Context { 62 | cwd: string; 63 | dependencies: { [key: string]: string }; 64 | peerDependencies: { [key: string]: string }; 65 | cache?: boolean; 66 | config: Config; 67 | moduleDirectory: string[]; 68 | cacheId?: string; 69 | showUnusedFiles: boolean; 70 | showUnusedDeps: boolean; 71 | showUnresolvedImports: boolean; 72 | } 73 | 74 | const oraStub = { 75 | set text(msg) { 76 | log.info(msg); 77 | }, 78 | stop(msg = '') { 79 | log.info(msg); 80 | }, 81 | }; 82 | 83 | export async function main(args: CliArguments): Promise { 84 | const projectPkg = await readPkgUp({ cwd: args.cwd }); 85 | const unimportedPkg = await readPkgUp({ cwd: __dirname }); 86 | 87 | // equality check to prevent tests from walking up and running on unimported itself 88 | if (!projectPkg || !unimportedPkg || unimportedPkg.path === projectPkg.path) { 89 | console.error( 90 | chalk.redBright( 91 | `could not resolve package.json, are you in a node project?`, 92 | ), 93 | ); 94 | process.exit(1); 95 | return; 96 | } 97 | 98 | // change the work dir for the process to the project root, this enables us 99 | // to run unimported from nested paths within the project 100 | process.chdir(path.dirname(projectPkg.path)); 101 | const cwd = process.cwd(); 102 | 103 | // clear cache and return 104 | if (args.clearCache) { 105 | return purgeCache(); 106 | } 107 | 108 | const spinner = 109 | log.enabled() || process.env.NODE_ENV === 'test' 110 | ? oraStub 111 | : ora('initializing').start(); 112 | 113 | try { 114 | const config = await getConfig(args); 115 | 116 | if (args.showConfig) { 117 | spinner.stop(); 118 | console.dir(config, { depth: 5 }); 119 | process.exit(0); 120 | } 121 | 122 | if (typeof args.showPreset === 'string') { 123 | spinner.stop(); 124 | if (args.showPreset) { 125 | console.dir(await getPreset(args.showPreset), { depth: 5 }); 126 | } else { 127 | const available = presets 128 | .map((x) => x.name) 129 | .sort() 130 | .map((x) => ` - ${x}`) 131 | .join('\n'); 132 | 133 | console.log( 134 | `you didn't provide a preset name, please choose one of the following: \n\n${available}`, 135 | ); 136 | } 137 | process.exit(0); 138 | } 139 | 140 | const [dependencies, peerDependencies] = await Promise.all([ 141 | meta.getDependencies(cwd), 142 | meta.getPeerDependencies(cwd), 143 | ]); 144 | 145 | const moduleDirectory = config.moduleDirectory ?? ['node_modules']; 146 | 147 | const context: Context = { 148 | dependencies, 149 | peerDependencies, 150 | moduleDirectory, 151 | ...args, 152 | config, 153 | cwd: cwd.replace(/\\/g, '/'), 154 | }; 155 | 156 | if (args.init) { 157 | await writeConfig({ 158 | ignorePatterns: config.ignorePatterns, 159 | ignoreUnimported: config.ignoreUnimported, 160 | ignoreUnused: config.ignoreUnused, 161 | ignoreUnresolved: config.ignoreUnresolved, 162 | respectGitignore: config.respectGitignore, 163 | }); 164 | 165 | spinner.stop(); 166 | process.exit(0); 167 | } 168 | 169 | // Filter untracked files from git repositories 170 | if (args.ignoreUntracked) { 171 | const git = simpleGit({ baseDir: context.cwd }); 172 | const status = await git.status(); 173 | config.ignorePatterns.push( 174 | ...status.not_added.map((file) => path.resolve(file)), 175 | ); 176 | } 177 | 178 | spinner.text = `resolving imports`; 179 | 180 | const traverseResult = getResultObject(); 181 | 182 | for (const entry of config.entryFiles) { 183 | log.info('start traversal at %s', entry); 184 | 185 | const traverseConfig: TraverseConfig = { 186 | extensions: entry.extensions, 187 | assetExtensions: config.assetExtensions, 188 | // resolve full path of aliases 189 | aliases: await meta.getAliases(entry), 190 | cacheId: args.cache ? getCacheIdentity(entry) : undefined, 191 | flow: config.flow, 192 | moduleDirectory, 193 | preset: config.preset, 194 | dependencies, 195 | pathTransforms: config.pathTransforms, 196 | root: context.cwd, 197 | }; 198 | 199 | // we can't use the third argument here, to keep feeding to traverseResult 200 | // as that would break the import alias overrides. A client-entry file 201 | // can resolve `create-api` as `create-api-client.js` while server-entry 202 | // would resolve `create-api` to `create-api-server`. Sharing the subresult 203 | // between the initial and retry attempt, would make it fail cache recovery 204 | const subResult = await traverse( 205 | path.resolve(entry.file), 206 | traverseConfig, 207 | ).catch((err) => { 208 | if (err instanceof InvalidCacheError) { 209 | purgeCache(); 210 | // Retry once after invalid cache case. 211 | return traverse(path.resolve(entry.file), traverseConfig); 212 | } else { 213 | throw err; 214 | } 215 | }); 216 | 217 | subResult.files = new Map([...subResult.files].sort()); 218 | 219 | // and that's why we need to merge manually 220 | subResult.modules.forEach((module) => { 221 | traverseResult.modules.add(module); 222 | }); 223 | subResult.unresolved.forEach((unresolved, key) => { 224 | traverseResult.unresolved.set(key, unresolved); 225 | }); 226 | 227 | for (const [key, stat] of subResult.files) { 228 | const prev = traverseResult.files.get(key); 229 | 230 | if (!prev) { 231 | traverseResult.files.set(key, stat); 232 | continue; 233 | } 234 | 235 | const added = new Set(prev.imports.map((x) => x.path)); 236 | 237 | for (const file of stat.imports) { 238 | if (!added.has(file.path)) { 239 | prev.imports.push(file); 240 | added.add(file.path); 241 | } 242 | } 243 | } 244 | } 245 | 246 | // traverse the file system and get system data 247 | spinner.text = 'traverse the file system'; 248 | const scannedDirs = Array.from( 249 | new Set(['./src', ...(config.scannedDirs ?? [])]), 250 | ); 251 | const scanningPromises = scannedDirs.map(async (dir) => { 252 | const baseUrl = (await fs.exists(dir, cwd)) ? join(cwd, dir) : cwd; 253 | return await fs.list('**/*', baseUrl, { 254 | extensions: [...config.extensions, ...config.assetExtensions], 255 | ignore: config.ignorePatterns, 256 | }); 257 | }); 258 | 259 | let files: string[] = []; 260 | await Promise.all(scanningPromises).then((ret) => { 261 | ret.map((value) => { 262 | if (Array.isArray(value)) { 263 | files = files.concat(...value); 264 | } 265 | }); 266 | }); 267 | 268 | const normalizedFiles = files.map((path: string) => 269 | path.replace(/\\/g, '/'), 270 | ); 271 | 272 | spinner.text = 'process results'; 273 | spinner.stop(); 274 | 275 | const result = await processResults( 276 | normalizedFiles, 277 | traverseResult, 278 | context, 279 | ); 280 | 281 | if (args.cache) { 282 | storeCache(); 283 | } 284 | 285 | if (args.fix) { 286 | const deleteResult = await removeUnused(result, context); 287 | if (deleteResult.error) { 288 | console.log(chalk.redBright(`✕`) + ` ${deleteResult.error}`); 289 | process.exit(1); 290 | } 291 | printDeleteResult(deleteResult); 292 | process.exit(0); 293 | } 294 | 295 | if (args.update) { 296 | await updateAllowLists(result, context); 297 | // doesn't make sense here to return a error code 298 | process.exit(0); 299 | } else { 300 | printResults(result, context); 301 | } 302 | 303 | // return non-zero exit code in case the result wasn't clean, to support 304 | // running in CI environments. 305 | if (!result.clean) { 306 | process.exit(1); 307 | } 308 | } catch (error) { 309 | spinner.stop(); 310 | 311 | // console.log is intercepted for output comparison, this helps debugging 312 | if (process.env.NODE_ENV === 'test' && error instanceof Error) { 313 | console.log(error.message); 314 | } 315 | 316 | if (error instanceof InvalidCacheError) { 317 | console.error(chalk.redBright(`\nFailed parsing ${error['path']}`)); 318 | } else if (error instanceof Error) { 319 | console.error(chalk.redBright(error.message)); 320 | } else { 321 | // Who knows what this is, hopefully the .toString() is meaningful 322 | console.error(`Unexpected value thrown: ${error}`); 323 | } 324 | 325 | process.exit(1); 326 | } 327 | } 328 | 329 | export interface CliArguments { 330 | fix: boolean; 331 | flow: boolean; 332 | update: boolean; 333 | init: boolean; 334 | ignoreUntracked: boolean; 335 | clearCache: boolean; 336 | cache: boolean; 337 | cwd?: string; 338 | showConfig: boolean; 339 | showPreset?: string; 340 | config?: string; 341 | showUnusedFiles: boolean; 342 | showUnusedDeps: boolean; 343 | showUnresolvedImports: boolean; 344 | } 345 | 346 | if (process.env.NODE_ENV !== 'test') { 347 | /* istanbul ignore next */ 348 | yargs 349 | .scriptName('unimported') 350 | .usage('$0 [args]') 351 | .command( 352 | '* [cwd]', 353 | 'scan your project for dead files', 354 | (yargs) => { 355 | yargs.positional('cwd', { 356 | type: 'string', 357 | describe: 'The root directory that unimported should run from.', 358 | }); 359 | 360 | yargs.option('cache', { 361 | type: 'boolean', 362 | describe: 363 | 'Whether to use the cache. Disable the cache using --no-cache.', 364 | default: true, 365 | }); 366 | 367 | yargs.option('fix', { 368 | type: 'boolean', 369 | describe: 370 | 'Removes unused files and dependencies. This is a destructive operation, use with caution.', 371 | default: false, 372 | }); 373 | 374 | yargs.option('clear-cache', { 375 | type: 'boolean', 376 | describe: 'Clears the cache file and then exits.', 377 | }); 378 | 379 | yargs.option('flow', { 380 | alias: 'f', 381 | type: 'boolean', 382 | describe: 'Whether to strip flow types, regardless of @flow pragma.', 383 | }); 384 | 385 | yargs.option('ignore-untracked', { 386 | type: 'boolean', 387 | describe: 'Ignore files that are not currently tracked by git.', 388 | }); 389 | 390 | yargs.option('init', { 391 | alias: 'i', 392 | type: 'boolean', 393 | describe: 'Dump default settings to .unimportedrc.json.', 394 | }); 395 | 396 | yargs.option('show-config', { 397 | type: 'boolean', 398 | describe: 'Show config and then exists.', 399 | }); 400 | 401 | yargs.option('show-preset', { 402 | type: 'string', 403 | describe: 'Show preset and then exists.', 404 | }); 405 | 406 | yargs.option('update', { 407 | alias: 'u', 408 | type: 'boolean', 409 | describe: 'Update the ignore-lists stored in .unimportedrc.json.', 410 | }); 411 | 412 | yargs.option('config', { 413 | type: 'string', 414 | describe: 'The path to the config file.', 415 | }); 416 | 417 | yargs.option('show-unused-files', { 418 | type: 'boolean', 419 | describe: 'formats and only prints unimported files', 420 | }); 421 | 422 | yargs.option('show-unused-deps', { 423 | type: 'boolean', 424 | describe: 'formats and only prints unused dependencies', 425 | }); 426 | 427 | yargs.option('show-unresolved-imports', { 428 | type: 'boolean', 429 | describe: 'formats and only prints unresolved imports', 430 | }); 431 | }, 432 | function (argv: Arguments) { 433 | return main(argv); 434 | }, 435 | ) 436 | .help().argv; 437 | } 438 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "unimported", 3 | "projectOwner": "smeijer", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "smeijer", 15 | "name": "Stephan Meijer", 16 | "avatar_url": "https://avatars1.githubusercontent.com/u/1196524?v=4", 17 | "profile": "https://github.com/smeijer", 18 | "contributions": [ 19 | "ideas", 20 | "code", 21 | "infra", 22 | "maintenance" 23 | ] 24 | }, 25 | { 26 | "login": "punit2502", 27 | "name": "Punit Makwana", 28 | "avatar_url": "https://avatars1.githubusercontent.com/u/16760252?v=4", 29 | "profile": "https://in.linkedin.com/in/punit-makwana/", 30 | "contributions": [ 31 | "doc" 32 | ] 33 | }, 34 | { 35 | "login": "danew", 36 | "name": "Dane Wilson", 37 | "avatar_url": "https://avatars1.githubusercontent.com/u/5265684?v=4", 38 | "profile": "https://github.com/danew", 39 | "contributions": [ 40 | "code" 41 | ] 42 | }, 43 | { 44 | "login": "mpeyper", 45 | "name": "Michael Peyper", 46 | "avatar_url": "https://avatars.githubusercontent.com/u/23029903?v=4", 47 | "profile": "https://github.com/mpeyper", 48 | "contributions": [ 49 | "test", 50 | "code" 51 | ] 52 | }, 53 | { 54 | "login": "marcosvega91", 55 | "name": "Marco Moretti", 56 | "avatar_url": "https://avatars.githubusercontent.com/u/5365582?v=4", 57 | "profile": "https://github.com/marcosvega91", 58 | "contributions": [ 59 | "test" 60 | ] 61 | }, 62 | { 63 | "login": "Aprillion", 64 | "name": "Peter Hozák", 65 | "avatar_url": "https://avatars.githubusercontent.com/u/1087670?v=4", 66 | "profile": "http://peter.hozak.info/", 67 | "contributions": [ 68 | "test" 69 | ] 70 | }, 71 | { 72 | "login": "JacobMGEvans", 73 | "name": "Jacob M-G Evans", 74 | "avatar_url": "https://avatars.githubusercontent.com/u/27247160?v=4", 75 | "profile": "https://dev.to/jacobmgevans", 76 | "contributions": [ 77 | "test" 78 | ] 79 | }, 80 | { 81 | "login": "datner", 82 | "name": "Datner", 83 | "avatar_url": "https://avatars.githubusercontent.com/u/22598347?v=4", 84 | "profile": "https://github.com/datner", 85 | "contributions": [ 86 | "test" 87 | ] 88 | }, 89 | { 90 | "login": "codyarose", 91 | "name": "Cody Rose", 92 | "avatar_url": "https://avatars.githubusercontent.com/u/35306025?v=4", 93 | "profile": "https://github.com/codyarose", 94 | "contributions": [ 95 | "test" 96 | ] 97 | }, 98 | { 99 | "login": "AhmedEldessouki", 100 | "name": "Ahmed ElDessouki", 101 | "avatar_url": "https://avatars.githubusercontent.com/u/44158955?v=4", 102 | "profile": "https://ahmedeldessouki-a7488.firebaseapp.com/", 103 | "contributions": [ 104 | "test", 105 | "code" 106 | ] 107 | }, 108 | { 109 | "login": "YPAzevedo", 110 | "name": "Yago Pereira Azevedo", 111 | "avatar_url": "https://avatars.githubusercontent.com/u/56167866?v=4", 112 | "profile": "https://www.linkedin.com/in/ypazevedo/", 113 | "contributions": [ 114 | "test" 115 | ] 116 | }, 117 | { 118 | "login": "juhanakristian", 119 | "name": "Juhana Jauhiainen", 120 | "avatar_url": "https://avatars.githubusercontent.com/u/544386?v=4", 121 | "profile": "https://github.com/juhanakristian", 122 | "contributions": [ 123 | "test" 124 | ] 125 | }, 126 | { 127 | "login": "nobrayner", 128 | "name": "Braydon Hall", 129 | "avatar_url": "https://avatars.githubusercontent.com/u/40751395?v=4", 130 | "profile": "https://github.com/nobrayner", 131 | "contributions": [ 132 | "test" 133 | ] 134 | }, 135 | { 136 | "login": "abeprincec", 137 | "name": "abeprincec", 138 | "avatar_url": "https://avatars.githubusercontent.com/u/16880975?v=4", 139 | "profile": "https://github.com/abeprincec", 140 | "contributions": [ 141 | "test" 142 | ] 143 | }, 144 | { 145 | "login": "lswest", 146 | "name": "Lucas Westermann", 147 | "avatar_url": "https://avatars.githubusercontent.com/u/57859?v=4", 148 | "profile": "http://www.code-root.com/", 149 | "contributions": [ 150 | "code", 151 | "test" 152 | ] 153 | }, 154 | { 155 | "login": "simonwinter", 156 | "name": "Simon Winter", 157 | "avatar_url": "https://avatars.githubusercontent.com/u/1104537?v=4", 158 | "profile": "https://github.com/simonwinter", 159 | "contributions": [ 160 | "code", 161 | "test" 162 | ] 163 | }, 164 | { 165 | "login": "stovmascript", 166 | "name": "Martin Šťovíček", 167 | "avatar_url": "https://avatars.githubusercontent.com/u/14262802?v=4", 168 | "profile": "https://github.com/stovmascript", 169 | "contributions": [ 170 | "doc" 171 | ] 172 | }, 173 | { 174 | "login": "kpdecker", 175 | "name": "Kevin Decker", 176 | "avatar_url": "https://avatars.githubusercontent.com/u/196390?v=4", 177 | "profile": "http://www.incaseofstairs.com/", 178 | "contributions": [ 179 | "code", 180 | "test" 181 | ] 182 | }, 183 | { 184 | "login": "olidacombe", 185 | "name": "olidacombe", 186 | "avatar_url": "https://avatars.githubusercontent.com/u/1752435?v=4", 187 | "profile": "https://github.com/olidacombe", 188 | "contributions": [ 189 | "code" 190 | ] 191 | }, 192 | { 193 | "login": "punitcodes", 194 | "name": "Punit Makwana", 195 | "avatar_url": "https://avatars.githubusercontent.com/u/16760252?v=4", 196 | "profile": "https://in.linkedin.com/in/punit-makwana/", 197 | "contributions": [ 198 | "doc" 199 | ] 200 | }, 201 | { 202 | "login": "kkpalanisamy", 203 | "name": "Palanisamy KK", 204 | "avatar_url": "https://avatars.githubusercontent.com/u/8186979?v=4", 205 | "profile": "https://github.com/kkpalanisamy", 206 | "contributions": [ 207 | "doc" 208 | ] 209 | }, 210 | { 211 | "login": "dbartholomae", 212 | "name": "Daniel Bartholomae", 213 | "avatar_url": "https://avatars.githubusercontent.com/u/3396992?v=4", 214 | "profile": "https://startup-cto.net/", 215 | "contributions": [ 216 | "doc" 217 | ] 218 | }, 219 | { 220 | "login": "gontovnik", 221 | "name": "Danil Gontovnik", 222 | "avatar_url": "https://avatars.githubusercontent.com/u/3436659?v=4", 223 | "profile": "https://t.me/gontovnik", 224 | "contributions": [ 225 | "code" 226 | ] 227 | }, 228 | { 229 | "login": "jarjee", 230 | "name": "Nathan Smyth", 231 | "avatar_url": "https://avatars.githubusercontent.com/u/3888305?v=4", 232 | "profile": "https://github.com/jarjee", 233 | "contributions": [ 234 | "code" 235 | ] 236 | }, 237 | { 238 | "login": "chasingmaxwell", 239 | "name": "Peter Sieg", 240 | "avatar_url": "https://avatars.githubusercontent.com/u/3128659?v=4", 241 | "profile": "http://petersieg.me/", 242 | "contributions": [ 243 | "code", 244 | "test" 245 | ] 246 | }, 247 | { 248 | "login": "notjosh", 249 | "name": "Joshua May", 250 | "avatar_url": "https://avatars.githubusercontent.com/u/33126?v=4", 251 | "profile": "http://notjosh.com/", 252 | "contributions": [ 253 | "code", 254 | "test" 255 | ] 256 | }, 257 | { 258 | "login": "nweber-gh", 259 | "name": "Nathan Weber", 260 | "avatar_url": "https://avatars.githubusercontent.com/u/52676728?v=4", 261 | "profile": "https://github.com/nweber-gh", 262 | "contributions": [ 263 | "code" 264 | ] 265 | }, 266 | { 267 | "login": "wladiston", 268 | "name": "Wlad Paiva", 269 | "avatar_url": "https://avatars.githubusercontent.com/u/1843792?v=4", 270 | "profile": "https://eatingdots.com", 271 | "contributions": [ 272 | "code" 273 | ] 274 | }, 275 | { 276 | "login": "Tbhesswebber", 277 | "name": "Tanner B. Hess Webber", 278 | "avatar_url": "https://avatars.githubusercontent.com/u/28069638?v=4", 279 | "profile": "https://medium.com/@tbhesswebber", 280 | "contributions": [ 281 | "code" 282 | ] 283 | }, 284 | { 285 | "login": "tomfa", 286 | "name": "Tomas Fagerbekk", 287 | "avatar_url": "https://avatars.githubusercontent.com/u/1502702?v=4", 288 | "profile": "http://webutvikling.org/", 289 | "contributions": [ 290 | "code" 291 | ] 292 | }, 293 | { 294 | "login": "valeriobelli", 295 | "name": "Valerio Belli", 296 | "avatar_url": "https://avatars.githubusercontent.com/u/56547421?v=4", 297 | "profile": "https://github.com/valeriobelli", 298 | "contributions": [ 299 | "code" 300 | ] 301 | }, 302 | { 303 | "login": "uloco", 304 | "name": "Umut Topuzoğlu", 305 | "avatar_url": "https://avatars.githubusercontent.com/u/8818340?v=4", 306 | "profile": "https://uloco.github.io/", 307 | "contributions": [ 308 | "code" 309 | ] 310 | }, 311 | { 312 | "login": "Slapbox", 313 | "name": "slapbox", 314 | "avatar_url": "https://avatars.githubusercontent.com/u/6835891?v=4", 315 | "profile": "https://recollectr.io/", 316 | "contributions": [ 317 | "doc" 318 | ] 319 | }, 320 | { 321 | "login": "Michael-372", 322 | "name": "Michael", 323 | "avatar_url": "https://avatars.githubusercontent.com/u/5233503?v=4", 324 | "profile": "https://github.com/Michael-372", 325 | "contributions": [ 326 | "code" 327 | ] 328 | }, 329 | { 330 | "login": "karlhorky", 331 | "name": "Karl Horky", 332 | "avatar_url": "https://avatars.githubusercontent.com/u/1935696?v=4", 333 | "profile": "https://github.com/karlhorky", 334 | "contributions": [ 335 | "doc" 336 | ] 337 | }, 338 | { 339 | "login": "AdityaVandan", 340 | "name": "Aditya Vandan Sharma", 341 | "avatar_url": "https://avatars.githubusercontent.com/u/35309453?v=4", 342 | "profile": "https://adityavandan.github.io/", 343 | "contributions": [ 344 | "code" 345 | ] 346 | }, 347 | { 348 | "login": "Dogdriip", 349 | "name": "Aru Hyunseung Jeon", 350 | "avatar_url": "https://avatars.githubusercontent.com/u/6940439?v=4", 351 | "profile": "https://github.com/Dogdriip", 352 | "contributions": [ 353 | "code" 354 | ] 355 | }, 356 | { 357 | "login": "ericcornelissen", 358 | "name": "Eric Cornelissen", 359 | "avatar_url": "https://avatars.githubusercontent.com/u/3742559?v=4", 360 | "profile": "https://www.ericcornelissen.dev/", 361 | "contributions": [ 362 | "doc" 363 | ] 364 | }, 365 | { 366 | "login": "xibman", 367 | "name": "Georget Julien", 368 | "avatar_url": "https://avatars.githubusercontent.com/u/623141?v=4", 369 | "profile": "https://github.com/xibman", 370 | "contributions": [ 371 | "code" 372 | ] 373 | }, 374 | { 375 | "login": "yyamanoi1222", 376 | "name": "yu-yamanoi", 377 | "avatar_url": "https://avatars.githubusercontent.com/u/17104096?v=4", 378 | "profile": "https://github.com/yyamanoi1222", 379 | "contributions": [ 380 | "code", 381 | "test" 382 | ] 383 | }, 384 | { 385 | "login": "vimutti77", 386 | "name": "Vantroy", 387 | "avatar_url": "https://avatars.githubusercontent.com/u/27840664?v=4", 388 | "profile": "https://github.com/vimutti77", 389 | "contributions": [ 390 | "code" 391 | ] 392 | }, 393 | { 394 | "login": "LukaszGrela", 395 | "name": "Lukasz Grela", 396 | "avatar_url": "https://avatars.githubusercontent.com/u/7643591?v=4", 397 | "profile": "https://github.com/LukaszGrela", 398 | "contributions": [ 399 | "code" 400 | ] 401 | }, 402 | { 403 | "login": "ryanwilsonperkin", 404 | "name": "Ryan Wilson-Perkin", 405 | "avatar_url": "https://avatars.githubusercontent.com/u/3004111?v=4", 406 | "profile": "https://ryanwilsonperkin.com", 407 | "contributions": [ 408 | "code", 409 | "test" 410 | ] 411 | }, 412 | { 413 | "login": "ritingliudd01", 414 | "name": "Riting LIU", 415 | "avatar_url": "https://avatars.githubusercontent.com/u/47513914?v=4", 416 | "profile": "https://github.com/ritingliudd01", 417 | "contributions": [ 418 | "code", 419 | "test" 420 | ] 421 | }, 422 | { 423 | "login": "Fritsch-Tech", 424 | "name": "Lukas Fritsch", 425 | "avatar_url": "https://avatars.githubusercontent.com/u/111692684?v=4", 426 | "profile": "https://fritsch.tech/", 427 | "contributions": [ 428 | "code", 429 | "test" 430 | ] 431 | }, 432 | { 433 | "login": "MathiasVandePol", 434 | "name": "Mathias Van de Pol", 435 | "avatar_url": "https://avatars.githubusercontent.com/u/606681?v=4", 436 | "profile": "https://github.com/MathiasVandePol", 437 | "contributions": [ 438 | "code" 439 | ] 440 | }, 441 | { 442 | "login": "foucdeg", 443 | "name": "Foucauld Degeorges", 444 | "avatar_url": "https://avatars.githubusercontent.com/u/12948989?v=4", 445 | "profile": "https://github.com/foucdeg", 446 | "contributions": [ 447 | "code" 448 | ] 449 | } 450 | ], 451 | "contributorsPerLine": 7, 452 | "skipCi": true, 453 | "commitType": "docs" 454 | } 455 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Archived 2 | 3 | > [!IMPORTANT] 4 | > This project is no longer maintained. There's a project called [knip](https://knip.dev/explanations/comparison-and-migration#unimported) which has more features, and is actively maintained by [Lars Kappert](https://x.com/webprolific). Thank you for using unimported over the years! Enjoy knip, and [say hi to me on Twitter/X](https://meijer.ws/twitter). 5 | 6 | --- 7 | 8 | # unimported 9 | 10 | **Find unused source files in javascript / typescript projects.** 11 | 12 | ![screenshot of unimported results](./docs/unimported.png) 13 | 14 | While adding new code to our projects, we might forget to remove the old code. Linters warn us for unused code in a module, but they fail to report unused files. 15 | 16 | `unimported` analyzes your code by following the require/import statements starting from your entry file. 17 | 18 | The result is a report showing which files are unimported, which dependencies are missing from your `package.json`, and which dependencies can be removed from your `package.json`. 19 | 20 | ## Usage 21 | 22 | Run the following command in the root of your project (next to `package.json`). The result will be as shown under [example](#example) 23 | 24 | ```shell 25 | npx unimported 26 | ``` 27 | 28 | When running unimported from a directory that doesn't contain a `package.json`, it will run from the first parent directory that does. To override this behavior, and run from an alternative work directory, use the `[cwd]` positional argument: 29 | 30 | ``` 31 | npx unimported ~/dev/leaflet-geosearch 32 | ``` 33 | 34 | By providing the path as argument, unimported will start looking for the project root, starting at that location. 35 | 36 | ## Options 37 | 38 | Output all options in your terminal: 39 | 40 | ```shell 41 | npx unimported --help 42 | ``` 43 | 44 | ### Init 45 | 46 | This option will write the default ignore patterns to the `.unimportedrc.json` settings files. This will enable you to easily adjust them to your needs. 47 | 48 | ```shell 49 | npx unimported --init 50 | ``` 51 | 52 | ### Update 53 | 54 | Update, will write the current results to the ignore lists in `.unimportedrc.json`. You want to use this option **after running and verifying** a full scan. Ignore lists are used to ignore certain false positives that could not be resolved properly. This is especially useful when running `unimported` on a regular basis, or for example as part of a CI pipeline. 55 | 56 | ```shell 57 | npx unimported --update 58 | ``` 59 | 60 | ### Fix 61 | 62 | Running with the `--fix` argument will automatically remove unimported files from your project. This is a destructive action, so make sure that any changes you find important, are committed to your repo. 63 | 64 | ```shell 65 | npx unimported --fix 66 | ``` 67 | 68 | ### Flow Type 69 | 70 | By default, flow types are stripped from files containing the `@flow` pragma. When the `--flow` argument is provided, types will be stripped from all files, regardless of the pragma. This flag defaults to false, but when `flow-bin` is detected in one of the dependency lists in `package.json`. 71 | 72 | ```shell 73 | npx unimported --flow 74 | ``` 75 | 76 | ## CI Usage 77 | 78 | You can drop in `npx unimported` into your CI. It will fail if it finds any unimported files that are not explicitly set up in the `unimported` config file. 79 | 80 | ### Cache 81 | 82 | Unimported uses a caching system to speed up recurring checks. This cache can be disabled using `--no-cache`. Note that the cache should only be disabled if you are experiencing caching related problems. 83 | 84 | ```shell 85 | npx unimported --no-cache 86 | ``` 87 | 88 | If you need to clear the cache, use `--clear-cache`. 89 | 90 | ### Clear Cache 91 | 92 | Delete the cache file and then exits without running. Note that clearing the cache will reduce performance. 93 | 94 | ```shell 95 | npx unimported --clear-cache 96 | ``` 97 | 98 | ### Show Config 99 | 100 | Show the runtime config and then exists without running. The config displayed is a working copy created by merging arguments, your config file, and the applied preset. 101 | 102 | ```shell 103 | npx unimported --show-config 104 | ``` 105 | 106 | ### Show Preset 107 | 108 | Show the preset being used and then exists without running. Note that presets are dynamic and based on your project structure. The same preset can show a different setup for different projects based on the installed packages and available files. 109 | 110 | ```shell 111 | npx unimported --show-preset react 112 | ``` 113 | 114 | Omit the preset name to get a list of available presets. 115 | 116 | ```shell 117 | npx unimported --show-preset 118 | ``` 119 | 120 | ### Example Config File 121 | 122 | Save the file as `.unimportedrc.json` in the root of your project (next to `package.json`) 123 | 124 | ```json 125 | { 126 | "entry": ["src/main.ts", "src/pages/**/*.{js,ts}"], 127 | "extensions": [".ts", ".js"], 128 | "ignorePatterns": ["**/node_modules/**", "private/**"], 129 | "ignoreUnresolved": ["some-npm-dependency"], 130 | "ignoreUnimported": ["src/i18n/locales/en.ts", "src/i18n/locales/nl.ts"], 131 | "ignoreUnused": ["bcrypt", "create-emotion"], 132 | "respectGitignore": true, 133 | "scannedDirs": ["./modules"] 134 | } 135 | ``` 136 | 137 | **Custom module directory** 138 | You can also add an optional `moduleDirectory` option to your configuration file to resolve dependencies from other directories than `node_modules`. This setting defaults to `node_modules`. 139 | 140 | ```json 141 | { 142 | "moduleDirectory": ["node_modules", "src/app"] 143 | } 144 | ``` 145 | 146 | **Custom aliases** 147 | If you wish to use aliases to import your modules & these can't be imported 148 | directly (e.g. `tsconfig.json` in the case of Typescript or `jsconfig.json` if you have one), there is an option `aliases` to provide the correct path mapping: 149 | 150 | ```json 151 | { 152 | "aliases": { 153 | "@components/*": ["./components", "./components/*"] 154 | } 155 | } 156 | ``` 157 | 158 | _Note:_ you may wish to also add the `rootDir` option to specify the base path to 159 | start looking for the aliases from: 160 | 161 | ```json 162 | { 163 | "rootDir": "./src" 164 | } 165 | ``` 166 | 167 | **Path transformations** 168 | If you wish to transform paths before module resolution, there is an option `pathTransforms` to specify regex search patterns and corresponding replacement values. Search patterns will be applied with the global flag and should **_not_** be enclosed by `/` characters. Replacement values support all special replacement patterns supported by [String.prototype.replaceAll()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replaceAll#specifying_a_string_as_a_parameter). 169 | 170 | Here is an example for transforming the extension for relative imports from ".js" to ".ts" (this is useful for TypeScript projects configured to output pure ESM). 171 | 172 | ```json 173 | { 174 | "pathTransforms": { 175 | "(\\..+)\\.js$": "$1.ts" 176 | } 177 | } 178 | ``` 179 | 180 | **Scanned dirs** 181 | By default the unimported files are only scanned from dir `./src`. If you also wish to scan files outside `./src`, add other dirs in the option `scannedDirs`: 182 | 183 | ```json 184 | { 185 | "scannedDirs": ["./modules", "./lib"] 186 | } 187 | ``` 188 | 189 | ## Report 190 | 191 | The report will look something like [below](#example). When a particular check didn't have any positive results, it's section will be excluded from the output. 192 | 193 | ### summary 194 | 195 | Summary displays a quick overview of the results, showing the entry points that were used, and some statistics about the outcome. 196 | 197 | ### unresolved imports 198 | 199 | These import statements could not be resolved. This can either be a reference to a local file. Or to a `node_module`. In case of a node module, it can be that nothing is wrong. Maybe you're importing only types from a `DefinitelyTyped` package. But as `unimported` only compares against `dependencies`, it can also be that you've added your module to the `devDependencies`, and that's a problem. 200 | 201 | To ignore specific results, add them to `.unimportedrc.json#ignoreUnresolved`. 202 | 203 | ### unused dependencies 204 | 205 | Some dependencies that are declared in your package.json, were not imported by your code. It should be possible to remove those packages from your project. 206 | 207 | But, please double check. Maybe you need to move some dependencies to `devDependencies`, or maybe it's a peer-dependency from another package. These are hints that something might be wrong. It's no guarantee. 208 | 209 | To ignore specific results, add them to `.unimportedrc.json#ignoreUnused`. 210 | 211 | ### unimported files 212 | 213 | The files listed under `unimported files`, are the files that exist in your code base, but are not part of your final bundle. It should be safe to delete those files. 214 | 215 | For your convenience, some files are not shown, as we treat those as 'dev only' files which you might need. More about that [below](#how). 216 | 217 | To ignore specific results, add them to `.unimportedrc.json#ignoreUnimported`. 218 | 219 | ### example 220 | 221 | ```shell 222 | summary 223 | ──────────────────────────────────────────────── 224 | entry file 1 : src/client/main.js 225 | entry file 2 : src/server/main.js 226 | 227 | unresolved imports : 2 228 | unused dependencies : 29 229 | unimported files : 86 230 | 231 | 232 | ─────┬────────────────────────────────────────── 233 | │ 2 unresolved imports 234 | ─────┼────────────────────────────────────────── 235 | 1 │ geojson 236 | 2 │ csstype 237 | ─────┴────────────────────────────────────────── 238 | 239 | 240 | ─────┬────────────────────────────────────────── 241 | │ 29 unused dependencies 242 | ─────┼────────────────────────────────────────── 243 | 1 │ @babel/polyfill 244 | 2 │ @babel/runtime 245 | .. │ ... 246 | ─────┴────────────────────────────────────────── 247 | 248 | 249 | ─────┬────────────────────────────────────────── 250 | │ 7 unimported files 251 | ─────┼────────────────────────────────────────── 252 | 1 │ src/common/components/Button/messages.ts 253 | 2 │ src/common/configs/sentry/graphql.js 254 | .. │ ... 255 | ─────┴────────────────────────────────────────── 256 | ``` 257 | 258 | ## How 259 | 260 | `Unimported` follows your import statements starting from one or more entry files. For nextjs projects, the entry files default to `pages/**`. For Meteor projects, the entry files are read from the `package.json#meteor.mainModule` key. Meteors eager loading is not supported, as that mode will load all files within your directory, regardless of import statements. 261 | 262 | For all other project types, the entry point is looked up in the following order: 263 | 264 | 1. `./package.json#source` 265 | 1. `./src/index` 266 | 1. `./src/main` 267 | 1. `./index` 268 | 1. `./main` 269 | 1. `./package.json#main` 270 | 271 | The last option is most likely never what you want, as the main field often points to a `dist` folder. Analyzing a bundled asset is likely to result in false positives. 272 | 273 | To specify custom entry points, add them to `.unimportedrc.json#entry`. 274 | 275 | **extensions** 276 | 277 | The resolver scans for files with the following extensions, in this specific order: 278 | 279 | 1. `.js` 280 | 1. `.jsx` 281 | 1. `.ts` 282 | 1. `.tsx` 283 | 284 | All other files are ignored. 285 | 286 | To specify custom extensions, add your own list to .unimportedrc.json#extensions`. Note that`unimported` won't merge settings! The custom list needs to be the full list of extension that you want to support. 287 | 288 | **ignored** 289 | 290 | Also ignored are files with paths matching the following patterns: 291 | 292 | ``` 293 | **/node_modules/** 294 | **/*.tests.{js,jsx,ts,tsx} 295 | **/*.spec.{js,jsx,ts,tsx} 296 | ``` 297 | 298 | In case `unimported` is running in a `Meteor` project, the following paths are being ignored as well: 299 | 300 | ``` 301 | packages/** 302 | public/** 303 | private/** 304 | tests/** 305 | ``` 306 | 307 | To specify custom ignore paths, add your own patterns to `.unimportedrc.json#ignorePatterns`. Note that `unimported` won't merge settings! The custom list needs to be the full list of patterns that you want to ignore. 308 | 309 | In addition `unimported` will also ignore files that match your `.gitignore` patterns. To disable this behavior, set `respectGitignore` to `false` in your `.unimportedrc.json` file. 310 | 311 | ```json 312 | { 313 | "respectGitignore": false 314 | } 315 | ``` 316 | 317 | ## Troubleshooting 318 | 319 | Common issues or known limitations of unimported. 320 | 321 | ### Export default 322 | 323 | At this moment, we don't support the `export default from './x'` export syntax. Parsing files that contain those exports, will result in an error with a message like `'\';\' expected`. If you make use of that part of the [export default from proposal](https://github.com/tc39/proposal-export-default-from#exporting-a-default-as-default), you can consider a find/replace before running `unimported`. 324 | 325 | Please search for: 326 | 327 | ```shell 328 | export default from 329 | ``` 330 | 331 | and replace it with 332 | 333 | ```shell 334 | export { default } from 335 | ``` 336 | 337 | ### Unexpected results / stale cache 338 | 339 | Please try [clearing the cache](#cache) if you have unexpected results, or keep getting the same results after changing the config file. 340 | 341 | ```shell 342 | npx unimported --clear-cache 343 | ``` 344 | 345 | ### TypeScript declaration files 346 | 347 | If you import declaration (`.d.ts`) files in a TypeScript project you will need to add it as an extension to `.unimportedrc.json`. Otherwise you will get "unresolved imports" warnings for imported declaration files. 348 | 349 | ```json 350 | { 351 | "extensions": [".ts", ".d.ts"] 352 | } 353 | ``` 354 | 355 | ## See Also 356 | 357 | - [depcheck](https://www.npmjs.com/depcheck) 358 | - [unrequired](https://npmjs.com/unrequired) 359 | - [trucker](https://npmjs.com/trucker) 360 | - [ts-unused-exports](https://www.npmjs.com/ts-unused-exports) 361 | - [no-unused-export](https://www.npmjs.com/no-unused-export) 362 | - [ts-prune](https://www.npmjs.com/ts-prune) 363 | 364 | ## License 365 | 366 | [![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](https://github.com/smeijer/unimported/blob/main/LICENSE) 367 | 368 | ## Contributors ✨ 369 | 370 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 |
Stephan Meijer
Stephan Meijer

🤔 💻 🚇 🚧
Punit Makwana
Punit Makwana

📖
Dane Wilson
Dane Wilson

💻
Michael Peyper
Michael Peyper

⚠️ 💻
Marco Moretti
Marco Moretti

⚠️
Peter Hozák
Peter Hozák

⚠️
Jacob M-G Evans
Jacob M-G Evans

⚠️
Datner
Datner

⚠️
Cody Rose
Cody Rose

⚠️
Ahmed ElDessouki
Ahmed ElDessouki

⚠️ 💻
Yago Pereira Azevedo
Yago Pereira Azevedo

⚠️
Juhana Jauhiainen
Juhana Jauhiainen

⚠️
Braydon Hall
Braydon Hall

⚠️
abeprincec
abeprincec

⚠️
Lucas Westermann
Lucas Westermann

💻 ⚠️
Simon Winter
Simon Winter

💻 ⚠️
Martin Šťovíček
Martin Šťovíček

📖
Kevin Decker
Kevin Decker

💻 ⚠️
olidacombe
olidacombe

💻
Punit Makwana
Punit Makwana

📖
Palanisamy KK
Palanisamy KK

📖
Daniel Bartholomae
Daniel Bartholomae

📖
Danil Gontovnik
Danil Gontovnik

💻
Nathan Smyth
Nathan Smyth

💻
Peter Sieg
Peter Sieg

💻 ⚠️
Joshua May
Joshua May

💻 ⚠️
Nathan Weber
Nathan Weber

💻
Wlad Paiva
Wlad Paiva

💻
Tanner B. Hess Webber
Tanner B. Hess Webber

💻
Tomas Fagerbekk
Tomas Fagerbekk

💻
Valerio Belli
Valerio Belli

💻
Umut Topuzoğlu
Umut Topuzoğlu

💻
slapbox
slapbox

📖
Michael
Michael

💻
Karl Horky
Karl Horky

📖
Aditya Vandan Sharma
Aditya Vandan Sharma

💻
Aru Hyunseung Jeon
Aru Hyunseung Jeon

💻
Eric Cornelissen
Eric Cornelissen

📖
Georget Julien
Georget Julien

💻
yu-yamanoi
yu-yamanoi

💻 ⚠️
Vantroy
Vantroy

💻
Lukasz Grela
Lukasz Grela

💻
Ryan Wilson-Perkin
Ryan Wilson-Perkin

💻 ⚠️
Riting LIU
Riting LIU

💻 ⚠️
Lukas Fritsch
Lukas Fritsch

💻 ⚠️
Mathias Van de Pol
Mathias Van de Pol

💻
Foucauld Degeorges
Foucauld Degeorges

💻
440 | 441 | 442 | 443 | 444 | 445 | 446 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 447 | -------------------------------------------------------------------------------- /src/__tests__/cli.ts: -------------------------------------------------------------------------------- 1 | import fs, { existsSync } from 'fs'; 2 | import path from 'path'; 3 | import util from 'util'; 4 | import cases from 'jest-in-case'; 5 | import simpleGit from 'simple-git'; 6 | import { main, CliArguments } from '..'; 7 | import { purgeCache } from '../cache'; 8 | 9 | import FileEntryCache from 'file-entry-cache'; 10 | import { __clearCachedConfig } from '../config'; 11 | import { createTestProject } from '../__utils__'; 12 | 13 | const rmdir = util.promisify(fs.rm); 14 | const readFile = util.promisify(fs.readFile); 15 | 16 | afterAll(() => { 17 | fs.rmSync('.test-space', { recursive: true }); 18 | }); 19 | 20 | jest.mock('simple-git'); 21 | 22 | jest.mock('file-entry-cache', () => { 23 | const actual = jest.requireActual('file-entry-cache'); 24 | let mockedCache: FileEntryCache.FileEntryCache; 25 | return { 26 | get mockedCache() { 27 | return mockedCache; 28 | }, 29 | create(...args) { 30 | mockedCache = actual.create(...args); 31 | mockedCache.removeEntry = jest.fn(mockedCache.removeEntry); 32 | return mockedCache; 33 | }, 34 | }; 35 | }); 36 | 37 | async function exec( 38 | testProjectDir: string, 39 | { 40 | init = false, 41 | flow = false, 42 | update = false, 43 | ignoreUntracked = false, 44 | cache = true, 45 | clearCache = false, 46 | showConfig = false, 47 | showUnresolvedImports = false, 48 | showUnusedFiles = false, 49 | showUnusedDeps = false, 50 | fix = false, 51 | }: Partial = {}, 52 | ): Promise<{ exitCode: number | null; stdout: string; stderr: string }> { 53 | const originalExit = process.exit; 54 | const originalCwd = process.cwd(); 55 | const originalConsole = { 56 | log: console.log, 57 | warn: console.warn, 58 | error: console.error, 59 | }; 60 | 61 | try { 62 | let exitCode: number | null = null; 63 | let stdout = ''; 64 | let stderr = ''; 65 | 66 | const appendStdout = (...args: any[]): void => { 67 | stdout += args.map((arg) => arg.toString()).join(' '); 68 | }; 69 | 70 | const appendStderr = (...args: any[]): void => { 71 | stderr += args.map((arg) => arg.toString()).join(' '); 72 | }; 73 | 74 | console.log = appendStdout; 75 | console.warn = appendStdout; 76 | console.error = appendStderr; 77 | 78 | process.exit = (code: number): never => { 79 | exitCode = exitCode ?? code; 80 | return undefined as never; 81 | }; 82 | 83 | process.chdir(testProjectDir); 84 | 85 | await main({ 86 | init, 87 | flow, 88 | update, 89 | ignoreUntracked, 90 | cache, 91 | clearCache, 92 | showConfig, 93 | showUnresolvedImports, 94 | showUnusedFiles, 95 | showUnusedDeps, 96 | fix, 97 | }); 98 | 99 | return { exitCode: exitCode ?? 0, stdout, stderr }; 100 | } finally { 101 | process.chdir(originalCwd); 102 | process.exit = originalExit; 103 | Object.entries(originalConsole).forEach(([key, value]) => { 104 | console[key] = value; 105 | }); 106 | } 107 | } 108 | 109 | beforeEach(() => { 110 | __clearCachedConfig(); 111 | purgeCache(); 112 | }); 113 | 114 | cases( 115 | 'cli integration tests', 116 | async (scenario) => { 117 | const testProjectDir = await createTestProject( 118 | scenario.files, 119 | scenario.baseDir, 120 | scenario.name, 121 | ); 122 | 123 | try { 124 | if (scenario.ignoreUntracked) { 125 | const status = jest.fn(async () => { 126 | return { not_added: scenario.untracked }; 127 | }); 128 | (simpleGit as jest.Mock).mockImplementationOnce(() => { 129 | return { 130 | status, 131 | }; 132 | }); 133 | } 134 | 135 | let { stdout, stderr, exitCode } = await exec(testProjectDir, { 136 | ignoreUntracked: scenario.ignoreUntracked, 137 | }); 138 | 139 | expect(stdout).toMatch(scenario.stdout || ''); 140 | expect(stderr).toMatch(scenario.stderr || ''); 141 | expect(exitCode).toBe(scenario.exitCode); 142 | 143 | // Exec again to test cache primed case 144 | if (scenario.ignoreUntracked) { 145 | const status = jest.fn(async () => { 146 | return { not_added: scenario.untracked }; 147 | }); 148 | (simpleGit as jest.Mock).mockImplementationOnce(() => { 149 | return { 150 | status, 151 | }; 152 | }); 153 | } 154 | 155 | ({ stdout, stderr, exitCode } = await exec(testProjectDir, { 156 | ignoreUntracked: scenario.ignoreUntracked, 157 | })); 158 | 159 | expect(stdout).toMatch(scenario.stdout || ''); 160 | expect(stderr).toMatch(scenario.stderr || ''); 161 | expect(exitCode).toBe(scenario.exitCode); 162 | } finally { 163 | await rmdir(testProjectDir, { recursive: true }); 164 | } 165 | }, 166 | [ 167 | { 168 | name: 'logs an error message when package.json cannot be located', 169 | files: [{ name: 'index.js', content: '' }], 170 | exitCode: 1, 171 | stderr: /could not resolve package.json, are you in a node project\?/s, 172 | }, 173 | { 174 | name: 'should identify unimported file', 175 | files: [ 176 | { name: 'package.json', content: '{ "main": "index.js" }' }, 177 | { name: 'index.js', content: `import foo from './foo';` }, 178 | { name: 'foo.js', content: '' }, 179 | { name: 'bar.js', content: '' }, 180 | ], 181 | exitCode: 1, 182 | stdout: /1 unimported files.*bar.js/s, 183 | }, 184 | { 185 | name: 'should ignore unimported file matching `.gitignore`', 186 | files: [ 187 | { name: 'package.json', content: '{ "main": "index.js" }' }, 188 | { name: 'index.js', content: `import foo from './foo';` }, 189 | { name: 'foo.js', content: '' }, 190 | { name: 'bar.js', content: '' }, 191 | { name: 'ignore.js', content: '' }, 192 | { name: '.gitignore', content: '**/ignore*' }, 193 | ], 194 | exitCode: 1, 195 | stdout: /1 unimported files.*bar.js/s, 196 | }, 197 | { 198 | name: 'should support JSON config', 199 | files: [ 200 | { name: 'package.json', content: '{}' }, 201 | { 202 | name: '.unimportedrc.json', 203 | content: '{ "entry": ["entry.js"] }', 204 | }, 205 | { name: 'entry.js', content: `import foo from './foo';` }, 206 | { name: 'foo.js', content: '' }, 207 | { name: 'bar.js', content: '' }, 208 | ], 209 | exitCode: 1, 210 | stdout: /1 unimported files.*bar.js/s, 211 | }, 212 | { 213 | name: 'should support JS config', 214 | files: [ 215 | { name: 'package.json', content: '{}' }, 216 | { 217 | name: '.unimportedrc.js', 218 | content: 'module.exports = { entry: ["entry.js"] }', 219 | }, 220 | { name: 'entry.js', content: `import foo from './foo';` }, 221 | { name: 'foo.js', content: '' }, 222 | { name: 'bar.js', content: '' }, 223 | ], 224 | exitCode: 1, 225 | stdout: /1 unimported files.*bar.js/s, 226 | }, 227 | { 228 | name: 'should support YML config', 229 | files: [ 230 | { name: 'package.json', content: '{}' }, 231 | { 232 | name: '.unimportedrc.yml', 233 | content: ` 234 | entry: 235 | - entry.js 236 | `, 237 | }, 238 | { name: 'entry.js', content: `import foo from './foo';` }, 239 | { name: 'foo.js', content: '' }, 240 | { name: 'bar.js', content: '' }, 241 | ], 242 | exitCode: 1, 243 | stdout: /1 unimported files.*bar.js/s, 244 | }, 245 | { 246 | name: 'should support package.json config', 247 | files: [ 248 | { 249 | name: 'package.json', 250 | content: '{ "unimported": { "entry": ["entry.js"] } }', 251 | }, 252 | { name: 'entry.js', content: `import foo from './foo';` }, 253 | { name: 'foo.js', content: '' }, 254 | { name: 'bar.js', content: '' }, 255 | ], 256 | exitCode: 1, 257 | stdout: /1 unimported files.*bar.js/s, 258 | }, 259 | { 260 | name: 'should identify unresolved imports', 261 | files: [ 262 | { name: 'package.json', content: '{ "main": "index.js" }' }, 263 | { name: 'index.js', content: `import foo from './foo';` }, 264 | ], 265 | exitCode: 1, 266 | stdout: /1 unresolved imports.*.\/foo/s, 267 | }, 268 | { 269 | name: 'should show the source of the unresolved import', 270 | files: [ 271 | { name: 'package.json', content: '{ "main": "index.js" }' }, 272 | { name: 'index.js', content: `import foo from './foo';` }, 273 | ], 274 | exitCode: 1, 275 | stdout: /\.\/foo.*at index\.js/s, 276 | }, 277 | { 278 | name: 'should ignore untracked files that are not imported', 279 | files: [ 280 | { name: 'package.json', content: '{ "main": "index.js" }' }, 281 | { name: 'index.js', content: `import foo from './foo';` }, 282 | { name: 'foo.js', content: '' }, 283 | { name: 'bar.js', content: '' }, 284 | ], 285 | exitCode: 0, 286 | stdout: /There don't seem to be any unimported files./, 287 | ignoreUntracked: true, 288 | untracked: ['bar.js'], 289 | }, 290 | { 291 | name: 'should identify unimported file in meteor project', 292 | files: [ 293 | { 294 | name: 'package.json', 295 | content: 296 | '{ "meteor" : { "mainModule": { "client": "client.js", "server": "server.js" } } }', 297 | }, 298 | { name: 'client.js', content: `import foo from './foo';` }, 299 | { name: 'server.js', content: '' }, 300 | { name: 'foo.js', content: '' }, 301 | { name: 'bar.js', content: '' }, 302 | ], 303 | exitCode: 1, 304 | stdout: /1 unimported files.*bar.js/s, 305 | }, 306 | { 307 | name: 'should work for vue files', 308 | files: [ 309 | { name: 'package.json', content: '{ "main" : "index.js" }' }, 310 | { name: 'dangling.js', content: '' }, 311 | { 312 | name: 'index.js', 313 | content: ` 314 | import './script.vue' 315 | import './script-ts.vue' 316 | import './script-src.vue' 317 | import './script-setup.vue'; 318 | import './script-setup-alongside-script.vue'; 319 | import './script-setup-ts.vue'; 320 | import './script-setup-ts-alongside-script'; 321 | `, 322 | }, 323 | { 324 | name: 'script.vue', 325 | content: ` 326 | 331 | 332 | 335 | `, 336 | }, 337 | { name: 'script-imported.js', content: '' }, 338 | { 339 | name: 'script-ts.vue', 340 | content: ` 341 | 344 | `, 345 | }, 346 | { name: 'script-ts-imported.js', content: '' }, 347 | { 348 | name: 'script-setup.vue', 349 | content: ` 350 | 353 | `, 354 | }, 355 | { name: 'script-setup-imported.js', content: '' }, 356 | { 357 | name: 'script-setup-alongside-script.vue', 358 | content: ` 359 | 362 | 365 | `, 366 | }, 367 | { name: 'script-setup-imported.js', content: '' }, 368 | { name: 'script-setup2-imported.js', content: '' }, 369 | { 370 | name: 'script-setup-ts.vue', 371 | content: ` 372 | 375 | `, 376 | }, 377 | { name: 'script-setup-ts-imported.js', content: '' }, 378 | { 379 | name: 'script-setup-ts-alongside-script.vue', 380 | content: ` 381 | 384 | 385 | 388 | `, 389 | }, 390 | { name: 'script-setup-ts-imported.js', content: '' }, 391 | { name: 'script-setup-ts2-imported.js', content: '' }, 392 | { 393 | name: 'script-src.vue', 394 | content: ` 395 | 396 | `, 397 | }, 398 | { 399 | name: 'script-src-imported.js', 400 | content: `import 'script-src-imported-content'`, 401 | }, 402 | { name: 'script-src-imported-content.js', content: '' }, 403 | { 404 | name: '.unimportedrc.json', 405 | content: '{ "extensions": [".js", ".vue"] }', 406 | }, 407 | ], 408 | exitCode: 1, 409 | stdout: /1 unimported files.*dangling.js/s, 410 | }, 411 | { 412 | name: 'should support flow type', 413 | files: [ 414 | { 415 | name: 'package.json', 416 | content: 417 | '{ "main" : "index.js", "devDependencies": { "flow-bin": "1" } }', 418 | }, 419 | { name: 'dangling.js', content: '' }, 420 | { 421 | name: 'index.js', 422 | content: ` 423 | // @flow 424 | import './imported'; 425 | import type myNumber from './exports-1'; 426 | import typeof { MyClass } from './exports-2'; 427 | import { type MyClass } from './exports-3'; 428 | import { typeof MyClass} from './exports-4'; 429 | `, 430 | }, 431 | { name: 'imported', content: '' }, 432 | { 433 | name: 'exports-1.js', 434 | content: ` 435 | // @flow 436 | const myNumber = 42; 437 | export default myNumber; 438 | export class MyClass { 439 | // ... 440 | } 441 | `, 442 | }, 443 | { name: 'exports-2.js', content: '' }, 444 | { name: 'exports-3.js', content: '' }, 445 | { name: 'exports-4.js', content: '' }, 446 | ], 447 | exitCode: 1, 448 | stdout: /1 unimported files.*dangling.js/s, 449 | }, 450 | { 451 | name: 'Invalid json', 452 | files: [ 453 | { 454 | name: '.unimportedrc.json', 455 | content: '{ "entry": ["index.js"} }', 456 | }, 457 | ], 458 | exitCode: 1, 459 | stdout: '', 460 | }, 461 | { 462 | name: 'next project', 463 | files: [ 464 | { 465 | name: 'package.json', 466 | content: 467 | '{ "main": "index.js", "dependencies": { "next": "1.0.0", "@test/dependency": "1.0.0" } }', 468 | }, 469 | { name: 'pages/index.js', content: `import foo from './foo';` }, 470 | { name: 'pages/foo.js', content: '' }, 471 | ], 472 | exitCode: 1, 473 | stdout: /1 unused dependencies.*@test\/dependency/s, 474 | }, 475 | { 476 | name: 'should identify unused dependencies', 477 | files: [ 478 | { 479 | name: 'package.json', 480 | content: 481 | '{ "main": "index.js", "dependencies": { "@test/dependency": "1.0.0" } }', 482 | }, 483 | { name: 'index.js', content: `import foo from './foo';` }, 484 | { name: 'foo.js', content: '' }, 485 | ], 486 | exitCode: 1, 487 | stdout: /1 unused dependencies.*@test\/dependency/s, 488 | }, 489 | { 490 | name: 'should not report issues when everything is used', 491 | files: [ 492 | { 493 | name: 'package.json', 494 | content: 495 | '{ "main": "index.js", "dependencies": { "@test/dependency": "1.0.0" } }', 496 | }, 497 | { 498 | name: 'index.js', 499 | content: ` 500 | import foo from './foo'; 501 | import bar from './bar'; 502 | `, 503 | }, 504 | { name: 'foo.js', content: '' }, 505 | { name: 'bar.js', content: 'import test from "@test/dependency"' }, 506 | ], 507 | exitCode: 0, 508 | stdout: /There don't seem to be any unimported files./, 509 | }, 510 | { 511 | name: 'should not report entry file loaded from config, as missing', 512 | files: [ 513 | { name: 'package.json', content: '{}' }, 514 | { 515 | name: '.unimportedrc.json', 516 | content: '{ "entry": ["index.js"] }', 517 | }, 518 | { 519 | name: 'index.js', 520 | content: `import a from './a'`, 521 | }, 522 | { 523 | name: 'a.js', 524 | content: `export default null`, 525 | }, 526 | ], 527 | exitCode: 0, 528 | stdout: /There don't seem to be any unimported files/, 529 | }, 530 | { 531 | name: 'should use all variants of import/export/require', 532 | files: [ 533 | { 534 | name: 'package.json', 535 | content: '{ "main": "index.js" }', 536 | }, 537 | { 538 | name: 'index.js', 539 | content: `import a from './a'`, 540 | }, 541 | { 542 | name: 'a.js', 543 | content: ` 544 | import {b as a} from './b' 545 | const promise = import('./d') 546 | const templatePromise = import(\`./e\`) 547 | const promiseAwaited = await import('./f') 548 | const templatePromiseAwaited = await import(\`./g\`) 549 | const required = require('./h') 550 | const templateRequired = require(\`./i\`) 551 | const requiredAwaited = await require('./j') 552 | const templateRequiredAwaited = await require(\`./k\`) 553 | export {a} 554 | export {b} from './b' 555 | export * from './c' 556 | export default promise 557 | `, 558 | }, 559 | { name: 'b.js', content: 'export const b = 2;' }, 560 | { name: 'c.js', content: 'const c = 3; export {c}' }, 561 | { name: 'd.js', content: 'export default 42' }, 562 | { name: 'e.js', content: 'export default 42' }, 563 | { name: 'f.js', content: 'export default 42' }, 564 | { name: 'g.js', content: 'export default 42' }, 565 | { name: 'h.js', content: 'export default 42' }, 566 | { name: 'i.js', content: 'export default 42' }, 567 | { name: 'j.js', content: 'export default 42' }, 568 | { name: 'k.js', content: 'export default 42' }, 569 | ], 570 | exitCode: 0, 571 | stdout: /There don't seem to be any unimported files./, 572 | }, 573 | { 574 | name: 'should identify ts paths imports', 575 | files: [ 576 | { name: 'package.json', content: '{ "main": "index.ts" }' }, 577 | { name: 'index.ts', content: `import foo from '@root/foo';` }, 578 | { 579 | name: 'foo.ts', 580 | content: ` 581 | class Foo extends Bar { 582 | override baz() {} 583 | } 584 | `, 585 | }, 586 | { name: 'bar.ts', content: '' }, 587 | { 588 | name: 'tsconfig.json', 589 | content: '{ "compilerOptions": { "paths": { "@root": ["."] } } }', 590 | }, 591 | ], 592 | exitCode: 1, 593 | stdout: /1 unimported files.*bar.ts/s, 594 | }, 595 | { 596 | name: 'should identify js paths imports', 597 | files: [ 598 | { name: 'package.json', content: '{ "main": "index.js" }' }, 599 | { name: 'index.js', content: `import foo from '@root/foo';` }, 600 | { 601 | name: 'foo.js', 602 | content: ` 603 | class Foo extends Bar { 604 | override baz() {} 605 | } 606 | `, 607 | }, 608 | { name: 'bar.js', content: '' }, 609 | { 610 | name: 'jsconfig.json', 611 | content: '{ "compilerOptions": { "paths": { "@root": ["."] } } }', 612 | }, 613 | ], 614 | exitCode: 1, 615 | stdout: /1 unimported files.*bar.js/s, 616 | }, 617 | { 618 | name: 'should identify config alias imports', 619 | files: [ 620 | { name: 'package.json', content: '{ "main": "index.ts" }' }, 621 | { name: 'index.ts', content: `import foo from '@root/foo';` }, 622 | { name: 'foo.ts', content: '' }, 623 | { name: 'bar.ts', content: '' }, 624 | { 625 | name: '.unimportedrc.json', 626 | content: '{ "aliases": { "@root": ["."] }, "rootDir": "/" }', 627 | }, 628 | ], 629 | exitCode: 1, 630 | stdout: /1 unimported files.*bar.ts/s, 631 | }, 632 | { 633 | name: 'should identify alias index imports', 634 | files: [ 635 | { name: 'package.json', content: '{ "main": "index.ts" }' }, 636 | { name: 'index.ts', content: `import { random } from '@helpers';` }, 637 | { name: 'helpers/index.ts', content: '' }, 638 | { 639 | name: '.unimportedrc.json', 640 | content: '{ "aliases": { "@helpers": ["./helpers"] } }', 641 | }, 642 | ], 643 | exitCode: 0, 644 | stdout: /There don't seem to be any unimported files./, 645 | }, 646 | { 647 | name: 'should identify alias file imports', 648 | files: [ 649 | { name: 'package.json', content: '{ "main": "index.ts" }' }, 650 | { name: 'index.ts', content: `import { random } from '@random';` }, 651 | { name: 'helpers/random.ts', content: '' }, 652 | { 653 | name: '.unimportedrc.json', 654 | content: '{ "aliases": { "@random": ["./helpers/random"] } }', 655 | }, 656 | ], 657 | exitCode: 0, 658 | stdout: /There don't seem to be any unimported files./, 659 | }, 660 | { 661 | name: 'should identify monorepo-type sibling modules', 662 | baseDir: 'packages/A', 663 | files: [ 664 | { 665 | name: 'packages/A/package.json', 666 | content: 667 | '{ "main": "index.js", "repository": { "directory": "path/goes/here" } }', 668 | }, 669 | { 670 | name: 'packages/A/index.js', 671 | content: `import foo from 'B/foo';`, 672 | }, 673 | { name: 'packages/B/foo.js', content: '' }, 674 | { name: 'packages/C/bar.js', content: '' }, 675 | ], 676 | exitCode: 0, 677 | stdout: /There don't seem to be any unimported files./, 678 | }, 679 | { 680 | name: 'should support rootDir config', 681 | files: [ 682 | { name: 'package.json', content: '{ "main": "src/index.ts" }' }, 683 | { name: 'src/index.ts', content: `import '/nested';` }, 684 | { 685 | name: 'src/nested/index.ts', 686 | content: `import foo from '/nested/foo';`, 687 | }, 688 | { name: 'src/nested/foo.ts', content: '' }, 689 | { name: 'src/nested/bar.ts', content: '' }, 690 | { 691 | name: '.unimportedrc.json', 692 | content: '{ "rootDir": "src" }', 693 | }, 694 | ], 695 | exitCode: 1, 696 | stdout: /1 unimported files.*bar.ts/s, 697 | }, 698 | { 699 | name: 'should support scannedDirs config', 700 | files: [ 701 | { name: 'package.json', content: '{ "main": "src/index.ts" }' }, 702 | { name: 'src/index.ts', content: `import './nested';` }, 703 | { 704 | name: 'src/nested/index.ts', 705 | content: `import foo from './foo';`, 706 | }, 707 | { name: 'src/nested/foo.ts', content: '' }, 708 | { name: 'non-src/nested/bar.ts', content: '' }, 709 | { 710 | name: '.unimportedrc.json', 711 | content: '{ "scannedDirs": ["./non-src"] }', 712 | }, 713 | ], 714 | exitCode: 1, 715 | stdout: /1 unimported files.*bar.ts/s, 716 | }, 717 | { 718 | name: 'should support module directories', 719 | files: [ 720 | { name: 'package.json', content: '{ "main": "index.js" }' }, 721 | { 722 | name: 'index.js', 723 | content: ` 724 | import foo from './foo'; 725 | import bar from 'bar'; 726 | `, 727 | }, 728 | { name: 'foo/index.js', content: '' }, 729 | { name: 'modules/bar.js', content: '' }, 730 | { 731 | name: '.unimportedrc.json', 732 | content: '{ "moduleDirectory": ["node_modules", "modules"] }', 733 | }, 734 | ], 735 | exitCode: 0, 736 | stdout: /There don't seem to be any unimported files./s, 737 | }, 738 | { 739 | name: 'should support root slash import', 740 | files: [ 741 | { name: 'package.json', content: '{ "main": "index.js" }' }, 742 | { name: 'index.js', content: `import foo from '/foo';` }, 743 | { name: 'foo/index.js', content: `import bar from '/bar';` }, 744 | { name: 'bar.js', content: '' }, 745 | ], 746 | exitCode: 0, 747 | stdout: /There don't seem to be any unimported files./s, 748 | }, 749 | { 750 | name: 'should support root slash import in meteor project', 751 | files: [ 752 | { 753 | name: 'package.json', 754 | content: 755 | '{ "meteor" : { "mainModule": { "client": "client.js", "server": "server.js" } } }', 756 | }, 757 | { name: 'client.js', content: `import foo from '/foo';` }, 758 | { name: 'server.js', content: '' }, 759 | { name: 'foo.js', content: '' }, 760 | ], 761 | exitCode: 0, 762 | stdout: /There don't seem to be any unimported files./s, 763 | }, 764 | { 765 | name: 'should support type imports for typescript projects', 766 | files: [ 767 | { name: 'package.json', content: '{ "main": "index.ts" }' }, 768 | { 769 | name: 'index.ts', 770 | content: `import foo from './foo'; import type { Bar } from './bar'`, 771 | }, 772 | { name: 'foo.ts', content: '' }, 773 | { name: 'bar.ts', content: '' }, 774 | ], 775 | exitCode: 0, 776 | stdout: /There don't seem to be any unimported files./s, 777 | }, 778 | { 779 | name: 'should report parse failure for invalid file', 780 | files: [ 781 | { name: 'package.json', content: '{ "main": "index.js" }' }, 782 | { name: 'index.js', content: `not valid` }, 783 | ], 784 | exitCode: 1, 785 | stderr: /Failed parsing.*\/index.js/s, 786 | }, 787 | { 788 | name: 'should ignore non import/require paths', 789 | files: [ 790 | { name: 'package.json', content: '{ "main": "index.js" }' }, 791 | { 792 | name: 'index.js', 793 | content: `import fs from 'fs'; const dependency = fs.readFileSync('some_path.js');`, 794 | }, 795 | ], 796 | exitCode: 0, 797 | stdout: '', 798 | }, 799 | { 800 | name: 'should not report unimported file which is in ignore file', 801 | files: [ 802 | { name: 'package.json', content: '{ "main": "index.js" }' }, 803 | { name: 'index.js', content: `import foo from './foo';` }, 804 | { 805 | name: '.unimportedrc.json', 806 | content: '{"ignoreUnimported": ["bar.js"]}', 807 | }, 808 | { name: 'foo.js', content: '' }, 809 | { name: 'bar.js', content: '' }, 810 | ], 811 | exitCode: 0, 812 | stdout: /There don't seem to be any unimported files./s, 813 | }, 814 | { 815 | name: 'should not report unused dependency which is in ignore file', 816 | files: [ 817 | { 818 | name: 'package.json', 819 | content: 820 | '{ "main": "index.js", "dependencies": { "@test/dependency": "1.0.0" } }', 821 | }, 822 | { name: 'index.js', content: `import foo from './foo';` }, 823 | { 824 | name: '.unimportedrc.json', 825 | content: '{"ignoreUnused": ["@test/dependency"]}', 826 | }, 827 | { name: 'foo.js', content: '' }, 828 | ], 829 | exitCode: 0, 830 | stdout: /There don't seem to be any unimported files./s, 831 | }, 832 | { 833 | name: 'should not report unresolved import which is in ignore file', 834 | files: [ 835 | { 836 | name: 'package.json', 837 | content: '{ "main": "index.js" }', 838 | }, 839 | { name: 'index.js', content: `import foo from './foo';` }, 840 | { 841 | name: '.unimportedrc.json', 842 | content: '{"ignoreUnresolved": [["./foo", ["index.js"]]]}', 843 | }, 844 | ], 845 | exitCode: 0, 846 | stdout: /There don't seem to be any unimported files./s, 847 | }, 848 | { 849 | name: 'should not report entry file as missing', 850 | files: [ 851 | { 852 | name: 'package.json', 853 | content: '{ "main": "index.js" }', 854 | }, 855 | ], 856 | exitCode: 1, 857 | stdout: '', 858 | }, 859 | { 860 | name: 'can work with glob patterns in config file', 861 | files: [ 862 | { name: 'package.json', content: '{}' }, 863 | { 864 | name: '.unimportedrc.json', 865 | content: `{ 866 | "entry": ["src/index.tsx", "src/**/*.test.{j,t}s"], 867 | "ignoreUnresolved": [], 868 | "ignoreUnimported": ["src/setup{Proxy,Tests}.js"], 869 | "ignoreUnused": [], 870 | "ignorePatterns": ["**/node_modules/**", "**/*.d.ts"], 871 | "respectGitignore": true 872 | }`, 873 | }, 874 | { name: 'src/index.tsx', content: `import './imported';` }, 875 | { name: 'src/imported.ts', content: 'export default null;' }, 876 | { 877 | name: 'src/__tests__/imported.test.js', 878 | content: `import proxy from '../setupProxy'`, 879 | }, 880 | { name: 'src/setupProxy.js', content: '' }, 881 | { name: 'src/setupTests.js', content: '' }, 882 | { name: 'node_module/module/lib.js', content: '' }, 883 | { name: 'src/global.d.ts', content: '' }, 884 | ], 885 | exitCode: 0, 886 | stdout: /There don't seem to be any unimported files./s, 887 | }, 888 | { 889 | name: 'supports alias overrides per entry point', 890 | files: [ 891 | { name: 'package.json', content: '{}' }, 892 | { 893 | name: '.unimportedrc.json', 894 | content: `{ 895 | "entry": [{ 896 | "file": "src/entry-client.js", 897 | "extend": { "aliases": { "create-api": ["./src/api/create-api-client"] } } 898 | }, { 899 | "file": "src/entry-server.js", 900 | "extend": { "aliases": { "create-api": ["./src/api/create-api-server"] } } 901 | }], 902 | "extensions": [".js"], 903 | "aliases": { "create-api": ["./src/api/create-api-server"] } 904 | }`, 905 | }, 906 | { name: 'src/entry-client.js', content: `import 'create-api';` }, 907 | { name: 'src/entry-server.js', content: `import 'create-api';` }, 908 | { name: 'src/api/create-api-client.js', content: `` }, 909 | { name: 'src/api/create-api-server.js', content: `` }, 910 | ], 911 | exitCode: 0, 912 | stdout: /There don't seem to be any unimported files./s, 913 | }, 914 | { 915 | name: 'supports extension overrides per entry point', 916 | files: [ 917 | { 918 | name: 'package.json', 919 | content: 920 | '{ "dependencies": { "@test/client": "1", "@test/server": "1" } }', 921 | }, 922 | { 923 | name: '.unimportedrc.json', 924 | content: `{ 925 | "entry": [{ 926 | "file": "src/entry.js", 927 | "label": "client", 928 | "extend": { "extensions": [".client.js"] } 929 | }, { 930 | "file": "src/entry.js", 931 | "label": "server", 932 | "extend": { "extensions": [".server.js"] } 933 | }], 934 | "extensions": [".js"] 935 | }`, 936 | }, 937 | { name: 'src/entry.js', content: `import './config';` }, 938 | { 939 | name: 'src/config.client.js', 940 | content: ` 941 | import '@test/client'; 942 | import './client-only'; 943 | import './shared'; 944 | `, 945 | }, 946 | { 947 | name: 'src/config.server.js', 948 | content: ` 949 | import '@test/server'; 950 | import './server-only'; 951 | import './shared'; 952 | `, 953 | }, 954 | { name: 'src/shared.js', content: '' }, 955 | { name: 'src/client-only.js', content: '' }, 956 | { name: 'src/server-only.js', content: '' }, 957 | ], 958 | exitCode: 0, 959 | stdout: /There don't seem to be any unimported files./s, 960 | }, 961 | { 962 | name: 'should evaluate pathTransforms', 963 | files: [ 964 | { name: 'package.json', content: '{ "main": "index.ts" }' }, 965 | { 966 | name: 'index.ts', 967 | content: `import { random } from './helpers/index.js';`, 968 | }, 969 | { name: 'helpers/index.ts', content: '' }, 970 | { 971 | name: '.unimportedrc.json', 972 | content: '{ "pathTransforms": { "(..+).js$": "$1.ts" } }', 973 | }, 974 | ], 975 | exitCode: 0, 976 | stdout: /There don't seem to be any unimported files./, 977 | }, 978 | { 979 | name: 'should evaluate assetExtensions', 980 | files: [ 981 | { name: 'package.json', content: '{ "main": "index.ts" }' }, 982 | { 983 | name: 'index.ts', 984 | content: `import { random } from './image.jpeg';`, 985 | }, 986 | { name: 'image.jpeg', content: '' }, 987 | { 988 | name: '.unimportedrc.json', 989 | content: '{ "assetExtensions": [".jpeg"] }', 990 | }, 991 | ], 992 | exitCode: 0, 993 | stdout: /There don't seem to be any unimported files./, 994 | }, 995 | { 996 | name: 'should detect unimported assets', 997 | files: [ 998 | { name: 'package.json', content: '{ "main": "index.ts" }' }, 999 | { 1000 | name: 'index.ts', 1001 | content: `import { random } from './image.jpeg';`, 1002 | }, 1003 | { name: 'image.jpeg', content: '' }, 1004 | { name: 'unimported.jpeg', content: '' }, 1005 | { 1006 | name: '.unimportedrc.json', 1007 | content: '{ "assetExtensions": [".jpeg"] }', 1008 | }, 1009 | ], 1010 | exitCode: 1, 1011 | stdout: /unimported\.jpeg/s, 1012 | }, 1013 | ], 1014 | ); 1015 | 1016 | // ---------------------------------------------------------------------------- 1017 | 1018 | cases( 1019 | 'cli integration tests with update option', 1020 | async (scenario) => { 1021 | const testProjectDir = await createTestProject(scenario.files); 1022 | const outputFile = path.join(testProjectDir, '.unimportedrc.json'); 1023 | 1024 | try { 1025 | const { exitCode } = await exec(testProjectDir, { update: true }); 1026 | 1027 | const outputFileContent = JSON.parse(await readFile(outputFile, 'utf-8')); 1028 | expect(scenario.output).toEqual(outputFileContent); 1029 | expect(exitCode).toBe(scenario.exitCode); 1030 | } finally { 1031 | await rmdir(testProjectDir, { recursive: true }); 1032 | } 1033 | }, 1034 | [ 1035 | { 1036 | name: 'should identify unimported file', 1037 | files: [ 1038 | { name: 'package.json', content: '{ "main": "index.js" }' }, 1039 | { name: 'index.js', content: `import foo from './foo';` }, 1040 | { name: 'foo.js', content: '' }, 1041 | { name: 'bar.js', content: '' }, 1042 | ], 1043 | exitCode: 0, 1044 | output: { 1045 | ignoreUnresolved: [], 1046 | ignoreUnimported: ['bar.js'], 1047 | ignoreUnused: [], 1048 | }, 1049 | }, 1050 | { 1051 | name: 'should identify unused dependencies', 1052 | files: [ 1053 | { 1054 | name: 'package.json', 1055 | content: 1056 | '{ "main": "index.js", "dependencies": { "@test/dependency": "1.0.0" } }', 1057 | }, 1058 | { name: 'index.js', content: `import foo from './foo';` }, 1059 | { name: 'foo.js', content: '' }, 1060 | ], 1061 | exitCode: 0, 1062 | output: { 1063 | ignoreUnresolved: [], 1064 | ignoreUnimported: [], 1065 | ignoreUnused: ['@test/dependency'], 1066 | }, 1067 | }, 1068 | { 1069 | name: 'should not ignore anything when everything is used', 1070 | files: [ 1071 | { 1072 | name: 'package.json', 1073 | content: 1074 | '{ "main": "index.js", "dependencies": { "@test/dependency": "1.0.0" } }', 1075 | }, 1076 | { 1077 | name: 'index.js', 1078 | content: ` 1079 | import foo from './foo'; 1080 | import bar from './bar'; 1081 | `, 1082 | }, 1083 | { name: 'foo.js', content: '' }, 1084 | { name: 'bar.js', content: 'import test from "@test/dependency"' }, 1085 | ], 1086 | exitCode: 0, 1087 | output: { 1088 | ignoreUnresolved: [], 1089 | ignoreUnimported: [], 1090 | ignoreUnused: [], 1091 | }, 1092 | }, 1093 | ], 1094 | ); 1095 | 1096 | // ---------------------------------------------------------------------------- 1097 | 1098 | cases( 1099 | 'cli integration tests with init option', 1100 | async (scenario) => { 1101 | const testProjectDir = await createTestProject(scenario.files); 1102 | const outputFile = path.join(testProjectDir, '.unimportedrc.json'); 1103 | 1104 | try { 1105 | const { exitCode } = await exec(testProjectDir, { init: true }); 1106 | 1107 | const outputFileContent = JSON.parse(await readFile(outputFile, 'utf-8')); 1108 | expect(scenario.output).toEqual(outputFileContent); 1109 | expect(exitCode).toBe(scenario.exitCode); 1110 | } finally { 1111 | await rmdir(testProjectDir, { recursive: true }); 1112 | } 1113 | }, 1114 | [ 1115 | { 1116 | name: 'should create default ignore file', 1117 | files: [ 1118 | { name: 'package.json', content: '{}' }, 1119 | { name: 'index.js', content: '' }, 1120 | ], 1121 | exitCode: 0, 1122 | output: { 1123 | ignorePatterns: [ 1124 | '**/node_modules/**', 1125 | '**/*.tests.{js,jsx,ts,tsx}', 1126 | '**/*.test.{js,jsx,ts,tsx}', 1127 | '**/*.spec.{js,jsx,ts,tsx}', 1128 | '**/tests/**', 1129 | '**/__tests__/**', 1130 | '**/*.d.ts', 1131 | ], 1132 | ignoreUnresolved: [], 1133 | ignoreUnimported: [], 1134 | ignoreUnused: [], 1135 | respectGitignore: true, 1136 | }, 1137 | }, 1138 | { 1139 | name: 'should create expected ignore file for meteor project', 1140 | files: [ 1141 | { 1142 | name: 'package.json', 1143 | content: 1144 | '{ "meteor": { "mainModule": { "client": "index.js", "server": "" } } }', 1145 | }, 1146 | { 1147 | name: 'index.js', 1148 | content: '', 1149 | }, 1150 | ], 1151 | exitCode: 0, 1152 | output: { 1153 | ignorePatterns: [ 1154 | '**/node_modules/**', 1155 | '**/*.tests.{js,jsx,ts,tsx}', 1156 | '**/*.test.{js,jsx,ts,tsx}', 1157 | '**/*.spec.{js,jsx,ts,tsx}', 1158 | '**/tests/**', 1159 | '**/__tests__/**', 1160 | '**/*.d.ts', 1161 | 'packages/**', 1162 | 'public/**', 1163 | 'private/**', 1164 | 'tests/**', 1165 | ], 1166 | ignoreUnresolved: [], 1167 | ignoreUnimported: [], 1168 | ignoreUnused: ['@babel/runtime', 'meteor-node-stubs'], 1169 | respectGitignore: true, 1170 | }, 1171 | }, 1172 | ], 1173 | ); 1174 | 1175 | cases( 1176 | 'cli integration tests with clear-cache option', 1177 | async (scenario) => { 1178 | const testProjectDir = await createTestProject(scenario.files); 1179 | const cachePath = path.resolve( 1180 | testProjectDir, 1181 | './node_modules/.cache/unimported', 1182 | ); 1183 | 1184 | try { 1185 | const { exitCode, stdout } = await exec(testProjectDir, { 1186 | clearCache: true, 1187 | }); 1188 | 1189 | const cacheExists = existsSync(cachePath); 1190 | expect(exitCode).toBe(scenario.exitCode); 1191 | expect(stdout).toBe(scenario.stdout); 1192 | expect(cacheExists).toBe(false); 1193 | } finally { 1194 | await rmdir(testProjectDir, { recursive: true }); 1195 | } 1196 | }, 1197 | [ 1198 | { 1199 | name: 'should remove cache and exit silently', 1200 | files: [ 1201 | { name: 'package.json', content: '{}' }, 1202 | { name: 'index.js', content: '' }, 1203 | { name: 'node_modules/.cache/unimported/cache-1', content: '' }, 1204 | ], 1205 | exitCode: 0, 1206 | stdout: '', 1207 | }, 1208 | ], 1209 | ); 1210 | 1211 | describe('cache', () => { 1212 | const files = [ 1213 | { name: 'package.json', content: '{ "main": "index.js" }' }, 1214 | { 1215 | name: 'index.js', 1216 | content: ` 1217 | import foo from './foo'; 1218 | import bar from './bar'; 1219 | `, 1220 | }, 1221 | { name: 'foo.js', content: 'import bar from "./bar"' }, 1222 | { name: 'bar.js', content: '' }, 1223 | ]; 1224 | 1225 | beforeEach(() => { 1226 | jest.clearAllMocks(); 1227 | }); 1228 | 1229 | it('should invalidate the cache on parse error', async () => { 1230 | const testProjectDir = await createTestProject(files); 1231 | 1232 | try { 1233 | let { stdout, stderr, exitCode } = await exec(testProjectDir); 1234 | 1235 | expect(stdout).toMatch(/There don't seem to be any unimported files./); 1236 | expect(stderr).toMatch(''); 1237 | expect(exitCode).toBe(0); 1238 | 1239 | fs.unlinkSync(path.join(testProjectDir, 'bar.js')); 1240 | 1241 | ({ stdout, stderr, exitCode } = await exec(testProjectDir, {})); 1242 | 1243 | expect(stdout).toMatch(/1 unresolved imports.*.\/bar/s); 1244 | expect(stderr).toMatch(''); 1245 | expect(exitCode).toBe(1); 1246 | 1247 | expect( 1248 | (FileEntryCache as any).mockedCache.removeEntry.mock.calls.map( 1249 | ([filePath]) => path.basename(filePath), 1250 | ), 1251 | ).toMatchInlineSnapshot(` 1252 | [ 1253 | "bar.js", 1254 | "bar.js", 1255 | "index.js", 1256 | "foo.js", 1257 | "foo.js", 1258 | "index.js", 1259 | ] 1260 | `); 1261 | } finally { 1262 | await rmdir(testProjectDir, { recursive: true }); 1263 | } 1264 | }); 1265 | 1266 | it('should recover from extension rename', async () => { 1267 | const testProjectDir = await createTestProject(files); 1268 | 1269 | try { 1270 | let { stdout, stderr, exitCode } = await exec(testProjectDir); 1271 | 1272 | expect(stdout).toMatch(/There don't seem to be any unimported files./); 1273 | expect(stderr).toMatch(''); 1274 | expect(exitCode).toBe(0); 1275 | 1276 | fs.renameSync( 1277 | path.join(testProjectDir, 'bar.js'), 1278 | path.join(testProjectDir, 'bar.ts'), 1279 | ); 1280 | 1281 | ({ stdout, stderr, exitCode } = await exec(testProjectDir, {})); 1282 | 1283 | expect(stdout).toMatch(/There don't seem to be any unimported files./); 1284 | expect(stderr).toMatch(''); 1285 | expect(exitCode).toBe(0); 1286 | 1287 | expect( 1288 | (FileEntryCache as any).mockedCache.removeEntry.mock.calls.map( 1289 | ([filePath]) => path.basename(filePath), 1290 | ), 1291 | ).toMatchInlineSnapshot(` 1292 | [ 1293 | "bar.js", 1294 | "bar.js", 1295 | "index.js", 1296 | "foo.js", 1297 | "foo.js", 1298 | "index.js", 1299 | ] 1300 | `); 1301 | } finally { 1302 | await rmdir(testProjectDir, { recursive: true }); 1303 | } 1304 | }); 1305 | }); 1306 | --------------------------------------------------------------------------------