├── .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 |   
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 | [](https://asciinema.org/a/liQKNmkGkedCnyHuJzzgu7uDI) [](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 |
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 | }
--------------------------------------------------------------------------------