├── .eslintignore ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── contributors.yml │ ├── stale.yml │ ├── test-and-publish.yml │ └── test.yml ├── .gitignore ├── README.md ├── integration ├── outfile.base ├── outfile_unusedInModules.base ├── package.json ├── test.sh ├── testproject │ ├── src │ │ ├── A.ts │ │ ├── B.ts │ │ ├── C.ts │ │ ├── D.ts │ │ ├── barrel │ │ │ ├── E.ts │ │ │ ├── F.ts │ │ │ └── index.ts │ │ ├── cities.ts │ │ ├── dynamic │ │ │ ├── fail.ts │ │ │ ├── index.ts │ │ │ └── succ.ts │ │ ├── export-from.ts │ │ ├── exportD.ts │ │ ├── ignore-next.ts │ │ ├── importD.ts │ │ ├── importE.ts │ │ ├── index.ts │ │ ├── internal-uses.ts │ │ ├── skipPattern │ │ │ ├── foo.skip.me.test.ts │ │ │ ├── foo.ts │ │ │ └── spread.skip.me.ts │ │ └── wildcard │ │ │ ├── b.ts │ │ │ ├── foo.ts │ │ │ └── index.ts │ └── tsconfig.json └── tsconfig.json ├── jest.config.js ├── package.json ├── src ├── analyzer.test.ts ├── analyzer.ts ├── configurator.test.ts ├── configurator.ts ├── constants.ts ├── index.ts ├── initializer.ts ├── present.test.ts ├── presenter.ts ├── runner.ts ├── state.test.ts ├── state.ts └── util │ ├── getModuleSourceFile.ts │ ├── getNodesOfKind.test.ts │ ├── getNodesOfKind.ts │ └── isDefinitelyUsedImport.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module", 7 | "ecmaFeatures": { 8 | "modules": true 9 | } 10 | }, 11 | "rules": { 12 | "no-undef": 0, 13 | "no-unused-vars": 0, 14 | "no-useless-constructor": 0 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: nadeesha 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. ... 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Optional: reference to the test project** 21 | I'd greatly appreciate if you could introduce the failure case to the [test project in this repo](https://github.com/nadeesha/ts-prune/tree/master/integration/testproject) and submit the patch or a link to a branch here. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/workflows/contributors.yml: -------------------------------------------------------------------------------- 1 | name: Add contributors 2 | on: 3 | schedule: 4 | - cron: '20 20 * * *' 5 | 6 | jobs: 7 | add-contributors: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: BobAnkh/add-contributors@master 12 | with: 13 | CONTRIBUTOR: '### Contributors' 14 | COLUMN_PER_ROW: '6' 15 | ACCESS_TOKEN: ${{secrets.GITHUB_TOKEN}} 16 | IMG_WIDTH: '100' 17 | FONT_SIZE: '14' 18 | PATH: '/README.md' 19 | COMMIT_MESSAGE: 'docs(README): update contributors' 20 | AVATAR_SHAPE: 'round' 21 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | # This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. 2 | # 3 | # You can adjust the behavior by modifying this file. 4 | # For more information, see: 5 | # https://github.com/actions/stale 6 | name: Mark stale issues and pull requests 7 | 8 | on: 9 | schedule: 10 | - cron: '17 13 * * *' 11 | 12 | jobs: 13 | stale: 14 | 15 | runs-on: ubuntu-latest 16 | permissions: 17 | issues: write 18 | pull-requests: write 19 | 20 | steps: 21 | - uses: actions/stale@v3 22 | with: 23 | repo-token: ${{ secrets.GITHUB_TOKEN }} 24 | stale-issue-message: 'Stale issue message' 25 | stale-pr-message: 'Stale pull request message' 26 | stale-issue-label: 'no-issue-activity' 27 | stale-pr-label: 'no-pr-activity' 28 | -------------------------------------------------------------------------------- /.github/workflows/test-and-publish.yml: -------------------------------------------------------------------------------- 1 | name: Test and Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | test: 10 | name: test 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Git Checkout 14 | uses: actions/checkout@v1 15 | - name: Setup node version 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: "12" 19 | - name: Install dependencies 20 | run: yarn install 21 | - name: Build typescript 22 | run: yarn build 23 | - name: Unit tests 24 | run: yarn test 25 | - name: Integration tests 26 | run: yarn test:integration 27 | - name: Publish to NPM 28 | if: github.ref == 'refs/heads/master' 29 | run: yarn semantic-release 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | NPM_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | test: 8 | name: test 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Git Checkout 12 | uses: actions/checkout@v1 13 | - name: Setup node version 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: "12" 17 | - name: Install dependencies 18 | run: yarn install 19 | - name: Build typescript 20 | run: yarn build 21 | - name: Unit tests 22 | run: yarn test 23 | - name: Integration tests 24 | run: yarn test:integration 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/ 2 | lib/ 3 | node_modules/ 4 | .DS_Store 5 | Thumbs.db 6 | .idea/ 7 | .vscode/ 8 | *.sublime-project 9 | *.sublime-workspace 10 | *.log 11 | package-lock.json 12 | .eslintcache 13 | integration/testproject/outfile # this will be generated by integration tests 14 | .wakatime-project 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build](https://img.shields.io/github/workflow/status/nadeesha/ts-prune/Run%20CI%20Pipeline) ![npm](https://img.shields.io/npm/dm/ts-prune) ![GitHub issues](https://img.shields.io/github/issues-raw/nadeesha/ts-prune) 2 | 3 | # 🚨 ts-prune is going into maintanence mode 4 | 5 | Please use [knip](https://github.com/webpro/knip) which carries on the spirit. 6 | 7 |
8 | More details 9 | 10 | I started ts-prune to find a sustainable way to detect unused exports in Typescript code. Due to the absence of native APIs that enable this, the best way forward was to consolidate a few hacks together that did this semi-elegantly for _most_ usecases. 11 | 12 | However, due to the popularity of ts-prune, it has absorbed more use cases, and complexity has bloated to the point that I'm no longer comfortable to add more features or do any other changes to the core system. 13 | 14 | The most important thing for ts-prune is to be backwards compatible and reliable for existing use cases. 15 | 16 | ## What will happen 17 | 18 | - Critical bug fixes 19 | - Patching vulnerabilities in third party code 20 | 21 | ## What will not happen 22 | 23 | - Entertaining feature requests 24 | - Accepting PRs for net new features of refactors 25 | 26 | ## Notes for the future 27 | 28 | - This is a feature Typescript should support natively, and each "hack" has a bunch of trade-offs. 29 | - Due to the sheer fragmentation of TS/JS ecosystem between frameworks, package managers etc a non-native solution will result in complexity bloat. 30 | - At this point, the maintainer has two choices 31 | 1. Aggresively defend against feature requests, changes and anger the open-source community 32 | 2. Accept complexity bloat, and dedicate time and energy for compaction 33 | 34 |
35 | 36 | # ts-prune 37 | 38 | Find potentially unused exports in your Typescript project with zero configuration. 39 | 40 | [![asciicast](https://asciinema.org/a/liQKNmkGkedCnyHuJzzgu7uDI.svg)](https://asciinema.org/a/liQKNmkGkedCnyHuJzzgu7uDI) [![Join the chat at https://gitter.im/ts-prune/community](https://badges.gitter.im/ts-prune/community.svg)](https://gitter.im/ts-prune/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 41 | 42 | ## Getting Started 43 | 44 | `ts-prune` exposes a cli that reads your tsconfig file and prints out all the unused exports in your source files. 45 | 46 | ### Installing 47 | 48 | Install ts-prune with yarn or npm 49 | 50 | ```sh 51 | # npm 52 | npm install ts-prune --save-dev 53 | # yarn 54 | yarn add -D ts-prune 55 | ``` 56 | 57 | ### Usage 58 | 59 | You can install it in your project and alias it to a npm script in package.json. 60 | 61 | ```json 62 | { 63 | "scripts": { 64 | "find-deadcode": "ts-prune" 65 | } 66 | } 67 | ``` 68 | 69 | If you want to run against different Typescript configuration than tsconfig.json: 70 | 71 | ```sh 72 | ts-prune -p tsconfig.dev.json 73 | ``` 74 | 75 | ### Examples 76 | 77 | - [gatsby-material-starter](https://github.com/Vagr9K/gatsby-material-starter/blob/bdeba4160319c1977c83ee90e035c7fe1bd1854c/themes/material/package.json#L147) 78 | - [DestinyItemManager](https://github.com/DestinyItemManager/DIM/blob/aeb43dd848b5137656e6f47812189a2beb970089/package.json#L26) 79 | 80 | ### Configuration 81 | 82 | ts-prune supports CLI and file configuration via [cosmiconfig](https://github.com/davidtheclark/cosmiconfig#usage) (all file formats are supported). 83 | 84 | #### Configuration options 85 | 86 | - `-p, --project` - __tsconfig.json__ path(`tsconfig.json` by default) 87 | - `-i, --ignore` - errors ignore RegExp pattern 88 | - `-e, --error` - return error code if unused exports are found 89 | - `-s, --skip` - skip these files when determining whether code is used. (For example, `.test.ts?` will stop ts-prune from considering an export in test file usages) 90 | - `-u, --unusedInModule` - skip files that are used in module (marked as `used in module`) 91 | 92 | CLI configuration options: 93 | 94 | ```bash 95 | ts-prune -p my-tsconfig.json -i my-component-ignore-patterns? 96 | ``` 97 | 98 | Configuration file example `.ts-prunerc`: 99 | 100 | ```json 101 | { 102 | "ignore": "my-component-ignore-patterns?" 103 | } 104 | ``` 105 | 106 | ### FAQ 107 | 108 | #### How do I get the count of unused exports? 109 | 110 | ```sh 111 | ts-prune | wc -l 112 | ``` 113 | 114 | #### How do I ignore a specific path? 115 | 116 | You can either, 117 | 118 | ##### 1. Use the `-i, --ignore` configuration option: 119 | 120 | ```sh 121 | ts-prune --ignore 'src/ignore-this-path' 122 | ``` 123 | 124 | ##### 2. Use `grep -v` to filter the output: 125 | 126 | ```sh 127 | ts-prune | grep -v src/ignore-this-path 128 | ``` 129 | 130 | #### How do I ignore multiple paths? 131 | 132 | You can either, 133 | 134 | ##### 1. Use the `-i, --ignore` configuration option: 135 | 136 | ```sh 137 | ts-prune --ignore 'src/ignore-this-path|src/also-ignore-this-path' 138 | ``` 139 | 140 | ##### 2. Use multiple `grep -v` to filter the output: 141 | 142 | ```sh 143 | ts-prune | grep -v src/ignore-this-path | grep -v src/also-ignore-this-path 144 | ``` 145 | 146 | #### How do I ignore a specific identifier? 147 | 148 | You can either, 149 | 150 | ##### 1. Prefix the export with `// ts-prune-ignore-next` 151 | 152 | ```ts 153 | // ts-prune-ignore-next 154 | export const thisNeedsIgnoring = foo; 155 | ``` 156 | 157 | ##### 2. Use `grep -v` to ignore a more widely used export name 158 | 159 | ```sh 160 | ts-prune | grep -v ignoreThisThroughoutMyCodebase 161 | ``` 162 | 163 | ### Acknowledgements 164 | 165 | - The excellent [ts-morph](https://github.com/dsherret/ts-morph) library. And [this gist](https://gist.github.com/dsherret/0bae87310ce24866ae22425af80a9864) by [@dsherret](https://github.com/dsherret). 166 | 167 | ### Contributors 168 | 169 | 170 | 171 | 178 | 185 | 192 | 199 | 206 | 213 | 214 | 215 | 222 | 229 | 236 | 243 | 250 | 257 | 258 | 259 | 266 | 273 | 280 | 287 | 294 | 301 | 302 | 303 | 310 | 317 | 324 | 331 | 338 | 345 | 346 | 347 | 354 | 355 |
172 | 173 | Nadeesha 174 |
175 | Nadeesha Cabral 176 |
177 |
179 | 180 | Snyk 181 |
182 | Snyk bot 183 |
184 |
186 | 187 | Dan 188 |
189 | Dan Vanderkam 190 |
191 |
193 | 194 | Josh 195 |
196 | Josh Goldberg ✨ 197 |
198 |
200 | 201 | Vitaly 202 |
203 | Vitaly Iegorov 204 |
205 |
207 | 208 | Amir 209 |
210 | Amir Arad 211 |
212 |
216 | 217 | Ashok 218 |
219 | Ashok Argent-Katwala 220 |
221 |
223 | 224 | Caleb 225 |
226 | Caleb Peterson 227 |
228 |
230 | 231 | David 232 |
233 | David Graham 234 |
235 |
237 | 238 | Davis 239 |
240 | Davis Ford 241 |
242 |
244 | 245 | Hugo 246 |
247 | Hugo Duprat 248 |
249 |
251 | 252 | Ivo 253 |
254 | Ivo Raisr 255 |
256 |
260 | 261 | Jacob 262 |
263 | Jacob Bandes-Storch 264 |
265 |
267 | 268 | Kristján 269 |
270 | Kristján Oddsson 271 |
272 |
274 | 275 | Mikhail 276 |
277 | Mikhail Belyaev 278 |
279 |
281 | 282 | Reece 283 |
284 | Reece Daniels 285 |
286 |
288 | 289 | Simon 290 |
291 | Simon Jang 292 |
293 |
295 | 296 | The 297 |
298 | The Gitter Badger 299 |
300 |
304 | 305 | Tim 306 |
307 | Tim Bodeit 308 |
309 |
311 | 312 | Tim 313 |
314 | Tim Saunders 315 |
316 |
318 | 319 | Torkel 320 |
321 | Torkel Rogstad 322 |
323 |
325 | 326 | Victor 327 |
328 | Victor Nogueira 329 |
330 |
332 | 333 | William 334 |
335 | William Candillon 336 |
337 |
339 | 340 | curtvict/ 341 |
342 | curtvict 343 |
344 |
348 | 349 | phiresky/ 350 |
351 | phiresky 352 |
353 |
356 | -------------------------------------------------------------------------------- /integration/outfile.base: -------------------------------------------------------------------------------- 1 | src/B.ts:9 - UnusedFooType 2 | src/C.ts:9 - default 3 | src/cities.ts:1 - sepehub 4 | src/cities.ts:2 - kuariob 5 | src/cities.ts:4 - femvacsah 6 | src/cities.ts:5 - sijelup 7 | src/export-from.ts:1 - foo1 8 | src/export-from.ts:1 - foo2 9 | src/internal-uses.ts:5 - usedInThisFile (used in module) 10 | src/internal-uses.ts:7 - thisOneIsUnused 11 | src/internal-uses.ts:9 - UsedInThisFile (used in module) 12 | src/internal-uses.ts:11 - Unused 13 | src/internal-uses.ts:13 - Row (used in module) 14 | src/internal-uses.ts:17 - UnusedProps 15 | src/dynamic/fail.ts:1 - foo 16 | src/dynamic/fail.ts:3 - bar 17 | src/skipPattern/foo.ts:3 - foo 18 | src/wildcard/foo.ts:5 - vUnused 19 | -------------------------------------------------------------------------------- /integration/outfile_unusedInModules.base: -------------------------------------------------------------------------------- 1 | src/B.ts:9 - UnusedFooType 2 | src/C.ts:9 - default 3 | src/cities.ts:1 - sepehub 4 | src/cities.ts:2 - kuariob 5 | src/cities.ts:4 - femvacsah 6 | src/cities.ts:5 - sijelup 7 | src/export-from.ts:undefined - foo1 8 | src/export-from.ts:undefined - foo2 9 | src/internal-uses.ts:7 - thisOneIsUnused 10 | src/internal-uses.ts:11 - Unused 11 | src/internal-uses.ts:17 - UnusedProps 12 | src/dynamic/fail.ts:1 - foo 13 | src/dynamic/fail.ts:3 - bar 14 | src/skipPattern/foo.ts:3 - foo 15 | src/wildcard/foo.ts:5 - vUnused 16 | -------------------------------------------------------------------------------- /integration/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT" 6 | } 7 | -------------------------------------------------------------------------------- /integration/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | STEP_COUNTER=0 4 | step () { 5 | STEP_COUNTER=$(($STEP_COUNTER + 1)) 6 | printf "\n$STEP_COUNTER. $1 \n" 7 | } 8 | 9 | step "Creating npm link to the current working tree" 10 | npm link 11 | 12 | step "Change to testproject dir" 13 | cd "$(dirname "$0")" 14 | cd testproject 15 | 16 | step "Linking ts-prune from step 1" 17 | npm link ts-prune 18 | 19 | step "Run ts-prune" 20 | ts-prune --skip "skip.me" | tee outfile 21 | 22 | step "Diff between outputs" 23 | DIFF=$(diff outfile ../outfile.base) 24 | EXIT_CODE=2 25 | if [ "$DIFF" != "" ] 26 | then 27 | echo "The output was not the same as the base" 28 | echo "---" 29 | diff outfile ../outfile.base 30 | echo "---" 31 | EXIT_CODE=1 32 | else 33 | echo "Everything seems to be match! 🎉" 34 | EXIT_CODE=0 35 | fi 36 | 37 | step "Run ts-prune with --unusedInModule option" 38 | ts-prune --skip "skip.me" --unusedInModule | tee outfile_unusedInModules 39 | 40 | step "Diff between outputs" 41 | DIFF=$(diff outfile_unusedInModules ../outfile_unusedInModules.base) 42 | EXIT_CODE=2 43 | if [ "$DIFF" != "" ] 44 | then 45 | echo "The output was not the same as the base" 46 | echo "---" 47 | diff outfile_unusedInModules ../outfile_unusedInModules.base 48 | echo "---" 49 | EXIT_CODE=1 50 | else 51 | echo "Everything seems to be match! 🎉" 52 | EXIT_CODE=0 53 | fi 54 | 55 | step "Test exit code with no error flag" 56 | if ! ts-prune > /dev/null; then 57 | echo "ts-prune with no error flag returned error" 58 | EXIT_CODE=1 59 | fi 60 | 61 | step "Test exit code with error flag" 62 | if ts-prune -e > /dev/null; then 63 | echo "ts-prune with error flag did not return error" 64 | EXIT_CODE=1 65 | fi 66 | 67 | step "Test exit code with invalid config path" 68 | if ts-prune -p ./tsconfig.nonexistens.json &> /dev/null; then 69 | echo "ts-prune with invalid config path didn't return error" 70 | EXIT_CODE=1 71 | fi 72 | 73 | step "Test exit code with relative config path" 74 | if ! ts-prune -p ./tsconfig.json > /dev/null; then 75 | echo "ts-prune with relative config path returned error" 76 | EXIT_CODE=1 77 | fi 78 | 79 | step "Test exit code with absolute config path" 80 | if ! ts-prune -p $(pwd)/tsconfig.json > /dev/null; then 81 | echo "ts-prune with absolute config path returned error" 82 | EXIT_CODE=1 83 | fi 84 | 85 | step "Cleanup" 86 | rm ../../package-lock.json # remnants of the npm link 87 | rm outfile # generated outfile 88 | rm outfile_unusedInModules # generated outfile 89 | 90 | echo "🏁" 91 | exit $EXIT_CODE 92 | -------------------------------------------------------------------------------- /integration/testproject/src/A.ts: -------------------------------------------------------------------------------- 1 | import B, { foo } from "./B"; 2 | import "./D"; 3 | import { foo as foos } from "./C"; 4 | import type { FooType } from "./B"; 5 | 6 | type BarType = FooType; 7 | 8 | console.log(foo, foos, B); 9 | -------------------------------------------------------------------------------- /integration/testproject/src/B.ts: -------------------------------------------------------------------------------- 1 | export const foo = () => { 2 | return 1; 3 | }; 4 | 5 | const bar = 2; 6 | 7 | // ts-prune-dont-ignore-next 8 | export type FooType = 1; 9 | export type UnusedFooType = 1; 10 | 11 | // ts-prune-ignore-next 12 | export const unusedButIgnored = 1; 13 | 14 | export default bar; 15 | -------------------------------------------------------------------------------- /integration/testproject/src/C.ts: -------------------------------------------------------------------------------- 1 | import { gusizga } from "./cities"; 2 | 3 | export const foo = () => { 4 | return gusizga; 5 | }; 6 | 7 | const bar = 3; 8 | 9 | export default bar; 10 | -------------------------------------------------------------------------------- /integration/testproject/src/D.ts: -------------------------------------------------------------------------------- 1 | export const foo1 = "foo"; 2 | export const foo2 = "foo"; 3 | -------------------------------------------------------------------------------- /integration/testproject/src/barrel/E.ts: -------------------------------------------------------------------------------- 1 | export type KualaOrLumpur = 'Kuala' | 'Lumpur'; 2 | -------------------------------------------------------------------------------- /integration/testproject/src/barrel/F.ts: -------------------------------------------------------------------------------- 1 | export const one = 1; 2 | export const two = 2; -------------------------------------------------------------------------------- /integration/testproject/src/barrel/index.ts: -------------------------------------------------------------------------------- 1 | export { KualaOrLumpur } from './E'; 2 | export * from "./F"; -------------------------------------------------------------------------------- /integration/testproject/src/cities.ts: -------------------------------------------------------------------------------- 1 | export const sepehub = { population: 1706074909 }; 2 | export const kuariob = { population: 1513066561 }; 3 | export const gusizga = { population: 668741163 }; 4 | export const femvacsah = { population: 2667588536 }; 5 | export const sijelup = { population: 641437488 }; 6 | -------------------------------------------------------------------------------- /integration/testproject/src/dynamic/fail.ts: -------------------------------------------------------------------------------- 1 | export function foo() {} 2 | 3 | export function bar() {} 4 | -------------------------------------------------------------------------------- /integration/testproject/src/dynamic/index.ts: -------------------------------------------------------------------------------- 1 | 2 | async function test1() { 3 | const mything = await import("./succ"); 4 | console.log(mything); 5 | } 6 | 7 | async function test2() { 8 | // won't work for dynamic strings obviously 9 | const mything = await import(`${"./fail"}`); 10 | console.log(mything); 11 | } -------------------------------------------------------------------------------- /integration/testproject/src/dynamic/succ.ts: -------------------------------------------------------------------------------- 1 | export function foo() {} 2 | 3 | export function bar() {} 4 | -------------------------------------------------------------------------------- /integration/testproject/src/export-from.ts: -------------------------------------------------------------------------------- 1 | export * from "./D"; 2 | -------------------------------------------------------------------------------- /integration/testproject/src/exportD.ts: -------------------------------------------------------------------------------- 1 | export const D0 = "D"; 2 | export const D1 = "D"; 3 | export const D2 = "D"; 4 | -------------------------------------------------------------------------------- /integration/testproject/src/ignore-next.ts: -------------------------------------------------------------------------------- 1 | const sijelup2 = { population: 641437488 }; 2 | 3 | // ts-prune-ignore-next 4 | export const mustIgnore = "foo"; 5 | 6 | // ts-prune-ignore-next 7 | export default sijelup2; -------------------------------------------------------------------------------- /integration/testproject/src/importD.ts: -------------------------------------------------------------------------------- 1 | import * as D from "./exportD"; 2 | 3 | const foo = () => console.log(D); 4 | -------------------------------------------------------------------------------- /integration/testproject/src/importE.ts: -------------------------------------------------------------------------------- 1 | import * as barrel from './barrel'; 2 | 3 | barrel.one 4 | barrel.two 5 | type T = barrel.KualaOrLumpur; 6 | -------------------------------------------------------------------------------- /integration/testproject/src/index.ts: -------------------------------------------------------------------------------- 1 | export const libExport = "foo"; -------------------------------------------------------------------------------- /integration/testproject/src/internal-uses.ts: -------------------------------------------------------------------------------- 1 | // See https://github.com/nadeesha/ts-prune/issues/38 2 | 3 | // This is exported but never imported. 4 | // However, it is used in this file, so it's not dead code. 5 | export const usedInThisFile = {}; 6 | 7 | export const thisOneIsUnused = {...usedInThisFile}; 8 | 9 | export interface UsedInThisFile {} 10 | 11 | export interface Unused extends UsedInThisFile {} 12 | 13 | export interface Row { 14 | [column: string]: number; 15 | } 16 | 17 | export interface UnusedProps { 18 | rows: readonly Row[]; 19 | } 20 | -------------------------------------------------------------------------------- /integration/testproject/src/skipPattern/foo.skip.me.test.ts: -------------------------------------------------------------------------------- 1 | import { foo } from "./foo"; 2 | 3 | describe("foo", () => { 4 | it("should return false", () => { 5 | expect(foo()).toBeFalsy; 6 | }) 7 | }) 8 | -------------------------------------------------------------------------------- /integration/testproject/src/skipPattern/foo.ts: -------------------------------------------------------------------------------- 1 | import * as spread from "./spread.skip.me"; 2 | 3 | export const foo = () => spread.spreadUsed; 4 | -------------------------------------------------------------------------------- /integration/testproject/src/skipPattern/spread.skip.me.ts: -------------------------------------------------------------------------------- 1 | export const spreadUnused = true; 2 | export const spreadUsed = true; 3 | -------------------------------------------------------------------------------- /integration/testproject/src/wildcard/b.ts: -------------------------------------------------------------------------------- 1 | export const a = 'a'; 2 | export const b = 'b'; 3 | export const cUnused = 'c'; 4 | -------------------------------------------------------------------------------- /integration/testproject/src/wildcard/foo.ts: -------------------------------------------------------------------------------- 1 | export const x = 'x'; 2 | export const y = 'y'; 3 | export const z = {a: 'a'}; 4 | export const w = 'w'; 5 | export const vUnused = 'v'; 6 | 7 | export type UsedInIndex = 'x' | 'y' | 'z'; 8 | -------------------------------------------------------------------------------- /integration/testproject/src/wildcard/index.ts: -------------------------------------------------------------------------------- 1 | import * as foo from './foo'; 2 | import * as b from './b'; 3 | 4 | const x = foo.x; 5 | const {y} = foo; 6 | const {z: {a}} = foo; 7 | const w = foo['w']; 8 | 9 | console.log(x, y, a, w); 10 | console.log(b[Math.random() < 0.5 ? 'a' : 'b']); 11 | 12 | function f(x: foo.UsedInIndex) { 13 | console.log(x); 14 | } 15 | 16 | f('x'); 17 | -------------------------------------------------------------------------------- /integration/testproject/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./lib", // this is a comment because tsconfig.json can have comments 4 | "declaration": true, 5 | "target": "es5", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "strict": true, 9 | "noImplicitReturns": false, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": false, 12 | "noFallthroughCasesInSwitch": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "lib": [ 15 | "es6" 16 | ] 17 | }, 18 | "include": [ 19 | "./src/**/*" 20 | ], 21 | "files": [ 22 | "./src/index.ts" 23 | ] 24 | } -------------------------------------------------------------------------------- /integration/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "noImplicitAny": true, 5 | "removeComments": true, 6 | "preserveConstEnums": true, 7 | "outDir": "lib", 8 | "sourceMap": true, 9 | "lib": ["es2017", "dom"] 10 | }, 11 | "include": ["src/**/*"], 12 | "exclude": ["node_modules", "**/*.spec.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node" 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-prune", 3 | "version": "0.10.4", 4 | "description": "Find potentially unused exports in your Typescript project with zero configuration.", 5 | "keywords": [ 6 | "find", 7 | "unused", 8 | "exports", 9 | "deadcode" 10 | ], 11 | "main": "lib/index.js", 12 | "author": "Nadeesha Cabral ", 13 | "license": "MIT", 14 | "scripts": { 15 | "build": "tsc", 16 | "prepublish": "rm -rf lib && yarn build", 17 | "pretest": "npm run lint", 18 | "test": "jest --coverage", 19 | "test:integration": "sh integration/test.sh", 20 | "lint": "eslint . --cache --fix --ext .ts,.tsx", 21 | "semantic-release": "semantic-release" 22 | }, 23 | "devDependencies": { 24 | "@types/jest": "^25.2.1", 25 | "@types/json5": "^0.0.30", 26 | "@types/lodash": "^4.14.150", 27 | "@types/node": "^13.13.1", 28 | "@typescript-eslint/parser": "^2.29.0", 29 | "eslint": "^6.8.0", 30 | "eslint-config-prettier": "^6.11.0", 31 | "jest": "^25.4.0", 32 | "prettier": "^2.0.5", 33 | "semantic-release": "^21.0.1", 34 | "ts-jest": "^25.4.0", 35 | "ts-node": "^8.9.0", 36 | "typescript": "^4.3.2" 37 | }, 38 | "dependencies": { 39 | "chalk": "4.1.2", 40 | "commander": "^6.2.1", 41 | "cosmiconfig": "^8.1.3", 42 | "json5": "^2.1.3", 43 | "lodash": "^4.17.21", 44 | "true-myth": "^4.1.0", 45 | "ts-morph": "^13.0.1" 46 | }, 47 | "files": [ 48 | "/lib" 49 | ], 50 | "bin": "./lib/index.js", 51 | "repository": "git@github.com:nadeesha/ts-prune.git", 52 | "release": { 53 | "branches": [ 54 | "master" 55 | ] 56 | }, 57 | "types": "lib/index.d.ts" 58 | } 59 | -------------------------------------------------------------------------------- /src/analyzer.test.ts: -------------------------------------------------------------------------------- 1 | import { Project, ts } from "ts-morph"; 2 | import { 3 | getExported, 4 | getPotentiallyUnused, 5 | importsForSideEffects, 6 | trackWildcardUses, 7 | } from "./analyzer"; 8 | 9 | const fooSrc = ` 10 | export const x = 'x'; 11 | export const y = 'y'; 12 | export const z = {a: 'a'}; 13 | export const w = 'w'; 14 | export type ABC = 'a' | 'b' | 'c'; 15 | 16 | export const unusedC = 'c'; 17 | export type UnusedT = 'T'; 18 | `; 19 | 20 | const starExportSrc = ` 21 | export * from './foo'; 22 | `; 23 | 24 | const starImportSrc = ` 25 | import * as foo from './foo'; 26 | import {UseFoo} from './use-foo'; 27 | import {x,y,z,w,ABC} from './starExport'; 28 | 29 | const x = foo.x; 30 | const {y} = foo; 31 | const {z: {a}} = foo; 32 | const w = foo['w']; 33 | type ABC = foo.ABC; 34 | `; 35 | 36 | const useFooSrc = ` 37 | export function UseFoo(foo: string) { 38 | alert(foo); 39 | } 40 | `; 41 | 42 | const barSrc = ` 43 | export const bar = () => false; 44 | `; 45 | 46 | const testBarSrc = ` 47 | import { bar } from './bar'; 48 | 49 | describe("bar", () => { 50 | it("should return false", () => { 51 | expect(bar()).toBe.toBeFalsy; 52 | }); 53 | }); 54 | `; 55 | 56 | describe("analyzer", () => { 57 | const project = new Project(); 58 | const foo = project.createSourceFile("/project/foo.ts", fooSrc); 59 | const useFoo = project.createSourceFile("/project/use-foo.ts", useFooSrc); 60 | const star = project.createSourceFile("/project/star.ts", starImportSrc); 61 | const bar = project.createSourceFile("/project/bar.ts", barSrc); 62 | const testBar = project.createSourceFile("/project/bar.test.ts", testBarSrc); 63 | const starExport = project.createSourceFile("/project/starExport.ts", starExportSrc); 64 | 65 | it("should track import wildcards", () => { 66 | // TODO(danvk): rename this to importSideEffects() 67 | expect(importsForSideEffects(star)).toEqual([]); 68 | }); 69 | 70 | it("should track named exports", () => { 71 | expect(getExported(foo)).toEqual([ 72 | { name: "x", line: 2 }, 73 | { name: "y", line: 3 }, 74 | { name: "z", line: 4 }, 75 | { name: "w", line: 5 }, 76 | { name: "ABC", line: 6 }, 77 | { name: "unusedC", line: 8 }, 78 | { name: "UnusedT", line: 9 }, 79 | ]); 80 | 81 | expect(getExported(useFoo)).toEqual([{ name: "UseFoo", line: 2 }]); 82 | }); 83 | 84 | it("should track named imports", () => { 85 | expect(getPotentiallyUnused(foo)).toEqual({ 86 | file: "/project/foo.ts", 87 | symbols: [ 88 | { line: 8, name: "unusedC", usedInModule: false }, 89 | { line: 9, name: "UnusedT", usedInModule: false }, 90 | ], 91 | type: 0, 92 | }); 93 | }); 94 | 95 | it("should not skip source files without a pattern", () => { 96 | // while bar.test.ts is included, bar is used 97 | expect(getPotentiallyUnused(bar)).toEqual({ 98 | file: "/project/bar.ts", 99 | symbols: [], 100 | type: 0, 101 | }); 102 | }); 103 | 104 | it("should skip source files matching a pattern", () => { 105 | // when bar.test.ts is exclude by the skip pattern, bar is unused 106 | expect(getPotentiallyUnused(bar, /.test.ts/)).toEqual({ 107 | file: "/project/bar.ts", 108 | symbols: [ 109 | { line: 2, name: "bar", usedInModule: false }, 110 | ], 111 | type: 0, 112 | }); 113 | }); 114 | 115 | it("should use line number of 'export * from' rather than line number of original export", () => { 116 | expect(getPotentiallyUnused(starExport)).toEqual({ 117 | file: "/project/starExport.ts", 118 | symbols: [ 119 | { name: "unusedC", line: 2, usedInModule:false }, 120 | { name: "UnusedT", line: 2, usedInModule:false }, 121 | ], 122 | type: 0, 123 | }); 124 | }); 125 | 126 | it("should track usage through star imports", () => { 127 | const importNode = star.getFirstDescendantByKindOrThrow( 128 | ts.SyntaxKind.ImportDeclaration 129 | ); 130 | 131 | expect(trackWildcardUses(importNode)).toEqual(["x", "y", "z", "w", "ABC"]); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /src/analyzer.ts: -------------------------------------------------------------------------------- 1 | import { ignoreComment } from "./constants"; 2 | import { 3 | ExportDeclaration, 4 | ImportDeclaration, 5 | Project, 6 | SourceFile, 7 | SourceFileReferencingNodes, 8 | ts, 9 | Symbol, 10 | SyntaxKind, 11 | StringLiteral, 12 | ObjectBindingPattern, 13 | } from "ts-morph"; 14 | import { isDefinitelyUsedImport } from "./util/isDefinitelyUsedImport"; 15 | import { getModuleSourceFile } from "./util/getModuleSourceFile"; 16 | import { getNodesOfKind } from './util/getNodesOfKind'; 17 | import countBy from "lodash/fp/countBy"; 18 | import last from "lodash/fp/last"; 19 | import { realpathSync } from "fs"; 20 | import { IConfigInterface } from "./configurator"; 21 | 22 | type OnResultType = (result: IAnalysedResult) => void; 23 | 24 | export enum AnalysisResultTypeEnum { 25 | POTENTIALLY_UNUSED, 26 | DEFINITELY_USED 27 | } 28 | 29 | export type ResultSymbol = { 30 | name: string; 31 | line?: number; 32 | usedInModule: boolean; 33 | }; 34 | 35 | export type IAnalysedResult = { 36 | file: string; 37 | type: AnalysisResultTypeEnum; 38 | symbols: ResultSymbol[]; 39 | } 40 | 41 | function handleExportDeclaration(node: SourceFileReferencingNodes) { 42 | return (node as ExportDeclaration).getNamedExports().map(n => n.getName()); 43 | } 44 | 45 | function handleImportDeclaration(node: ImportDeclaration) { 46 | return ( 47 | [ 48 | ...node.getNamedImports().map(n => n.getName()), 49 | ...(node.getDefaultImport() ? ['default'] : []), 50 | ...(node.getNamespaceImport() ? trackWildcardUses(node) : []), 51 | ] 52 | ); 53 | } 54 | 55 | /** 56 | * Given an `import * as foo from './foo'` import, figure out which symbols in foo are used. 57 | * 58 | * If there are uses which cannot be tracked, this returns ["*"]. 59 | */ 60 | export const trackWildcardUses = (node: ImportDeclaration) => { 61 | const clause = node.getImportClause(); 62 | const namespaceImport = clause.getFirstChildByKind(ts.SyntaxKind.NamespaceImport); 63 | const source = node.getSourceFile(); 64 | 65 | const uses = getNodesOfKind(source, ts.SyntaxKind.Identifier) 66 | .filter(n => (n.getSymbol()?.getDeclarations() ?? []).includes(namespaceImport)); 67 | 68 | const symbols: string[] = []; 69 | for (const use of uses) { 70 | if (use.getParentIfKind(SyntaxKind.NamespaceImport)) { 71 | // This is the "import * as module" line. 72 | continue; 73 | } 74 | 75 | const p = use.getParentIfKind(SyntaxKind.PropertyAccessExpression); 76 | if (p) { 77 | // e.g. `module.x` 78 | symbols.push(p.getName()); 79 | continue; 80 | } 81 | 82 | const el = use.getParentIfKind(SyntaxKind.ElementAccessExpression); 83 | if (el) { 84 | const arg = el.getArgumentExpression(); 85 | if (arg.getKind() === SyntaxKind.StringLiteral) { 86 | // e.g. `module['x']` 87 | symbols.push((arg as StringLiteral).getLiteralText()); 88 | continue; 89 | } 90 | } 91 | 92 | const varExp = use.getParentIfKind(SyntaxKind.VariableDeclaration); 93 | if (varExp) { 94 | const nameNode = varExp.getNameNode(); 95 | if (nameNode.getKind() === SyntaxKind.ObjectBindingPattern) { 96 | const binder = (nameNode as ObjectBindingPattern); 97 | for (const bindEl of binder.getElements()) { 98 | const p = bindEl.getPropertyNameNode(); 99 | if (p) { 100 | // e.g. const {z: {a}} = module; 101 | symbols.push(p.getText()); 102 | } else { 103 | // e.g. const {x} = module; 104 | symbols.push(bindEl.getName()); 105 | } 106 | } 107 | continue; 108 | } 109 | } 110 | 111 | const qualExp = use.getParentIfKind(SyntaxKind.QualifiedName); 112 | if (qualExp) { 113 | // e.g. type T = module.TypeName; 114 | symbols.push(qualExp.getRight().getText()); 115 | continue; 116 | } 117 | 118 | // If we don't understand a use, be conservative. 119 | return ['*']; 120 | } 121 | 122 | return symbols; 123 | }; 124 | 125 | // like import("../xyz") 126 | function handleDynamicImport(node: SourceFileReferencingNodes) { 127 | // a dynamic import always imports all elements, so we can't tell if only some are used 128 | return ["*"]; 129 | } 130 | 131 | const nodeHandlers = { 132 | [ts.SyntaxKind.ExportDeclaration.toString()]: handleExportDeclaration, 133 | [ts.SyntaxKind.ImportDeclaration.toString()]: handleImportDeclaration, 134 | [ts.SyntaxKind.CallExpression.toString()]: handleDynamicImport, 135 | }; 136 | 137 | const mustIgnore = (symbol: Symbol, file: SourceFile) => { 138 | const symbolLinePos = symbol 139 | .getDeclarations() 140 | .map((decl) => decl.getStartLinePos()) 141 | .reduce((currentMin, current) => Math.min(currentMin, current), Infinity); 142 | 143 | const comments = file 144 | .getDescendantAtPos(symbolLinePos) 145 | ?.getLeadingCommentRanges(); 146 | 147 | if (!comments) { 148 | return false; 149 | } 150 | 151 | return last(comments)?.getText().includes(ignoreComment); 152 | }; 153 | 154 | const lineNumber = (symbol: Symbol) => 155 | symbol.getDeclarations().map(decl => decl.getStartLineNumber()).reduce((currentMin, current) => Math.min(currentMin, current), Infinity) 156 | 157 | export const getExported = (file: SourceFile) => 158 | file.getExportSymbols().filter(symbol => !mustIgnore(symbol, file)) 159 | .map(symbol => ({ 160 | name: symbol.compilerSymbol.name, 161 | line: symbol.getDeclarations().every(decl => decl.getSourceFile() === file) ? lineNumber(symbol) : undefined, 162 | })); 163 | 164 | /* Returns all the "import './y';" imports, which must be for side effects */ 165 | export const importsForSideEffects = (file: SourceFile): IAnalysedResult[] => 166 | file 167 | .getImportDeclarations() 168 | .map(decl => ({ 169 | moduleSourceFile: getModuleSourceFile(decl), 170 | definitelyUsed: isDefinitelyUsedImport(decl) 171 | })) 172 | .filter(meta => meta.definitelyUsed && !!meta.moduleSourceFile) 173 | .map(({ moduleSourceFile }) => ({ 174 | file: moduleSourceFile, 175 | symbols: [], 176 | type: AnalysisResultTypeEnum.DEFINITELY_USED 177 | })); 178 | 179 | const exportWildCards = (file: SourceFile): IAnalysedResult[] => 180 | file 181 | .getExportDeclarations() 182 | .filter(decl => decl.getText().includes("*")) 183 | .map((decl) => ({ 184 | file: getModuleSourceFile(decl), 185 | symbols: [], 186 | type: AnalysisResultTypeEnum.DEFINITELY_USED 187 | })); 188 | 189 | const getDefinitelyUsed = (file: SourceFile): IAnalysedResult[] => ([ 190 | ...importsForSideEffects(file), 191 | ...exportWildCards(file), 192 | ]); 193 | 194 | const getReferences = ( 195 | originalList: SourceFileReferencingNodes[], 196 | skipper?: RegExp 197 | ): SourceFileReferencingNodes[] => { 198 | if (skipper) { 199 | return originalList.filter(file => 200 | !skipper.test(file.getSourceFile().compilerNode.fileName) 201 | ); 202 | } 203 | return originalList; 204 | } 205 | export const getPotentiallyUnused = (file: SourceFile, skipper?: RegExp): IAnalysedResult => { 206 | const exported = getExported(file); 207 | 208 | const idsInFile = file.getDescendantsOfKind(ts.SyntaxKind.Identifier); 209 | const referenceCounts = countBy(x => x)((idsInFile || []).map(node => node.getText())); 210 | const referencedInFile = Object.entries(referenceCounts) 211 | .reduce( 212 | (previous, [name, count]) => previous.concat(count > 1 ? [name] : []), 213 | [] 214 | ); 215 | 216 | const referenced = getReferences( 217 | file.getReferencingNodesInOtherSourceFiles(), 218 | skipper 219 | ).reduce( 220 | (previous, node: SourceFileReferencingNodes) => { 221 | const kind = node.getKind().toString(); 222 | const value = nodeHandlers?.[kind]?.(node) ?? []; 223 | 224 | return previous.concat(value); 225 | }, 226 | [] 227 | ); 228 | 229 | const unused = referenced.includes("*") ? [] : 230 | exported.filter(exp => !referenced.includes(exp.name)) 231 | .map(exp => ({ ...exp, usedInModule: referencedInFile.includes(exp.name) })) 232 | 233 | return { 234 | file: file.getFilePath(), 235 | symbols: unused, 236 | type: AnalysisResultTypeEnum.POTENTIALLY_UNUSED 237 | }; 238 | }; 239 | 240 | const emitTsConfigEntrypoints = (entrypoints: string[], onResult: OnResultType) => 241 | entrypoints.map(file => ({ 242 | file, 243 | symbols: [], 244 | type: AnalysisResultTypeEnum.DEFINITELY_USED, 245 | })).forEach(emittable => onResult(emittable)) 246 | 247 | const filterSkippedFiles = (sourceFiles: SourceFile[], skipper: RegExp | undefined) => { 248 | if (!skipper) { 249 | return sourceFiles; 250 | } 251 | 252 | return sourceFiles.filter(file => !skipper.test(file.getSourceFile().compilerNode.fileName)); 253 | } 254 | 255 | export const analyze = (project: Project, onResult: OnResultType, entrypoints: string[], skipPattern?: string) => { 256 | const skipper = skipPattern ? new RegExp(skipPattern) : undefined; 257 | 258 | filterSkippedFiles(project.getSourceFiles(), skipper) 259 | .forEach(file => { 260 | [ 261 | getPotentiallyUnused(file, skipper), 262 | ...getDefinitelyUsed(file), 263 | ].forEach(result => { 264 | if (!result.file) return // Prevent passing along a "null" filepath. Fixes #105 265 | onResult({ ...result, file: realpathSync(result.file) }) 266 | }); 267 | }); 268 | 269 | emitTsConfigEntrypoints(entrypoints, onResult); 270 | }; 271 | -------------------------------------------------------------------------------- /src/configurator.test.ts: -------------------------------------------------------------------------------- 1 | import { getConfig } from "./configurator"; 2 | describe("getConfig", () => { 3 | it("should return a sensible default config", () => { 4 | expect(getConfig()).toMatchInlineSnapshot(` 5 | Object { 6 | "project": "tsconfig.json", 7 | } 8 | `); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/configurator.ts: -------------------------------------------------------------------------------- 1 | import { cosmiconfigSync } from "cosmiconfig"; 2 | import program from "commander"; 3 | import pick from "lodash/fp/pick"; 4 | 5 | export interface IConfigInterface { 6 | project?: string; 7 | ignore?: string; 8 | error?: string; 9 | skip?: string; 10 | unusedInModule?: string; 11 | } 12 | 13 | const defaultConfig: IConfigInterface = { 14 | project: "tsconfig.json", 15 | ignore: undefined, 16 | error: undefined, 17 | skip: undefined, 18 | unusedInModule: undefined, 19 | } 20 | 21 | const onlyKnownConfigOptions = pick(Object.keys(defaultConfig)); 22 | 23 | 24 | export const getConfig = () => { 25 | const cliConfig = onlyKnownConfigOptions(program 26 | .allowUnknownOption() // required for tests passing in unknown options (ex: https://github.com/nadeesha/ts-prune/runs/1125728070) 27 | .option('-p, --project [project]', 'TS project configuration file (tsconfig.json)', 'tsconfig.json') 28 | .option('-i, --ignore [regexp]', 'Path ignore RegExp pattern') 29 | .option('-e, --error', 'Return error code if unused exports are found') 30 | .option('-s, --skip [regexp]', 'skip these files when determining whether code is used') 31 | .option('-u, --unusedInModule', 'Skip files that are used in module (marked as `used in module`)') 32 | .parse(process.argv)) 33 | 34 | const defaultConfig = { 35 | project: "tsconfig.json" 36 | } 37 | 38 | const moduleName = 'ts-prune'; 39 | const explorerSync = cosmiconfigSync(moduleName); 40 | const fileConfig = explorerSync.search()?.config; 41 | 42 | const config: IConfigInterface = { 43 | ...defaultConfig, 44 | ...fileConfig, 45 | ...cliConfig 46 | }; 47 | 48 | return config; 49 | } 50 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ignoreComment = "ts-prune-ignore-next"; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | export { IConfigInterface } from "./configurator"; 3 | export { run } from "./runner"; 4 | export { ResultSymbol } from "./analyzer"; 5 | 6 | import { getConfig } from "./configurator"; 7 | import { run } from "./runner"; 8 | 9 | const config = getConfig(); 10 | const resultCount = run(config); 11 | 12 | if (resultCount > 0 && config.error){ 13 | process.exit(1); 14 | } else { 15 | process.exit(0); 16 | } 17 | -------------------------------------------------------------------------------- /src/initializer.ts: -------------------------------------------------------------------------------- 1 | import { Project } from "ts-morph"; 2 | 3 | export const initialize = (tsConfigFilePath: string) => { 4 | const project = new Project({ tsConfigFilePath }); 5 | 6 | return { 7 | project 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/present.test.ts: -------------------------------------------------------------------------------- 1 | import { State } from "./state"; 2 | import { AnalysisResultTypeEnum } from "./analyzer"; 3 | import { present } from "./presenter"; 4 | 5 | describe("present", () => { 6 | describe("when given state with unused exports", () => { 7 | const state = new State(); 8 | 9 | [ 10 | { 11 | type: AnalysisResultTypeEnum.POTENTIALLY_UNUSED, 12 | symbols: [{ name: "foo", line: 0, usedInModule: false }], 13 | file: "foo.ts", 14 | }, 15 | { 16 | type: AnalysisResultTypeEnum.POTENTIALLY_UNUSED, 17 | symbols: [{ name: "bar", line: 0, usedInModule: false }], 18 | file: "bar.ts", 19 | }, 20 | ].forEach((result) => state.onResult(result)); 21 | 22 | it("should produce a presentable output", () => { 23 | expect(JSON.stringify(present(state))).toMatchInlineSnapshot( 24 | `"[\\"foo.ts:0 - foo\\",\\"bar.ts:0 - bar\\"]"` 25 | ); 26 | }); 27 | }); 28 | 29 | describe("when given state with no unused exports", () => { 30 | const state = new State(); 31 | 32 | [ 33 | { 34 | type: AnalysisResultTypeEnum.POTENTIALLY_UNUSED, 35 | symbols: [{ name: "foo", line: 0, usedInModule: false }], 36 | file: "foo.ts", 37 | }, 38 | { 39 | type: AnalysisResultTypeEnum.DEFINITELY_USED, 40 | symbols: [{ name: "foo", line: 0, usedInModule: false }], 41 | file: "foo.ts", 42 | }, 43 | ].forEach((result) => state.onResult(result)); 44 | 45 | it("should produce an empty output", () => { 46 | expect(JSON.stringify(present(state))).toBe(JSON.stringify([])); 47 | }); 48 | }); 49 | 50 | describe("when given state with exports used in own module", () => { 51 | const state = new State(); 52 | 53 | [ 54 | { 55 | type: AnalysisResultTypeEnum.POTENTIALLY_UNUSED, 56 | symbols: [{ name: "foo", line: 0, usedInModule: true }], 57 | file: "foo.ts", 58 | }, 59 | { 60 | type: AnalysisResultTypeEnum.POTENTIALLY_UNUSED, 61 | symbols: [{ name: "bar", line: 0, usedInModule: false }], 62 | file: "bar.ts", 63 | }, 64 | ].forEach((result) => state.onResult(result)); 65 | 66 | it("should produce a presentable output", () => { 67 | expect(JSON.stringify(present(state))).toMatchInlineSnapshot( 68 | `"[\\"foo.ts:0 - foo (used in module)\\",\\"bar.ts:0 - bar\\"]"` 69 | ); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/presenter.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import { State } from "./state"; 3 | import { ResultSymbol } from "./analyzer"; 4 | 5 | export const USED_IN_MODULE = ' (used in module)'; 6 | 7 | const formatOutput = (file: string, result: ResultSymbol) => { 8 | const {name, line, usedInModule} = result; 9 | return `${chalk.green(file)}:${chalk.yellow(line)} - ${chalk.cyan(name)}` + (usedInModule ? `${chalk.grey(USED_IN_MODULE)}` : ''); 10 | } 11 | 12 | export const present = (state: State): string[] => { 13 | const unused2D = state 14 | .definitelyUnused() 15 | .map(result => ({ 16 | file: result.file.replace(process.cwd(), "").replace(new RegExp("^/"), ""), 17 | symbols: result.symbols 18 | })) 19 | .map( 20 | ({file, symbols}) => symbols.map(sym => formatOutput(file, sym)) 21 | ); 22 | 23 | return [].concat.apply([], unused2D); 24 | }; 25 | -------------------------------------------------------------------------------- /src/runner.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import JSON5 from "json5"; 3 | import fs from "fs"; 4 | 5 | import { analyze } from "./analyzer"; 6 | import { initialize } from "./initializer"; 7 | import { State } from "./state"; 8 | import { present, USED_IN_MODULE } from "./presenter"; 9 | import { IConfigInterface } from "./configurator"; 10 | 11 | export const run = (config: IConfigInterface, output = console.log) => { 12 | const tsConfigPath = path.resolve(config.project); 13 | const { project } = initialize(tsConfigPath); 14 | const tsConfigJSON = JSON5.parse(fs.readFileSync(tsConfigPath, "utf-8")); 15 | 16 | const entrypoints: string[] = 17 | tsConfigJSON?.files?.map((file: string) => 18 | path.resolve(path.dirname(tsConfigPath), file) 19 | ) || []; 20 | 21 | const state = new State(); 22 | 23 | analyze(project, state.onResult, entrypoints, config.skip); 24 | 25 | const presented = present(state); 26 | 27 | const filterUsedInModule = config.unusedInModule !== undefined ? presented.filter(file => !file.includes(USED_IN_MODULE)) : presented; 28 | const filterIgnored = config.ignore !== undefined ? filterUsedInModule.filter(file => !file.match(config.ignore)) : filterUsedInModule; 29 | 30 | filterIgnored.forEach(value => { 31 | output(value); 32 | }); 33 | return filterIgnored.length; 34 | }; 35 | -------------------------------------------------------------------------------- /src/state.test.ts: -------------------------------------------------------------------------------- 1 | import { State } from "./state"; 2 | import { AnalysisResultTypeEnum } from "./analyzer"; 3 | 4 | describe("State", () => { 5 | describe("when given state with unused exports", () => { 6 | const state = new State(); 7 | 8 | [ 9 | { 10 | type: AnalysisResultTypeEnum.POTENTIALLY_UNUSED, 11 | symbols: [{ name: "foo", line: 0, usedInModule: false }], 12 | file: "foo.ts" 13 | }, 14 | { 15 | type: AnalysisResultTypeEnum.POTENTIALLY_UNUSED, 16 | symbols: [{ name: "bar", line: 0, usedInModule: false }], 17 | file: "bar.ts" 18 | } 19 | ].forEach(result => state.onResult(result)); 20 | 21 | it("should have definitelyUnused exports", () => { 22 | expect(state.definitelyUnused().length).toBe(2); 23 | }); 24 | }); 25 | 26 | describe("when given state with no unused exports", () => { 27 | const state = new State(); 28 | 29 | [ 30 | { 31 | type: AnalysisResultTypeEnum.POTENTIALLY_UNUSED, 32 | symbols: [{ name: "foo", line: 0, usedInModule: false }], 33 | file: "foo.ts" 34 | }, 35 | { 36 | type: AnalysisResultTypeEnum.DEFINITELY_USED, 37 | symbols: [{ name: "foo", line: 0, usedInModule: false }], 38 | file: "foo.ts" 39 | } 40 | ].forEach(result => state.onResult(result)); 41 | 42 | it("should not have definitelyUnused exports", () => { 43 | expect(state.definitelyUnused().length).toBe(0); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import { IAnalysedResult, AnalysisResultTypeEnum } from "./analyzer"; 2 | import differenceBy from "lodash/fp/differenceBy"; 3 | 4 | export class State { 5 | private results: Array = []; 6 | 7 | private resultsOfType = (type: AnalysisResultTypeEnum) => 8 | this.results.filter(r => r.type === type); 9 | 10 | onResult = (result: IAnalysedResult) => { 11 | this.results.push(result); 12 | }; 13 | 14 | definitelyUnused = () => 15 | differenceBy( 16 | result => result.file, 17 | this.resultsOfType(AnalysisResultTypeEnum.POTENTIALLY_UNUSED), 18 | this.resultsOfType(AnalysisResultTypeEnum.DEFINITELY_USED) 19 | ) 20 | .filter(result => result.symbols.length > 0) 21 | } 22 | -------------------------------------------------------------------------------- /src/util/getModuleSourceFile.ts: -------------------------------------------------------------------------------- 1 | import { ImportDeclaration, ExportDeclaration } from "ts-morph"; 2 | 3 | export const getModuleSourceFile = (decl: ImportDeclaration | ExportDeclaration) => 4 | decl.getModuleSpecifierSourceFile()?.getFilePath() ?? null; 5 | -------------------------------------------------------------------------------- /src/util/getNodesOfKind.test.ts: -------------------------------------------------------------------------------- 1 | import { Project, ts } from "ts-morph"; 2 | import { getNodesOfKind } from "./getNodesOfKind"; 3 | 4 | const starImportSrc = ` 5 | import * as foo from './foo'; 6 | import {UseFoo} from './use-foo'; 7 | 8 | const x = foo.x; 9 | const {y} = foo; 10 | const {z: {a}} = foo; 11 | const w = foo['w']; 12 | type ABC = foo.ABC; 13 | 14 | () => { 15 | () => { 16 | () => { 17 | alert(foo.y); 18 | } 19 | } 20 | } 21 | `; 22 | 23 | test("should get nodes of a kind", () => { 24 | const project = new Project(); 25 | const star = project.createSourceFile("/project/star.ts", starImportSrc); 26 | 27 | expect( 28 | getNodesOfKind(star, ts.SyntaxKind.PropertyAccessExpression).map((n) => 29 | n.getText() 30 | ) 31 | ).toEqual(["foo.x", "foo.y"]); 32 | }); 33 | -------------------------------------------------------------------------------- /src/util/getNodesOfKind.ts: -------------------------------------------------------------------------------- 1 | import { SourceFile, SyntaxKind, Node } from "ts-morph"; 2 | 3 | export function getNodesOfKind(node: SourceFile, kind: SyntaxKind): Node[] { 4 | return node.getDescendants().filter(node => node.getKind() === kind); 5 | } 6 | -------------------------------------------------------------------------------- /src/util/isDefinitelyUsedImport.ts: -------------------------------------------------------------------------------- 1 | import { ImportDeclaration } from "ts-morph"; 2 | 3 | const containsUnnamedImport = (decl: ImportDeclaration) => 4 | !decl.getImportClause(); 5 | 6 | export const isDefinitelyUsedImport = (decl: ImportDeclaration) => 7 | containsUnnamedImport(decl); 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "noImplicitAny": true, 6 | "removeComments": true, 7 | "preserveConstEnums": true, 8 | "outDir": "lib", 9 | "sourceMap": true, 10 | "declaration": true, 11 | "lib": [ 12 | "esnext" 13 | ], 14 | "esModuleInterop": true 15 | }, 16 | "files": [ 17 | "src/index.ts" 18 | ], 19 | "exclude": [ 20 | "node_modules", 21 | "**/*.spec.ts" 22 | ] 23 | } --------------------------------------------------------------------------------