├── .eslintignore ├── .prettierignore ├── .gitignore ├── .prettierrc ├── assets ├── failure.png └── screenshot.png ├── index.js ├── .npmignore ├── src ├── readdir │ └── index.js ├── reporter │ └── index.js ├── index.js └── resolver │ └── index.js ├── .eslintrc ├── .circleci └── config.yml ├── jest.js ├── __tests__ └── readdir │ └── index.js ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .npmrc 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /assets/failure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MatthieuLemoine/remnants/HEAD/assets/failure.png -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MatthieuLemoine/remnants/HEAD/assets/screenshot.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable */ 3 | require = require('esm')(module); 4 | module.exports = require('./src/index'); 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | __tests__ 3 | .circleci 4 | .git 5 | examples 6 | .eslintignore 7 | .eslintrc 8 | .gitignore 9 | .prettierignore 10 | .prettierrc 11 | assets 12 | jest.js 13 | -------------------------------------------------------------------------------- /src/readdir/index.js: -------------------------------------------------------------------------------- 1 | import deglob from 'deglob'; 2 | import { promisify } from 'util'; 3 | 4 | const readdir = promisify(deglob); 5 | 6 | export default (sources, exclude) => Promise.all( 7 | sources.map(directory => readdir('**/*', { 8 | cwd: directory, 9 | ignore: exclude, 10 | useGitIgnore: true, 11 | })), 12 | ); 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base"], 3 | "parserOptions": { 4 | "ecmaVersion": 9 5 | }, 6 | "env": { 7 | "jest": true, 8 | "node": true 9 | }, 10 | "rules": { 11 | "no-underscore-dangle": 0, 12 | "no-use-before-define": ["error", { "functions": false, "classes": true, "variables": true }], 13 | "import/no-dynamic-require": 0, 14 | "max-len": 0 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:10.11.0 6 | steps: 7 | - checkout 8 | - restore_cache: 9 | keys: 10 | - v1-dependencies-{{ checksum "package.json" }} 11 | - run: yarn install 12 | - save_cache: 13 | paths: 14 | - node_modules 15 | key: v1-dependencies-{{ checksum "package.json" }} 16 | - run: yarn lint 17 | - run: yarn pretty-check 18 | - run: yarn test 19 | -------------------------------------------------------------------------------- /jest.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable */ 3 | const jestRuntime = require('jest-runtime'); 4 | 5 | const oldexecModule = jestRuntime.prototype._execModule; 6 | 7 | jestRuntime.prototype._execModule = function _execModule(localModule, options) { 8 | // Do not apply esm to dependencies & test files to have access to jest globals 9 | if ( 10 | localModule.id.includes('node_modules') || 11 | localModule.id.includes('__tests__') 12 | ) { 13 | return oldexecModule.apply(this, [localModule, options]); 14 | } 15 | localModule.exports = require('esm')(localModule)(localModule.id); 16 | return localModule; 17 | }; 18 | 19 | cli = require('jest/bin/jest'); 20 | -------------------------------------------------------------------------------- /__tests__/readdir/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { default: readdir } = require('../../src/readdir'); 3 | 4 | const sourceDirectory = path.join(__dirname, '..', '..', 'src'); 5 | const sourceDirectories = [sourceDirectory]; 6 | 7 | describe('readdir', () => { 8 | test('should list all files recursively', async () => { 9 | const files = await readdir(sourceDirectories); 10 | expect(files[0].sort()).toEqual([ 11 | path.join(sourceDirectory, 'index.js'), 12 | path.join(sourceDirectory, 'readdir', 'index.js'), 13 | path.join(sourceDirectory, 'reporter', 'index.js'), 14 | path.join(sourceDirectory, 'resolver', 'index.js'), 15 | ]); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/reporter/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import chalk from 'chalk'; 3 | 4 | export const filesReport = ( 5 | projectRoot, 6 | sourceDirectories, 7 | filesByDirectory, 8 | ) => { 9 | const allFiles = filesByDirectory.reduce( 10 | (array, item) => array.concat(item), 11 | [], 12 | ); 13 | if (!allFiles.length) { 14 | process.stdout.write(chalk.green('\n✅ No unused source files found\n')); 15 | return; 16 | } 17 | process.stdout.write( 18 | chalk.red(`\n❌ ${allFiles.length} unused source files found.\n`), 19 | ); 20 | filesByDirectory.forEach((files, index) => { 21 | const directory = sourceDirectories[index]; 22 | const relative = path.relative(projectRoot, directory); 23 | process.stdout.write(chalk.blue(`\n● ${relative}\n`)); 24 | files.map(file => process.stdout.write( 25 | chalk.yellow(` • ${path.relative(directory, file)}\n`), 26 | )); 27 | }); 28 | }; 29 | 30 | export const dependenciesReport = (unusedDependencies) => { 31 | if (!unusedDependencies.length) { 32 | process.stdout.write(chalk.green('\n✅ No unused dependencies found.\n')); 33 | return; 34 | } 35 | process.stdout.write( 36 | chalk.red( 37 | `\n❌ ${unusedDependencies.length} unused dependencies found.\n\n`, 38 | ), 39 | ); 40 | unusedDependencies.forEach(dep => process.stdout.write(chalk.yellow(` • ${dep}\n`))); 41 | }; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remnants", 3 | "version": "1.3.0", 4 | "description": "Find unused files. Spot these residues, leftovers, relics of an ancient past.", 5 | "main": "index.js", 6 | "module": "src/index.js", 7 | "repository": "https://github.com/MatthieuLemoine/remnants", 8 | "author": "MatthieuLemoine", 9 | "license": "MIT", 10 | "bin": { 11 | "remnants": "index.js" 12 | }, 13 | "dependencies": { 14 | "chalk": "^2.4.1", 15 | "conductor": "^1.4.1", 16 | "deglob": "^3.1.0", 17 | "esm": "^3.0.84", 18 | "fs-extra": "^7.0.0", 19 | "ora": "^3.0.0", 20 | "yargs": "^12.0.2" 21 | }, 22 | "devDependencies": { 23 | "eslint": "^5.6.1", 24 | "eslint-config-airbnb-base": "^13.1.0", 25 | "eslint-plugin-import": "^2.14.0", 26 | "husky": "^1.1.1", 27 | "jest": "^23.6.0", 28 | "lint-staged": "^7.3.0", 29 | "prettier": "^1.14.3", 30 | "prettier-eslint-cli": "^4.7.1" 31 | }, 32 | "scripts": { 33 | "test": "./jest.js", 34 | "lint": "eslint .", 35 | "prettify": "prettier-eslint --write \"**/*.js*\" --list-different", 36 | "pretty-check": "prettier-eslint \"**/*.js*\" --list-different", 37 | "check-version": "node scripts/check-version.js" 38 | }, 39 | "husky": { 40 | "hooks": { 41 | "pre-commit": "lint-staged" 42 | } 43 | }, 44 | "lint-staged": { 45 | "linters": { 46 | "*.js": [ 47 | "prettier-eslint --write --config .prettierrc", 48 | "eslint --fix", 49 | "git add" 50 | ], 51 | "*.json": [ 52 | "prettier-eslint --write --config .prettierrc", 53 | "git add" 54 | ] 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import yargs from 'yargs'; 3 | import ora from 'ora'; 4 | import fs from 'fs-extra'; 5 | import chalk from 'chalk'; 6 | import { filesReport, dependenciesReport } from './reporter'; 7 | import resolve from './resolver'; 8 | import readdir from './readdir'; 9 | 10 | const { argv } = yargs.array('sourceDirectories').array('exclude'); 11 | 12 | const { 13 | projectRoot: relativeRoot = process.cwd(), 14 | sourceDirectories = [], 15 | remove, 16 | exclude = [], 17 | } = argv; 18 | 19 | const projectRoot = path.resolve(relativeRoot); 20 | const manifest = require(path.join(projectRoot, 'package.json')); 21 | 22 | if (!sourceDirectories.length) { 23 | process.stderr.write('Missing required argument --sourceDirectories\n'); 24 | process.exit(1); 25 | } 26 | 27 | const spinner = ora('Looking for remnants').start(); 28 | 29 | const { usedFiles, usedDependencies } = resolve(projectRoot); 30 | 31 | (async () => { 32 | const directories = await readdir(sourceDirectories, exclude); 33 | const unusedDependencies = [ 34 | ...Object.keys(manifest.dependencies || {}), 35 | ].filter(item => !usedDependencies[item]); 36 | const unusedFiles = directories.map(files => files.filter(filePath => !usedFiles[path.join(projectRoot, filePath)])); 37 | spinner.stop(); 38 | filesReport(projectRoot, sourceDirectories, unusedFiles); 39 | dependenciesReport(unusedDependencies); 40 | if (remove) { 41 | const filesToDelete = unusedFiles.reduce( 42 | (acc, files) => [...acc, ...files], 43 | [], 44 | ); 45 | await Promise.all(filesToDelete.map(file => fs.remove(file))); 46 | process.stdout.write( 47 | chalk.green(`\n🔥 ${filesToDelete.length} files deleted.\n`), 48 | ); 49 | } 50 | })(); 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remnants [![CircleCI](https://circleci.com/gh/MatthieuLemoine/remnants/tree/master.svg?style=svg)](https://circleci.com/gh/MatthieuLemoine/remnants/tree/master) 2 | 3 | Find unused files and dependencies. Spot these residues, leftovers, relics of an ancient past. 4 | 5 | And :fire: them. Death to legacy & dead code :skull: 6 | 7 | ## Is it for me ? 8 | 9 | :recycle: Did you recently refactor parts of your awesome project ? ✅ 10 | 11 | 🧓 Is your project so old (more than 2 months old) that you can't even remember why some files & dependencies exist ? ✅ 12 | 13 | 🏭 Is your project so bloated that you're afraid to delete a file ? ✅ 14 | 15 | **Remnants** find those relics for you so that you can :fire: them in peace. 16 | 17 | ## Universal 18 | 19 | Can be used with webpack, Metro, Rollup (& more) bundled projects but also good old unbundled Node projects. 20 | 21 | ## Install 22 | 23 | ``` 24 | yarn global add remnants 25 | or 26 | npm i -g remnants 27 | ``` 28 | 29 | ## Usage 30 | 31 | In your project directory 32 | 33 | ``` 34 | remnants --sourceDirectories src 35 | ``` 36 | 37 | `sourceDirectories` are the folders where you want **Remnants** to look for unused files. 38 | 39 | ## Example 40 | 41 | Running **Remnants** on itself 🤯 42 | 43 | ![screenshot](assets/failure.png) 44 | 45 | :scream: Look at these remnants! :rage: 46 | 47 | Let :fire: them all! 48 | 49 | ... 50 | 51 | Done ✅ 52 | 53 | ![screenshot](assets/screenshot.png) 54 | 55 | Yeah no unused files or dependencies :tada: 56 | 57 | Thanks **Remnants** ! 58 | 59 | ## Remove unused files 60 | 61 | ``` 62 | remnants --sourceDirectories src --remove 63 | ``` 64 | 65 | ## Advance usage 66 | 67 | ``` 68 | remnants --sourceDirectories src --sourceDirectories lib --projectRoot /Users/remnants/dev/awesome-project --exclude **/*@*x.png 69 | ``` 70 | 71 | ## Related 72 | 73 | If you're looking for a webpack plugin, give [unused-webpack-plugin](https://github.com/MatthieuLemoine/unused-webpack-plugin) a try. #shamelessplug 74 | -------------------------------------------------------------------------------- /src/resolver/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import { compose } from 'conductor'; 4 | 5 | const extensions = ['.js', '.json', '.graphql', '.jsx']; 6 | const imageExtensions = ['.png', '.jpg', '.jpeg']; 7 | const indexFiles = [ 8 | 'index.js', 9 | 'index.android.js', 10 | 'index.ios.js', 11 | 'index.web.js', 12 | ]; 13 | const importRegex = /from '(.*)'/g; 14 | const requireRegex = /require\('(.*)'\)/g; 15 | const commentRegex = /\/\/.*|\/\*.*\*\//g; 16 | const graphqlImportRegex = /#import "(.*)"/g; 17 | const usedFiles = {}; 18 | const usedDependencies = {}; 19 | 20 | const withExtensions = absolutePath => extensions.map(extension => `${absolutePath}${extension}`); 21 | const withIndex = absolutePath => indexFiles.map(file => path.join(absolutePath, file)); 22 | 23 | const resolve = sourcePath => (relativePath) => { 24 | const absolutePath = path.join(sourcePath, relativePath); 25 | let paths = []; 26 | // isFile 27 | try { 28 | fs.readdirSync(absolutePath); 29 | } catch (e) { 30 | paths.push(absolutePath); 31 | const ext = path.extname(absolutePath); 32 | if (imageExtensions.includes(ext)) { 33 | paths.push(absolutePath.replace(ext, `@2x${ext}`)); 34 | paths.push(absolutePath.replace(ext, `@3x${ext}`)); 35 | } 36 | } 37 | paths = [ 38 | ...paths, 39 | ...withExtensions(absolutePath), 40 | ...withIndex(absolutePath), 41 | ]; 42 | const founds = paths.filter(fs.existsSync); 43 | return founds; 44 | }; 45 | 46 | const findImports = filePaths => filePaths.map((filePath) => { 47 | if (usedFiles[filePath]) { 48 | return []; 49 | } 50 | usedFiles[filePath] = true; 51 | const content = fs 52 | .readFileSync(filePath, { encoding: 'utf8' }) 53 | .replace(commentRegex, ''); 54 | const founds = []; 55 | let found = importRegex.exec(content); 56 | while (found) { 57 | if (found[1][0] === '.') { 58 | founds.push(found[1]); 59 | } else { 60 | const splits = found[1].split('/'); 61 | usedDependencies[ 62 | found[1][0] === '@' ? splits.slice(0, 2).join('/') : splits[0] 63 | ] = true; 64 | } 65 | found = importRegex.exec(content); 66 | } 67 | found = requireRegex.exec(content); 68 | while (found) { 69 | if (found[1][0] === '.') { 70 | founds.push(found[1]); 71 | } else { 72 | const splits = found[1].split('/'); 73 | usedDependencies[ 74 | found[1][0] === '@' ? splits.slice(0, 2).join('/') : splits[0] 75 | ] = true; 76 | } 77 | found = requireRegex.exec(content); 78 | } 79 | found = graphqlImportRegex.exec(content); 80 | while (found) { 81 | founds.push(found[1]); 82 | found = graphqlImportRegex.exec(content); 83 | } 84 | return founds.map(resolveImports(path.dirname(filePath))); 85 | }); 86 | 87 | function resolveImports(dirname) { 88 | return compose( 89 | findImports, 90 | resolve(dirname), 91 | ); 92 | } 93 | 94 | export default (projectRoot) => { 95 | resolveImports(projectRoot)(''); 96 | return { 97 | usedFiles, 98 | usedDependencies, 99 | }; 100 | }; 101 | --------------------------------------------------------------------------------